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,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