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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +142 -0
  3. data/ARCHITECTURE.md +134 -0
  4. data/AUTHORS +106 -0
  5. data/CODE_OF_CONDUCT.md +128 -0
  6. data/LICENSE +21 -0
  7. data/README.md +1290 -0
  8. data/SECURITY.md +67 -0
  9. data/bin/contractful +128 -0
  10. data/history.md +801 -0
  11. data/lib/contractful/abstract_response.rb +301 -0
  12. data/lib/contractful/circuit_breaker.rb +221 -0
  13. data/lib/contractful/connection_pool.rb +166 -0
  14. data/lib/contractful/contract/auth.rb +257 -0
  15. data/lib/contractful/contract/config.rb +93 -0
  16. data/lib/contractful/contract/definition.rb +295 -0
  17. data/lib/contractful/contract/dsl.rb +501 -0
  18. data/lib/contractful/contract/executor.rb +492 -0
  19. data/lib/contractful/contract/openapi_import.rb +1135 -0
  20. data/lib/contractful/contract/pagination.rb +218 -0
  21. data/lib/contractful/contract/param.rb +505 -0
  22. data/lib/contractful/contract/postman_import/auth.rb +39 -0
  23. data/lib/contractful/contract/postman_import/body.rb +87 -0
  24. data/lib/contractful/contract/postman_import/operation.rb +107 -0
  25. data/lib/contractful/contract/postman_import/scope.rb +37 -0
  26. data/lib/contractful/contract/postman_import/url.rb +80 -0
  27. data/lib/contractful/contract/postman_import.rb +164 -0
  28. data/lib/contractful/contract/response_field.rb +167 -0
  29. data/lib/contractful/contract/sequence.rb +432 -0
  30. data/lib/contractful/contract/swagger2/components.rb +84 -0
  31. data/lib/contractful/contract/swagger2/operations.rb +81 -0
  32. data/lib/contractful/contract/swagger2/parameters.rb +93 -0
  33. data/lib/contractful/contract/swagger2/references.rb +55 -0
  34. data/lib/contractful/contract/swagger2.rb +55 -0
  35. data/lib/contractful/contract.rb +85 -0
  36. data/lib/contractful/exceptions.rb +328 -0
  37. data/lib/contractful/notifications/active_support.rb +40 -0
  38. data/lib/contractful/notifications.rb +138 -0
  39. data/lib/contractful/pacer.rb +161 -0
  40. data/lib/contractful/params_array.rb +85 -0
  41. data/lib/contractful/payload.rb +340 -0
  42. data/lib/contractful/platform.rb +52 -0
  43. data/lib/contractful/rate_limit.rb +133 -0
  44. data/lib/contractful/raw_response.rb +70 -0
  45. data/lib/contractful/request.rb +1483 -0
  46. data/lib/contractful/resource.rb +189 -0
  47. data/lib/contractful/response.rb +109 -0
  48. data/lib/contractful/utils.rb +202 -0
  49. data/lib/contractful/version.rb +11 -0
  50. data/lib/contractful/windows/root_certs.rb +107 -0
  51. data/lib/contractful/windows.rb +9 -0
  52. data/lib/contractful.rb +233 -0
  53. 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