contractful 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/API.md +142 -0
- data/ARCHITECTURE.md +134 -0
- data/AUTHORS +106 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +1290 -0
- data/SECURITY.md +67 -0
- data/bin/contractful +128 -0
- data/history.md +801 -0
- data/lib/contractful/abstract_response.rb +301 -0
- data/lib/contractful/circuit_breaker.rb +221 -0
- data/lib/contractful/connection_pool.rb +166 -0
- data/lib/contractful/contract/auth.rb +257 -0
- data/lib/contractful/contract/config.rb +93 -0
- data/lib/contractful/contract/definition.rb +295 -0
- data/lib/contractful/contract/dsl.rb +501 -0
- data/lib/contractful/contract/executor.rb +492 -0
- data/lib/contractful/contract/openapi_import.rb +1135 -0
- data/lib/contractful/contract/pagination.rb +218 -0
- data/lib/contractful/contract/param.rb +505 -0
- data/lib/contractful/contract/postman_import/auth.rb +39 -0
- data/lib/contractful/contract/postman_import/body.rb +87 -0
- data/lib/contractful/contract/postman_import/operation.rb +107 -0
- data/lib/contractful/contract/postman_import/scope.rb +37 -0
- data/lib/contractful/contract/postman_import/url.rb +80 -0
- data/lib/contractful/contract/postman_import.rb +164 -0
- data/lib/contractful/contract/response_field.rb +167 -0
- data/lib/contractful/contract/sequence.rb +432 -0
- data/lib/contractful/contract/swagger2/components.rb +84 -0
- data/lib/contractful/contract/swagger2/operations.rb +81 -0
- data/lib/contractful/contract/swagger2/parameters.rb +93 -0
- data/lib/contractful/contract/swagger2/references.rb +55 -0
- data/lib/contractful/contract/swagger2.rb +55 -0
- data/lib/contractful/contract.rb +85 -0
- data/lib/contractful/exceptions.rb +328 -0
- data/lib/contractful/notifications/active_support.rb +40 -0
- data/lib/contractful/notifications.rb +138 -0
- data/lib/contractful/pacer.rb +161 -0
- data/lib/contractful/params_array.rb +85 -0
- data/lib/contractful/payload.rb +340 -0
- data/lib/contractful/platform.rb +52 -0
- data/lib/contractful/rate_limit.rb +133 -0
- data/lib/contractful/raw_response.rb +70 -0
- data/lib/contractful/request.rb +1483 -0
- data/lib/contractful/resource.rb +189 -0
- data/lib/contractful/response.rb +109 -0
- data/lib/contractful/utils.rb +202 -0
- data/lib/contractful/version.rb +11 -0
- data/lib/contractful/windows/root_certs.rb +107 -0
- data/lib/contractful/windows.rb +9 -0
- data/lib/contractful.rb +233 -0
- metadata +244 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
# Lightweight in-process event notifications.
|
|
5
|
+
#
|
|
6
|
+
# The request pipeline posts lifecycle events to this hub so callers can
|
|
7
|
+
# build metrics, tracing and alerting without touching the transport:
|
|
8
|
+
#
|
|
9
|
+
# Contractful::Notifications.subscribe do |event|
|
|
10
|
+
# statsd.increment("http.#{event[:name]}", tags: event[:payload].slice(:method, :url))
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Events posted by the core:
|
|
14
|
+
#
|
|
15
|
+
# * <tt>request</tt> — method, url, attempt (1-based)
|
|
16
|
+
# * <tt>response</tt> — method, url, status, duration_ms (one per attempt
|
|
17
|
+
# that produced a response, including suppressed error responses)
|
|
18
|
+
# * <tt>retry</tt> — method, url, attempt, delay (seconds, post-jitter),
|
|
19
|
+
# cause (exception class name or HTTP status Integer)
|
|
20
|
+
#
|
|
21
|
+
# Resilience features post their own events on top of these (circuit
|
|
22
|
+
# breaker state changes, rate-limit sleeps, token refreshes).
|
|
23
|
+
#
|
|
24
|
+
# Subscribers must never raise: a failing subscriber is logged via
|
|
25
|
+
# {Contractful.log} (when set) and skipped, so observability can never
|
|
26
|
+
# break the request it observes. This module is thread-safe; the
|
|
27
|
+
# subscriber list is guarded by a mutex and posted payloads are frozen.
|
|
28
|
+
#
|
|
29
|
+
# A bridge for ActiveSupport::Notifications ships separately —
|
|
30
|
+
# <tt>require 'contractful/notifications/active_support'</tt> re-posts
|
|
31
|
+
# every event as <tt>contractual.<name></tt> ... see that file.
|
|
32
|
+
module Notifications
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@subscribers = []
|
|
35
|
+
|
|
36
|
+
# Cap for #deep_freeze to deny a cycle-driven DoS by stack overflow.
|
|
37
|
+
# The default of 100 is generous for legitimate notification payloads.
|
|
38
|
+
DEEP_FREEZE_MAX_DEPTH = 100
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Subscribe to every event. Returns an unsubscribe Proc; call it to
|
|
42
|
+
# detach the subscriber.
|
|
43
|
+
#
|
|
44
|
+
# @yield [event] every posted event
|
|
45
|
+
# @yieldparam event [Hash] <tt>{name:, payload:, at:}</tt> where +at+
|
|
46
|
+
# is a monotonic timestamp in seconds
|
|
47
|
+
# @return [Proc] unsubscribe handle
|
|
48
|
+
def subscribe(&block)
|
|
49
|
+
raise ArgumentError, 'a block is required' unless block
|
|
50
|
+
|
|
51
|
+
@mutex.synchronize { @subscribers << block }
|
|
52
|
+
-> { unsubscribe(&block) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Remove a specific subscriber block.
|
|
56
|
+
def unsubscribe(&block)
|
|
57
|
+
@mutex.synchronize { @subscribers.delete(block) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Fan an event out to all subscribers. Internal — the request pipeline
|
|
61
|
+
# calls this; application code only subscribes. Never raises.
|
|
62
|
+
#
|
|
63
|
+
# @param name [String, Symbol] event name
|
|
64
|
+
# @param payload [Hash] event attributes (deep-frozen so subscribers
|
|
65
|
+
# in the same process cannot mutate each other's view of the event)
|
|
66
|
+
# @return [void]
|
|
67
|
+
def post(name, **payload)
|
|
68
|
+
# Deep-freeze the payload: the previous call only froze the
|
|
69
|
+
# outer Hash, leaving nested String values mutable. A subscriber
|
|
70
|
+
# could call `event[:payload][:url] << "?evil"` and corrupt the
|
|
71
|
+
# URL for every other subscriber in the same process. Deep-freeze
|
|
72
|
+
# via recursion (capped at DEEP_FREEZE_MAX_DEPTH to deny a
|
|
73
|
+
# DoS-by-cycle) so all String/Array/Hash values are immutable.
|
|
74
|
+
frozen_payload = deep_freeze(payload.dup)
|
|
75
|
+
event = { name: name.to_s, payload: frozen_payload,
|
|
76
|
+
at: Process.clock_gettime(Process::CLOCK_MONOTONIC) }.freeze
|
|
77
|
+
listeners = @mutex.synchronize { @subscribers.dup }
|
|
78
|
+
listeners.each { |listener| notify(listener, event) }
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Number of attached subscribers (mainly for tests and diagnostics).
|
|
83
|
+
def subscriber_count
|
|
84
|
+
@mutex.synchronize { @subscribers.size }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Detach every subscriber (test isolation hook).
|
|
88
|
+
def clear!
|
|
89
|
+
@mutex.synchronize { @subscribers.clear }
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Recursively freeze Hash, Array and String values. Numeric and
|
|
96
|
+
# Symbol are already frozen by the runtime; Proc and other mutable
|
|
97
|
+
# objects are left untouched (a Proc that closes over mutable
|
|
98
|
+
# state stays callable — and freezing it would not stop the closure
|
|
99
|
+
# from mutating its enclosing scope).
|
|
100
|
+
def deep_freeze(obj, depth = 0)
|
|
101
|
+
return obj if obj.frozen?
|
|
102
|
+
|
|
103
|
+
guard_deep_freeze_depth(depth)
|
|
104
|
+
deep_freeze_children(obj, depth)
|
|
105
|
+
obj.freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def guard_deep_freeze_depth(depth)
|
|
109
|
+
return unless depth > DEEP_FREEZE_MAX_DEPTH
|
|
110
|
+
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
'notification payload nested deeper than ' \
|
|
113
|
+
"#{DEEP_FREEZE_MAX_DEPTH} levels — refusing to deep-freeze " \
|
|
114
|
+
'(possible cycle)'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def deep_freeze_children(obj, depth)
|
|
118
|
+
case obj
|
|
119
|
+
when Hash
|
|
120
|
+
obj.each_value { |v| deep_freeze(v, depth + 1) }
|
|
121
|
+
when Array
|
|
122
|
+
obj.each { |v| deep_freeze(v, depth + 1) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Deliver one event to one listener; a raising listener is logged (when
|
|
127
|
+
# logging is configured) and skipped so observability can never break
|
|
128
|
+
# the request it observes.
|
|
129
|
+
def notify(listener, event)
|
|
130
|
+
listener.call(event)
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
Contractful.log&.<<(
|
|
133
|
+
"contractful: notification subscriber raised #{e.class}: #{e.message}\n"
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
# Raised when the pacing budget would force a wait longer than the
|
|
5
|
+
# configured `max_wait` — fail loudly instead of sleeping unboundedly.
|
|
6
|
+
# (Contractful::Exception is itself a RuntimeError; the cop's literal
|
|
7
|
+
# lint on '< Exception' does not see through the constant.)
|
|
8
|
+
class PacingTimeout < Exception # rubocop:disable Lint/InheritException
|
|
9
|
+
# The base Contractful::Exception#initialize takes a response object,
|
|
10
|
+
# not a message — take the message explicitly.
|
|
11
|
+
def initialize(message = nil)
|
|
12
|
+
super()
|
|
13
|
+
@message = message
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def retryable?
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Proactive rate-limit enforcement: a process-wide, thread-safe sliding
|
|
22
|
+
# window that delays (or refuses) requests so a configured per-host quota
|
|
23
|
+
# is never exceeded in the first place — instead of eating 429s and
|
|
24
|
+
# retrying after the fact.
|
|
25
|
+
#
|
|
26
|
+
# Opt in per request with `pace: true` (defaults: 600 requests / 60s
|
|
27
|
+
# window, 30s max wait) or a config Hash:
|
|
28
|
+
#
|
|
29
|
+
# Contractful::Request.execute(
|
|
30
|
+
# method: :get, url: 'https://api.example.com/x',
|
|
31
|
+
# pace: { limit: 100, window: 60, max_wait: 10 }
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# Requests are paced per scheme/host/port across the whole process
|
|
35
|
+
# (threads included): each attempt — retries included — must acquire a
|
|
36
|
+
# slot before the request leaves. When the window is full, the request
|
|
37
|
+
# sleeps until the oldest slot expires (posting a `pace` notification
|
|
38
|
+
# with the delay); when that wait would exceed `max_wait`, it raises
|
|
39
|
+
# {PacingTimeout} instead of stalling silently. A sleep in the request
|
|
40
|
+
# path is exactly what pacing means — callers who cannot tolerate it
|
|
41
|
+
# should set a small `max_wait`.
|
|
42
|
+
class Pacer
|
|
43
|
+
DEFAULT_LIMIT = 600
|
|
44
|
+
DEFAULT_WINDOW = 60.0
|
|
45
|
+
DEFAULT_MAX_WAIT = 30.0
|
|
46
|
+
|
|
47
|
+
CLOCK = Process::CLOCK_MONOTONIC
|
|
48
|
+
|
|
49
|
+
attr_reader :key, :limit, :window, :max_wait
|
|
50
|
+
|
|
51
|
+
def initialize(key, limit: DEFAULT_LIMIT, window: DEFAULT_WINDOW, max_wait: DEFAULT_MAX_WAIT)
|
|
52
|
+
raise ArgumentError, 'limit must be a positive Integer' unless limit.is_a?(Integer) && limit.positive?
|
|
53
|
+
raise ArgumentError, 'window must be a positive Numeric' unless window.is_a?(Numeric) && window.positive?
|
|
54
|
+
raise ArgumentError, 'max_wait must be a non-negative Numeric' unless max_wait.is_a?(Numeric) && max_wait >= 0
|
|
55
|
+
|
|
56
|
+
@key = key
|
|
57
|
+
@limit = limit
|
|
58
|
+
@window = window
|
|
59
|
+
@max_wait = max_wait
|
|
60
|
+
@stamps = []
|
|
61
|
+
@mutex = Mutex.new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Acquire a slot, sleeping (outside the lock) until one frees up. Every
|
|
65
|
+
# actual wait posts a `pace` notification with the delay.
|
|
66
|
+
def acquire
|
|
67
|
+
loop do
|
|
68
|
+
wait = reserve_slot_or_wait
|
|
69
|
+
return if wait <= 0
|
|
70
|
+
|
|
71
|
+
enforce_max_wait!(wait)
|
|
72
|
+
Notifications.post('pace', key: @key, delay: wait.round(3),
|
|
73
|
+
limit: @limit, window: @window)
|
|
74
|
+
sleep(wait)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Requests recorded inside the current window (diagnostics/tests).
|
|
79
|
+
def pending
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
now = Process.clock_gettime(CLOCK)
|
|
82
|
+
@stamps.reject! { |t| now - t >= @window }
|
|
83
|
+
@stamps.size
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Under the lock: expire old slots, take one if the budget allows,
|
|
90
|
+
# otherwise report the wait until the oldest slot expires.
|
|
91
|
+
def reserve_slot_or_wait
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
now = Process.clock_gettime(CLOCK)
|
|
94
|
+
@stamps.reject! { |t| now - t >= @window }
|
|
95
|
+
if @stamps.size < @limit
|
|
96
|
+
@stamps << now
|
|
97
|
+
0.0
|
|
98
|
+
else
|
|
99
|
+
@stamps.first + @window - now
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def enforce_max_wait!(wait)
|
|
105
|
+
return if wait <= @max_wait
|
|
106
|
+
|
|
107
|
+
raise PacingTimeout,
|
|
108
|
+
"pace limit #{@limit}/#{@window}s for #{@key} would wait " \
|
|
109
|
+
"#{wait.round(1)}s (max_wait #{@max_wait}s)"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Process-wide registry; pacer_for lazily creates one pacer per
|
|
113
|
+
# scheme/host/port key.
|
|
114
|
+
#
|
|
115
|
+
# The previous `||=` form was not atomic: two threads racing on
|
|
116
|
+
# first use both observed @registry as nil and each built a fresh
|
|
117
|
+
# Registry — one was discarded, losing track of the pacers it
|
|
118
|
+
# had created (so the per-origin rate limit diverged per-thread).
|
|
119
|
+
# Use a Mutex constant to serialize the lazy init. A constant is
|
|
120
|
+
# used because methods defined inside `class << self` execute with
|
|
121
|
+
# self = the class, but instance variables assigned inside the
|
|
122
|
+
# `class << self` block live on the singleton class — the two
|
|
123
|
+
# scopes do not match.
|
|
124
|
+
REGISTRY_MUTEX = Mutex.new
|
|
125
|
+
|
|
126
|
+
class << self
|
|
127
|
+
def registry
|
|
128
|
+
REGISTRY_MUTEX.synchronize { @registry ||= Registry.new }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reset_registry
|
|
132
|
+
REGISTRY_MUTEX.synchronize { @registry = nil }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Lazily-materializing pacer store. The first configuration for an
|
|
137
|
+
# origin wins: later calls for the same scheme/host/port reuse the
|
|
138
|
+
# existing pacer and ignore their limit/window/max_wait options.
|
|
139
|
+
class Registry
|
|
140
|
+
def initialize
|
|
141
|
+
@pacers = {}
|
|
142
|
+
@mutex = Mutex.new
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def pacer_for(uri, limit: DEFAULT_LIMIT, window: DEFAULT_WINDOW, max_wait: DEFAULT_MAX_WAIT)
|
|
146
|
+
key = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
147
|
+
@mutex.synchronize do
|
|
148
|
+
@pacers[key] ||= Pacer.new(key, limit: limit, window: window, max_wait: max_wait)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def clear!
|
|
153
|
+
@mutex.synchronize { @pacers.clear }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def size
|
|
157
|
+
@mutex.synchronize { @pacers.size }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
# The ParamsArray class is used to represent an ordered list of [key, value]
|
|
5
|
+
# pairs. Use this when you need to include a key multiple times or want
|
|
6
|
+
# explicit control over parameter ordering.
|
|
7
|
+
#
|
|
8
|
+
# Most of the request payload & parameter functions normally accept a Hash of
|
|
9
|
+
# keys => values, which does not allow for duplicated keys.
|
|
10
|
+
#
|
|
11
|
+
# @see Contractful::Utils.encode_query_string
|
|
12
|
+
# @see Contractful::Utils.flatten_params
|
|
13
|
+
#
|
|
14
|
+
class ParamsArray
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
# @param array [Array<Array>] An array of parameter key,value pairs. These
|
|
18
|
+
# pairs may be 2 element arrays [key, value] or single element hashes
|
|
19
|
+
# {key => value}. They may also be single element arrays to represent a
|
|
20
|
+
# key with no value.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# >> ParamsArray.new([[:foo, 123], [:foo, 456], [:bar, 789]])
|
|
24
|
+
# This will be encoded as "foo=123&foo=456&bar=789"
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# >> ParamsArray.new({foo: 123, bar: 456})
|
|
28
|
+
# This is valid, but there's no reason not to just use the Hash directly
|
|
29
|
+
# instead of a ParamsArray.
|
|
30
|
+
#
|
|
31
|
+
#
|
|
32
|
+
def initialize(array)
|
|
33
|
+
@array = process_input(array)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def each(...)
|
|
37
|
+
@array.each(...)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def empty?
|
|
41
|
+
@array.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def process_input(array)
|
|
47
|
+
array.map { |v| process_pair(v) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A pair may be:
|
|
51
|
+
# - A single element hash, e.g. {foo: 'bar'}
|
|
52
|
+
# - A two element array, e.g. ['foo', 'bar']
|
|
53
|
+
# - A one element array, e.g. ['foo']
|
|
54
|
+
#
|
|
55
|
+
# The recursive `pair.to_a` branch previously could infinite-loop on
|
|
56
|
+
# an object whose `to_a` returns itself (or another non-Hash/Array),
|
|
57
|
+
# and crashed on `nil.to_a` (which returns []) because the recursive
|
|
58
|
+
# call would then raise IndexError on `pair.fetch(0)`. Reject those
|
|
59
|
+
# inputs up front with a clear error message.
|
|
60
|
+
def process_pair(pair)
|
|
61
|
+
case pair
|
|
62
|
+
when Hash
|
|
63
|
+
raise ArgumentError.new("Bad # of fields for pair: #{pair.inspect}") if pair.length != 1
|
|
64
|
+
|
|
65
|
+
pair.to_a.fetch(0)
|
|
66
|
+
when Array
|
|
67
|
+
raise ArgumentError.new("Bad # of fields for pair: #{pair.inspect}") if pair.length > 2
|
|
68
|
+
|
|
69
|
+
[pair.fetch(0), pair[1]]
|
|
70
|
+
else
|
|
71
|
+
# recurse, converting any non-array to an array — but only if
|
|
72
|
+
# to_a actually returns an Array (to_a on a custom Object can
|
|
73
|
+
# legitimately return something else, e.g. an Enumerator).
|
|
74
|
+
coerced = pair.to_a
|
|
75
|
+
unless coerced.is_a?(Array)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
'ParamsArray pair must be a Hash, Array, or respond to ' \
|
|
78
|
+
"to_a with an Array, got #{pair.class}: #{pair.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
process_pair(coerced)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|