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,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Contractful
|
|
6
|
+
module Contract
|
|
7
|
+
# Raised when paginating a contract goes wrong: too many pages, an
|
|
8
|
+
# unparseable page body, or items that cannot be extracted.
|
|
9
|
+
class PaginationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Pagination engine for contracts declared with `paginated`.
|
|
12
|
+
#
|
|
13
|
+
# Two styles are supported:
|
|
14
|
+
#
|
|
15
|
+
# * +:cursor+ — the server returns an opaque cursor for the next page
|
|
16
|
+
# (page token style):
|
|
17
|
+
#
|
|
18
|
+
# paginated style: :cursor, cursor_param: :pageToken,
|
|
19
|
+
# next_cursor_path: 'nextPageToken',
|
|
20
|
+
# items_path: 'orders'
|
|
21
|
+
#
|
|
22
|
+
# * +:offset+ — classic limit/offset paging; iteration stops when the
|
|
23
|
+
# declared +has_more_path+ is false, or when a page comes back empty:
|
|
24
|
+
#
|
|
25
|
+
# paginated style: :offset, offset_param: :offset,
|
|
26
|
+
# limit_param: :limit, items_path: 'data'
|
|
27
|
+
#
|
|
28
|
+
# For every paginated contract the client class gets two generated
|
|
29
|
+
# methods (the pagination params themselves must be declared as regular
|
|
30
|
+
# contract params):
|
|
31
|
+
#
|
|
32
|
+
# client.list_orders_pages # Enumerator of validated page responses
|
|
33
|
+
# client.all_list_orders # flat Array of every item across pages
|
|
34
|
+
#
|
|
35
|
+
# Both accept the same params as the underlying contract, including the
|
|
36
|
+
# meta options (+_max_retries+, ...) and a +_max_pages:+ safety limit
|
|
37
|
+
# (default 1000) that raises {PaginationError} when exceeded — a missing
|
|
38
|
+
# stop condition must not become an infinite request loop.
|
|
39
|
+
module Pagination
|
|
40
|
+
DEFAULT_MAX_PAGES = 1000
|
|
41
|
+
|
|
42
|
+
CURSOR_KEYS = %i[cursor_param next_cursor_path].freeze
|
|
43
|
+
OFFSET_KEYS = %i[offset_param].freeze
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Validate and normalize the `paginated` DSL options at definition
|
|
48
|
+
# time, so a misconfigured pagination fails when the class loads.
|
|
49
|
+
def normalize!(options)
|
|
50
|
+
style = options[:style] ||
|
|
51
|
+
raise(InvalidDefinition,
|
|
52
|
+
'paginated requires a style: (:cursor or :offset)')
|
|
53
|
+
style = style.to_sym
|
|
54
|
+
|
|
55
|
+
keys = case style
|
|
56
|
+
when :cursor then CURSOR_KEYS
|
|
57
|
+
when :offset then OFFSET_KEYS
|
|
58
|
+
else
|
|
59
|
+
raise InvalidDefinition,
|
|
60
|
+
"unknown pagination style #{style.inspect} " \
|
|
61
|
+
'(expected :cursor or :offset)'
|
|
62
|
+
end
|
|
63
|
+
keys.each do |key|
|
|
64
|
+
unless options[key]
|
|
65
|
+
raise InvalidDefinition,
|
|
66
|
+
"paginated style #{style.inspect} requires #{key.inspect}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
style: style,
|
|
72
|
+
cursor_param: options[:cursor_param]&.to_sym,
|
|
73
|
+
next_cursor_path: options[:next_cursor_path],
|
|
74
|
+
offset_param: options[:offset_param]&.to_sym,
|
|
75
|
+
limit_param: options[:limit_param]&.to_sym,
|
|
76
|
+
items_path: options[:items_path],
|
|
77
|
+
has_more_path: options[:has_more_path]
|
|
78
|
+
}.freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Enumerator of page responses (each already validated by the
|
|
82
|
+
# contract's response declaration).
|
|
83
|
+
def each_page(instance, definition, params, &block)
|
|
84
|
+
return enum_for(:each_page, instance, definition, params) unless block
|
|
85
|
+
|
|
86
|
+
each_parsed(instance, definition, params) do |response, _parsed|
|
|
87
|
+
yield response
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Flat Array of every item across every page.
|
|
92
|
+
def all_items(instance, definition, params)
|
|
93
|
+
result = []
|
|
94
|
+
each_parsed(instance, definition, params) do |_response, parsed|
|
|
95
|
+
items = extract_items(parsed, definition.pagination, definition)
|
|
96
|
+
unless items.is_a?(Array)
|
|
97
|
+
raise PaginationError,
|
|
98
|
+
"items at #{definition.pagination[:items_path].inspect} are " \
|
|
99
|
+
"#{items.class}, expected Array for #{definition.describe}"
|
|
100
|
+
end
|
|
101
|
+
result.concat(items)
|
|
102
|
+
end
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Core loop: calls the contract page by page, injecting the cursor or
|
|
107
|
+
# advancing the offset, validating every page through the contract.
|
|
108
|
+
def each_parsed(instance, definition, params, &block)
|
|
109
|
+
return enum_for(:each_parsed, instance, definition, params) unless block
|
|
110
|
+
|
|
111
|
+
pagination = definition.pagination
|
|
112
|
+
params = params.dup
|
|
113
|
+
max_pages = params.delete(:_max_pages) || DEFAULT_MAX_PAGES
|
|
114
|
+
unless max_pages.is_a?(Integer) && max_pages.positive?
|
|
115
|
+
raise PaginationError,
|
|
116
|
+
":_max_pages must be a positive Integer, got #{max_pages.inspect}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resume support: an explicitly supplied cursor/offset drives page 1.
|
|
120
|
+
resume = if pagination[:style] == :cursor
|
|
121
|
+
params.delete(pagination[:cursor_param])
|
|
122
|
+
else
|
|
123
|
+
params.delete(pagination[:offset_param])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
pages = 0
|
|
127
|
+
loop do
|
|
128
|
+
pages += 1
|
|
129
|
+
if pages > max_pages
|
|
130
|
+
raise PaginationError,
|
|
131
|
+
"exceeded #{max_pages} pages for #{definition.describe} " \
|
|
132
|
+
'(pass _max_pages: to raise the limit, or check the ' \
|
|
133
|
+
'stop condition)'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
call_params = params.dup
|
|
137
|
+
if pagination[:style] == :cursor
|
|
138
|
+
call_params[pagination[:cursor_param]] = resume unless resume.nil?
|
|
139
|
+
else
|
|
140
|
+
call_params[pagination[:offset_param]] = resume unless resume.nil?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
response = Executor.call(instance, definition, call_params)
|
|
144
|
+
parsed = parse_json(response, definition)
|
|
145
|
+
items = extract_items(parsed, pagination, definition)
|
|
146
|
+
|
|
147
|
+
yield response, parsed
|
|
148
|
+
|
|
149
|
+
break unless continue?(pagination, parsed, items)
|
|
150
|
+
|
|
151
|
+
resume = if pagination[:style] == :cursor
|
|
152
|
+
dig(parsed, pagination[:next_cursor_path])
|
|
153
|
+
else
|
|
154
|
+
(resume || 0) + items.length
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def continue?(pagination, parsed, items)
|
|
160
|
+
if pagination[:has_more_path]
|
|
161
|
+
has_more = dig(parsed, pagination[:has_more_path])
|
|
162
|
+
return false unless has_more
|
|
163
|
+
|
|
164
|
+
return true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if pagination[:style] == :cursor
|
|
168
|
+
# Continue while the server hands out a non-empty next cursor.
|
|
169
|
+
cursor = dig(parsed, pagination[:next_cursor_path])
|
|
170
|
+
return !(cursor.nil? || cursor == '')
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Offset without has_more: stop when a page comes back empty.
|
|
174
|
+
!items.empty?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Extract items from a parsed page body. Without an items_path the
|
|
178
|
+
# body itself must be the collection (a bare JSON Array).
|
|
179
|
+
def extract_items(parsed, pagination, definition)
|
|
180
|
+
path = pagination[:items_path]
|
|
181
|
+
if path
|
|
182
|
+
items = dig(parsed, path)
|
|
183
|
+
items.nil? ? [] : items
|
|
184
|
+
elsif parsed.is_a?(Array)
|
|
185
|
+
parsed
|
|
186
|
+
else
|
|
187
|
+
raise PaginationError,
|
|
188
|
+
"cannot extract items from #{parsed.class} for " \
|
|
189
|
+
"#{definition.describe} — declare items_path: in paginated"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Parse a page body as JSON.
|
|
194
|
+
def parse_json(response, definition)
|
|
195
|
+
JSON.parse(response.body)
|
|
196
|
+
rescue JSON::ParserError, TypeError => e
|
|
197
|
+
raise PaginationError,
|
|
198
|
+
"page body is not valid JSON for #{definition.describe}: #{e.message}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Traverse a dotted path ('data.orders') through String- and
|
|
202
|
+
# Symbol-keyed hashes.
|
|
203
|
+
def dig(data, dotted_path)
|
|
204
|
+
return nil unless dotted_path
|
|
205
|
+
|
|
206
|
+
dotted_path.to_s.split('.').reduce(data) do |acc, key|
|
|
207
|
+
break nil if acc.nil? || !acc.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
if acc.key?(key)
|
|
210
|
+
acc[key]
|
|
211
|
+
else
|
|
212
|
+
(acc.key?(key.to_sym) ? acc[key.to_sym] : nil)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|