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
data/README.md
ADDED
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
# Contractful — a contract-first HTTP client for Ruby
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/contractful)
|
|
4
|
+
[](https://github.com/shubhamtaywade82/contractful/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
Contractful is a Ruby HTTP client whose behavior is driven by **contracts**:
|
|
7
|
+
closed request schemas that reject bad input before the network is touched,
|
|
8
|
+
open response schemas that treat unknown fields as data, and contract-level
|
|
9
|
+
auth, idempotency, pagination, rate limits and error classification. Client
|
|
10
|
+
classes are generated from contract definitions via metaprogramming, an
|
|
11
|
+
OpenAPI import helper turns existing specs into contracts, and a
|
|
12
|
+
recorded-sequence helper replays captured vendor interactions in tests.
|
|
13
|
+
|
|
14
|
+
* Main page: https://github.com/shubhamtaywade82/contractful
|
|
15
|
+
|
|
16
|
+
### Heritage
|
|
17
|
+
|
|
18
|
+
This project began as a modernization of the original
|
|
19
|
+
[rest-client](https://github.com/rest-client/rest-client) gem, which was
|
|
20
|
+
archived by its maintainers in 2019 and had fallen behind modern Ruby
|
|
21
|
+
releases. That modernization kept the gem alive on Ruby 3.2 through 4.0,
|
|
22
|
+
moved to current development tooling, and added long-requested options such
|
|
23
|
+
as `:write_timeout` and `:ssl_min_version` / `:ssl_max_version`. The
|
|
24
|
+
codebase then evolved into a contract-first client, and at version 0.1.0 it
|
|
25
|
+
was renamed **contractful** — the `rest-client` name on RubyGems belongs to
|
|
26
|
+
the original project, and no version of this codebase was ever published
|
|
27
|
+
under it. The MIT license and the upstream copyright are preserved in
|
|
28
|
+
[LICENSE](LICENSE); comments that cite upstream issues link to the original
|
|
29
|
+
repository on purpose. See [history.md](history.md) for the full list of
|
|
30
|
+
changes.
|
|
31
|
+
|
|
32
|
+
### Project documentation
|
|
33
|
+
|
|
34
|
+
The complete engineering documentation set lives at the root and under
|
|
35
|
+
`docs/`:
|
|
36
|
+
|
|
37
|
+
| Document | Purpose |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| [PRD.md](PRD.md) | Product goals, non-goals, feature requirements |
|
|
40
|
+
| [DESIGN.md](DESIGN.md) | Developer-experience and interface design principles |
|
|
41
|
+
| [ARCHITECTURE.md](ARCHITECTURE.md) | Layers, module map, request lifecycle |
|
|
42
|
+
| [API.md](API.md) | Normative public API reference (incl. the contract DSL) |
|
|
43
|
+
| [DATABASE.md](DATABASE.md) | No-database decision, in-memory state, fixture policy |
|
|
44
|
+
| [AGENTS.md](AGENTS.md) | Rules for AI coding agents working in this repo |
|
|
45
|
+
| [CONTRIBUTING.md](CONTRIBUTING.md) | Human contributor workflow |
|
|
46
|
+
| [SECURITY.md](SECURITY.md) | Vulnerability reporting and disclosure policy |
|
|
47
|
+
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant (2.1) |
|
|
48
|
+
| [docs/guides/raw-http-deep-dive.md](docs/guides/raw-http-deep-dive.md) | ActiveResource facade, redirect chains, result-handling styles, streaming, shell, hooks |
|
|
49
|
+
| [docs/requirements/REQUIREMENTS.md](docs/requirements/REQUIREMENTS.md) | Traceability: requirements ↔ specs ↔ status |
|
|
50
|
+
| [docs/implementation/IMPLEMENTATION_PLAN.md](docs/implementation/IMPLEMENTATION_PLAN.md) | Roadmap workstreams and sequencing |
|
|
51
|
+
| [docs/implementation/SETUP.md](docs/implementation/SETUP.md) | Tested local development setup |
|
|
52
|
+
| [docs/engineering/TESTING.md](docs/engineering/TESTING.md) | Test strategy, organization, known traps |
|
|
53
|
+
| [docs/engineering/QUALITY_GATES.md](docs/engineering/QUALITY_GATES.md) | Merge/release gates and enforcement |
|
|
54
|
+
| [docs/engineering/ERROR_HANDLING.md](docs/engineering/ERROR_HANDLING.md) | Exception model, retryable vs terminal |
|
|
55
|
+
| [docs/engineering/INTEGRATIONS.md](docs/engineering/INTEGRATIONS.md) | Dependencies, platforms, downstream consumers |
|
|
56
|
+
| [docs/security/THREAT_MODEL.md](docs/security/THREAT_MODEL.md) | Assets, trust boundaries, mitigations |
|
|
57
|
+
| [docs/security/AGENT_TOOL_POLICY.md](docs/security/AGENT_TOOL_POLICY.md) | Permission boundaries for autonomous agents |
|
|
58
|
+
| [docs/operations/DELIVERY.md](docs/operations/DELIVERY.md) | Versioning, releases, rollback |
|
|
59
|
+
| [docs/operations/OBSERVABILITY.md](docs/operations/OBSERVABILITY.md) | Logging, diagnostics, health signals |
|
|
60
|
+
| [docs/operations/RUNBOOK.md](docs/operations/RUNBOOK.md) | Symptom → diagnosis → fix field guide |
|
|
61
|
+
| [docs/adr/](docs/adr/) | Architecture decision records (0001–0003) |
|
|
62
|
+
| [docs/agents/AGENT_WORKFLOWS.md](docs/agents/AGENT_WORKFLOWS.md) | Phase-by-phase agent execution playbook |
|
|
63
|
+
| [docs/agents/MCP_INTEGRATION.md](docs/agents/MCP_INTEGRATION.md) | MCP policy (conditional document) |
|
|
64
|
+
|
|
65
|
+
## Requirements
|
|
66
|
+
|
|
67
|
+
MRI Ruby 3.2 and newer are supported, including Ruby 4.0. The full test suite
|
|
68
|
+
runs in CI against Ruby 3.2, 3.3, 3.4, and 4.0 (plus ruby-head on a best-effort
|
|
69
|
+
basis). Alternative interpreters compatible with Ruby 3.2+ such as JRuby
|
|
70
|
+
should work as well.
|
|
71
|
+
|
|
72
|
+
Ruby 2.x versions are no longer supported. Ruby 3.0 and 3.1 have reached
|
|
73
|
+
end-of-life upstream and are no longer available on the GitHub Actions
|
|
74
|
+
ubuntu-26.04 runner image, so they have been removed from the CI matrix and
|
|
75
|
+
the gemspec's `required_ruby_version` floor has been raised to `>= 3.2`. The
|
|
76
|
+
runtime still works on 3.0/3.1 in principle, but only 3.2+ is tested and
|
|
77
|
+
supported.
|
|
78
|
+
|
|
79
|
+
The contractful gem depends on these other gems for usage at runtime:
|
|
80
|
+
|
|
81
|
+
* [mime-types](http://rubygems.org/gems/mime-types)
|
|
82
|
+
* [netrc](http://rubygems.org/gems/netrc)
|
|
83
|
+
* [http-accept](https://rubygems.org/gems/http-accept)
|
|
84
|
+
* [http-cookie](https://rubygems.org/gems/http-cookie)
|
|
85
|
+
|
|
86
|
+
There are also several development dependencies. It's recommended to use
|
|
87
|
+
[bundler](http://bundler.io/) to manage these dependencies for hacking on
|
|
88
|
+
contractful.
|
|
89
|
+
|
|
90
|
+
### Upgrading from rest-client
|
|
91
|
+
|
|
92
|
+
contractful 0.1.0 is the renamed continuation of the modernized rest-client
|
|
93
|
+
codebase. Update the gem name, the `require` path, and the `RestClient`
|
|
94
|
+
constants to `contractful` / `Contractful`; the request API and options are
|
|
95
|
+
otherwise unchanged.
|
|
96
|
+
|
|
97
|
+
The rest-client 2.0 line, included in this codebase, cleaned up a number of
|
|
98
|
+
API warts and wrinkles. Usage is largely compatible, so many applications will
|
|
99
|
+
be able to upgrade with no changes.
|
|
100
|
+
|
|
101
|
+
Overview of significant changes:
|
|
102
|
+
|
|
103
|
+
* requires Ruby >= 3.2
|
|
104
|
+
* `Contractful::Response` objects are a subclass of `String` rather than a
|
|
105
|
+
Frankenstein monster. And `#body` or `#to_s` return a true `String` object.
|
|
106
|
+
* cleanup of exception classes, including new `Contractful::Exceptions::Timeout`
|
|
107
|
+
* improvements to handling of redirects: responses and history are properly
|
|
108
|
+
exposed
|
|
109
|
+
* major changes to cookie support: cookie jars are used for browser-like
|
|
110
|
+
behavior throughout
|
|
111
|
+
* encoding: Content-Type charset response headers are used to automatically set
|
|
112
|
+
the encoding of the response string
|
|
113
|
+
* HTTP params: handling of GET/POST params is more consistent and sophisticated
|
|
114
|
+
for deeply nested hash objects, and `ParamsArray` can be used to pass ordered
|
|
115
|
+
params
|
|
116
|
+
* improved proxy support with per-request proxy configuration, plus the ability
|
|
117
|
+
to disable proxies set by environment variables
|
|
118
|
+
* default request headers: contractful sets `Accept: */*` and
|
|
119
|
+
`User-Agent: contractful/...`
|
|
120
|
+
|
|
121
|
+
See [history.md](./history.md) for a more complete description of changes.
|
|
122
|
+
|
|
123
|
+
## Usage: Raw URL
|
|
124
|
+
|
|
125
|
+
Basic usage:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
require 'contractful'
|
|
129
|
+
|
|
130
|
+
Contractful.get(url, headers={})
|
|
131
|
+
|
|
132
|
+
Contractful.post(url, payload, headers={})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
In the high level helpers, only POST, PATCH, and PUT take a payload argument.
|
|
136
|
+
To pass a payload with other HTTP verbs or to pass more advanced options, use
|
|
137
|
+
`Contractful::Request.execute` instead.
|
|
138
|
+
|
|
139
|
+
More detailed examples:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
require 'contractful'
|
|
143
|
+
|
|
144
|
+
Contractful.get 'http://example.com/resource'
|
|
145
|
+
|
|
146
|
+
Contractful.get 'http://example.com/resource', {params: {id: 50, 'foo' => 'bar'}}
|
|
147
|
+
|
|
148
|
+
Contractful.get 'https://user:password@example.com/private/resource', {accept: :json}
|
|
149
|
+
|
|
150
|
+
Contractful.post 'http://example.com/resource', {param1: 'one', nested: {param2: 'two'}}
|
|
151
|
+
|
|
152
|
+
Contractful.post "http://example.com/resource", {'x' => 1}.to_json, {content_type: :json, accept: :json}
|
|
153
|
+
|
|
154
|
+
Contractful.delete 'http://example.com/resource'
|
|
155
|
+
|
|
156
|
+
>> response = Contractful.get 'http://example.com/resource'
|
|
157
|
+
=> <Contractful::Response 200 "<!doctype h...">
|
|
158
|
+
>> response.code
|
|
159
|
+
=> 200
|
|
160
|
+
>> response.cookies
|
|
161
|
+
=> {"Foo"=>"BAR", "QUUX"=>"QUUUUX"}
|
|
162
|
+
>> response.headers
|
|
163
|
+
=> {:content_type=>"text/html; charset=utf-8", :cache_control=>"private" ... }
|
|
164
|
+
>> response.body
|
|
165
|
+
=> "<!doctype html>\n<html>\n<head>\n <title>Example Domain</title>\n\n ..."
|
|
166
|
+
|
|
167
|
+
Contractful.post( url,
|
|
168
|
+
{
|
|
169
|
+
:transfer => {
|
|
170
|
+
:path => '/foo/bar',
|
|
171
|
+
:owner => 'that_guy',
|
|
172
|
+
:group => 'those_guys'
|
|
173
|
+
},
|
|
174
|
+
:upload => {
|
|
175
|
+
:file => File.new(path, 'rb')
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
## Passing advanced options
|
|
180
|
+
|
|
181
|
+
The top level helper methods like Contractful.get accept a headers hash as
|
|
182
|
+
their last argument and don't allow passing more complex options. But these
|
|
183
|
+
helpers are just thin wrappers around `Contractful::Request.execute`.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com/resource',
|
|
187
|
+
timeout: 10)
|
|
188
|
+
|
|
189
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com/resource',
|
|
190
|
+
ssl_ca_file: 'myca.pem',
|
|
191
|
+
ssl_ciphers: 'AESGCM:!aNULL')
|
|
192
|
+
```
|
|
193
|
+
You can also use this to pass a payload for HTTP verbs like DELETE, where the
|
|
194
|
+
`Contractful.delete` helper doesn't accept a payload.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
Contractful::Request.execute(method: :delete, url: 'http://example.com/resource',
|
|
198
|
+
payload: 'foo', headers: {myheader: 'bar'})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Due to unfortunate choices in the original API, the params used to populate the
|
|
202
|
+
query string are actually taken out of the headers hash. So if you want to pass
|
|
203
|
+
both the params hash and more complex options, use the special key
|
|
204
|
+
`:params` in the headers hash. This design may change in a future major
|
|
205
|
+
release.
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com/resource',
|
|
209
|
+
timeout: 10, headers: {params: {foo: 'bar'}})
|
|
210
|
+
|
|
211
|
+
➔ GET http://example.com/resource?foo=bar
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Automatic retries
|
|
215
|
+
|
|
216
|
+
Requests can be retried automatically when they fail transiently. Opt in
|
|
217
|
+
with `:max_retries`; delays between attempts grow exponentially from
|
|
218
|
+
`:retry_interval` (default 0.5s):
|
|
219
|
+
`retry_interval`, `retry_interval * 2`, `retry_interval * 4`, ...
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com/resource',
|
|
223
|
+
max_retries: 3, retry_interval: 0.5)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Network failures are retried by default: open/read/write timeouts, a server
|
|
227
|
+
that breaks the connection (`EOFError`), connection reset/abort, and broken
|
|
228
|
+
pipes. HTTP error responses (4xx/5xx) are not treated as network failures;
|
|
229
|
+
retryable statuses are handled by the classification below, and redirects
|
|
230
|
+
behave as usual.
|
|
231
|
+
|
|
232
|
+
The error model classifies failures as **retryable vs terminal**. HTTP
|
|
233
|
+
statuses 408, 425, 429, 500, 502, 503 and 504
|
|
234
|
+
(`Contractful::Exceptions::RETRIABLE_STATUSES`) are treated as transient like
|
|
235
|
+
network failures and retried when `:max_retries` is positive. Between
|
|
236
|
+
attempts the server's `Retry-After` header is honored (delta-seconds or
|
|
237
|
+
HTTP-date, capped by `:max_retry_after`, default 60) instead of blind
|
|
238
|
+
backoff. Opt out entirely with `retry_after: false`. The classification is
|
|
239
|
+
exposed for your own retry logic too:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
Contractful::Exceptions.retryable?(429) # => true
|
|
243
|
+
Contractful::Exceptions.retryable?(404) # => false
|
|
244
|
+
Contractful::Exceptions.retryable?(ex) # exception or network error
|
|
245
|
+
Contractful::ServiceUnavailable.new(response).retryable? # => true
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Every other 4xx (401, 404, 422, ...) is terminal: retrying will not fix a
|
|
249
|
+
bad token, a missing resource or a bad payload. Retryable-status retries
|
|
250
|
+
also apply inside the block/error-suppression path, so contract clients get
|
|
251
|
+
them via `_max_retries:` as well.
|
|
252
|
+
|
|
253
|
+
Retries are limited to idempotent operations. GET, HEAD, OPTIONS, PUT and
|
|
254
|
+
DELETE are idempotent under RFC 9110, so they retry without any declaration.
|
|
255
|
+
POST and PATCH are not: a request that timed out may already have been
|
|
256
|
+
processed, and retrying it can repeat the operation (an order placed twice,
|
|
257
|
+
for example). A positive `:max_retries` on such a request raises
|
|
258
|
+
`ArgumentError` before any network traffic. Declare the request idempotent
|
|
259
|
+
only when the server deduplicates requests, for example with an
|
|
260
|
+
`Idempotency-Key` header:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
Contractful::Request.execute(method: :post, url: 'https://api.example.com/orders',
|
|
264
|
+
payload: body, idempotent: true, max_retries: 2)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Contract clients get the same rule: `_max_retries:` on a non-idempotent
|
|
268
|
+
contract raises, while `idempotent true` on the contract (see
|
|
269
|
+
[Idempotency keys](#idempotency-keys)) or a per-call `_idempotency_key:`
|
|
270
|
+
makes the retries safe.
|
|
271
|
+
|
|
272
|
+
Request payloads are rewound between attempts, so bodies are retransmitted in
|
|
273
|
+
full. If a payload cannot be rewound (for example a non-seekable request
|
|
274
|
+
stream), the request is not retried and the original network error is raised.
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
# 1st attempt times out, retries after 0.5s, 2nd attempt succeeds
|
|
278
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com',
|
|
279
|
+
max_retries: 2, open_timeout: 2)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Retrying in lockstep is its own outage amplifier:
|
|
283
|
+
when many clients hit the same failing service, identical exponential
|
|
284
|
+
backoff makes them all knock again at the same instant. `:retry_jitter`
|
|
285
|
+
randomizes the computed backoff so retries spread out — `:full` draws from
|
|
286
|
+
`[0, backoff]`, `:equal` from `[backoff, 2 * backoff]`, and `:decorrelated`
|
|
287
|
+
follows the AWS "Exponential Backoff and Jitter" recipe (`:full` is what it
|
|
288
|
+
recommends for most systems). Jitter applies only to the computed backoff:
|
|
289
|
+
a server's `Retry-After` is still honored exactly as sent. A seeded
|
|
290
|
+
`:retry_rng` (any object answering `#rand(range)`) makes tests
|
|
291
|
+
deterministic.
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com',
|
|
295
|
+
max_retries: 3, retry_jitter: :full)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Persistent connections (keep-alive)
|
|
299
|
+
|
|
300
|
+
Pass `persistent: true` to reuse the underlying TCP/TLS
|
|
301
|
+
connection across requests via the process-wide connection pool, instead of
|
|
302
|
+
opening a new connection for every request. This saves the connection and TLS
|
|
303
|
+
handshake overhead when making many requests to the same host.
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
Contractful::Request.execute(method: :get, url: 'https://api.example.com/things',
|
|
307
|
+
persistent: true)
|
|
308
|
+
|
|
309
|
+
Contractful::Request.execute(method: :post, url: 'https://api.example.com/things',
|
|
310
|
+
payload: ..., persistent: true)
|
|
311
|
+
|
|
312
|
+
Contractful.connection_pool.stats # => {idle: 1, checked_out: 0}
|
|
313
|
+
Contractful.connection_pool.clear! # close all pooled connections
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
How it behaves:
|
|
317
|
+
|
|
318
|
+
- Connections are pooled per scheme/host/port plus proxy and SSL settings.
|
|
319
|
+
Requests to different hosts (or through different proxies) get their own
|
|
320
|
+
pooled connections, and redirects automatically use the pool for the target
|
|
321
|
+
host.
|
|
322
|
+
- A connection is never shared between concurrent requests; if no idle
|
|
323
|
+
connection is available, a new one is opened (just like a non-persistent
|
|
324
|
+
request). At most a few idle connections per host are retained
|
|
325
|
+
(`Contractful::ConnectionPool.new(max_idle: 8)` by default).
|
|
326
|
+
- After a network failure the connection is discarded rather than reused;
|
|
327
|
+
HTTP error responses (4xx/5xx) do not affect the connection.
|
|
328
|
+
- Stale connections closed by the server are detected and re-established on
|
|
329
|
+
the next use.
|
|
330
|
+
|
|
331
|
+
Call `Contractful.connection_pool.clear!` before forking a process or when you
|
|
332
|
+
know connections have gone stale.
|
|
333
|
+
|
|
334
|
+
## Circuit breaker
|
|
335
|
+
|
|
336
|
+
Retries answer transient errors; a circuit breaker
|
|
337
|
+
answers a host that is *down* — without one, every request still pays the
|
|
338
|
+
full timeout and every retry amplifies the load on something that is
|
|
339
|
+
already dying. Opt in per request with `breaker: true` (defaults) or a
|
|
340
|
+
config Hash:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
Contractful::Request.execute(method: :get, url: 'https://api.example.com/x',
|
|
344
|
+
breaker: { threshold: 5, cooldown: 30 })
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Counted as failures: network errors and retryable statuses (408/425/429/5xx)
|
|
348
|
+
from requests whose retries are exhausted; client errors like 404 never
|
|
349
|
+
open a circuit. After `threshold` consecutive failures every request to
|
|
350
|
+
that scheme/host/port fails fast with `Contractful::CircuitOpenError` for
|
|
351
|
+
`cooldown` seconds; the next request after the cooldown probes the host
|
|
352
|
+
(half-open) and its outcome closes or re-opens the circuit. Breakers are
|
|
353
|
+
process-wide and thread-safe; state transitions post a `circuit`
|
|
354
|
+
notification (see [Event notifications](#event-notifications)), so metrics
|
|
355
|
+
can watch open/half-open/closed as a first-class signal. The first breaker
|
|
356
|
+
configuration for a scheme/host/port wins; later `breaker:` values for the
|
|
357
|
+
same origin reuse the existing breaker.
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
Contractful::Notifications.subscribe do |event|
|
|
361
|
+
next unless event[:name] == 'circuit'
|
|
362
|
+
puts "circuit #{event[:payload][:key]}: #{event[:payload][:from]} -> #{event[:payload][:state]}"
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Rate-limit pacing
|
|
367
|
+
|
|
368
|
+
The breaker reacts to failure; pacing prevents the 429s in the first place.
|
|
369
|
+
Opt in per request with `pace: true` (defaults: 600 requests per 60s window)
|
|
370
|
+
or a config Hash:
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
Contractful::Request.execute(method: :get, url: 'https://api.example.com/x',
|
|
374
|
+
pace: { limit: 100, window: 60, max_wait: 10 })
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Pacing is per scheme/host/port across the whole process (all threads
|
|
378
|
+
included) using a sliding window: every attempt — retries included —
|
|
379
|
+
acquires a slot before the request leaves. The first `pace:` configuration
|
|
380
|
+
for an origin wins; later values reuse the existing pacer. A full window
|
|
381
|
+
makes the request sleep until the oldest slot expires (posting a `pace`
|
|
382
|
+
notification with the delay); a wait longer than `max_wait` raises
|
|
383
|
+
`Contractful::PacingTimeout` instead of stalling silently. Callers that
|
|
384
|
+
cannot tolerate waiting should set a small `max_wait` and treat the
|
|
385
|
+
timeout as their own backpressure signal.
|
|
386
|
+
|
|
387
|
+
## Contract-first API clients
|
|
388
|
+
|
|
389
|
+
`Contractful::Contract` is a contract-first layer built on
|
|
390
|
+
heavy metaprogramming: you *declare* the HTTP shape of an API — verb, path,
|
|
391
|
+
typed parameters, expected responses — and the gem generates the client
|
|
392
|
+
methods, path builders, validation and introspection for you. The contract
|
|
393
|
+
*is* the implementation; there is no imperative request code to write or
|
|
394
|
+
drift.
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
class GitHubAPI
|
|
398
|
+
include Contractful::Contract
|
|
399
|
+
|
|
400
|
+
base 'https://api.github.com'
|
|
401
|
+
default_headers 'Accept' => 'application/vnd.github+json'
|
|
402
|
+
|
|
403
|
+
before_request do |ctx|
|
|
404
|
+
ctx[:headers]['Authorization'] = "token #{ENV['GITHUB_TOKEN']}"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
contract :list_users, 'GET /users' do
|
|
408
|
+
param :page, Integer, default: 1
|
|
409
|
+
param :per_page, Integer, inclusion: (1..100), default: 30
|
|
410
|
+
responds 200, type: :array
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
contract :create_issue, 'POST /repos/:owner/:repo/issues' do
|
|
414
|
+
param :owner, String, required: true
|
|
415
|
+
param :repo, String, required: true
|
|
416
|
+
param :title, String, required: true
|
|
417
|
+
param :labels, Array, default: []
|
|
418
|
+
responds 201, 422
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
api = GitHubAPI.new
|
|
423
|
+
api.list_users(page: 2)
|
|
424
|
+
api.create_issue(owner: 'octo', repo: 'hello', title: 'Hi', labels: ['bug'])
|
|
425
|
+
api.create_issue_url(owner: 'octo', repo: 'hello') # => "https://api.github.com/repos/octo/hello/issues"
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Typed parameters
|
|
429
|
+
|
|
430
|
+
Every value passed to a contract method is coerced and validated before any
|
|
431
|
+
HTTP traffic happens. Supported types: `String`, `Integer`, `Float`,
|
|
432
|
+
`:boolean`, `Array`, `Hash`, any class (checked with `is_a?`, then
|
|
433
|
+
`Class#contractful_coerce` if defined, then a Kernel cast) and any `Proc`
|
|
434
|
+
(its return value is used). Options: `required:`, `default:` (lazy procs
|
|
435
|
+
allowed), `inclusion:` and `coerce: false` for strict type checking.
|
|
436
|
+
|
|
437
|
+
An `Integer` param accepts an Integer, a whole Float, or a string of decimal
|
|
438
|
+
digits with an optional sign. Strings are read in base 10, so `"010"` is ten
|
|
439
|
+
and never octal. Anything else, including `NaN` and infinities, raises
|
|
440
|
+
`InvalidParam`.
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
contract :search, 'GET /search' do
|
|
444
|
+
param :q, String, required: true
|
|
445
|
+
param :page, Integer, default: 1
|
|
446
|
+
param :per_page, Integer, inclusion: (1..100), default: 25
|
|
447
|
+
param :sort, String, inclusion: %w[asc desc], default: 'desc'
|
|
448
|
+
end
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
A param can travel in a request header or in a cookie instead of the query
|
|
452
|
+
string or the payload. `header:` names the header and `cookie:` the cookie:
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
contract :get_item, 'GET /items/:id' do
|
|
456
|
+
param :request_id, String, header: 'X-Request-Id', required: true
|
|
457
|
+
param :session, String, cookie: 'session_id'
|
|
458
|
+
param :trace, Integer, header: 'X-Trace', default: 1
|
|
459
|
+
end
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Only scalar types (`String`, `Integer`, `Float`, `:boolean`) can be sent this
|
|
463
|
+
way, and a header or cookie param cannot be a path placeholder. Values are
|
|
464
|
+
checked before any traffic: a header value with a control character (CR or LF
|
|
465
|
+
would split the header block) and a cookie value outside RFC 6265
|
|
466
|
+
cookie-octets raise `InvalidParam`.
|
|
467
|
+
|
|
468
|
+
Params also carry the closed-schema constraints known from JSON Schema /
|
|
469
|
+
OpenAPI: `min:` / `max:` (inclusive bounds on any Comparable), `length:`
|
|
470
|
+
(exact Integer or a Range, over String/Array/Hash) and `pattern:` (Regexp on
|
|
471
|
+
Strings). Constraints are enforced after coercion, so a `min: 1` Integer
|
|
472
|
+
param rejects the string `"0"` exactly like the integer 0:
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
contract :create_order, 'POST /orders' do
|
|
476
|
+
param :security_id, String, required: true, length: (1..20)
|
|
477
|
+
param :transaction_type, String, required: true, inclusion: %w[BUY SELL]
|
|
478
|
+
param :quantity, Integer, required: true, min: 1
|
|
479
|
+
end
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Violations raise a precise error instead of producing a malformed request:
|
|
483
|
+
|
|
484
|
+
- `Contractful::Contract::MissingParam` — required param absent
|
|
485
|
+
- `Contractful::Contract::UnknownParam` — undeclared param in strict mode
|
|
486
|
+
- `Contractful::Contract::InvalidParam` — value failed coercion/validation
|
|
487
|
+
|
|
488
|
+
Unknown params are rejected by default (contract-first strictness); opt out
|
|
489
|
+
per class with `strict_params false` or per contract with `strict_params
|
|
490
|
+
false` inside the block. Remaining (declared) params are routed by verb:
|
|
491
|
+
query string for GET/HEAD/DELETE/OPTIONS, JSON body (`Content-Type:
|
|
492
|
+
application/json`) for POST/PUT/PATCH. Override with `payload_style :form`
|
|
493
|
+
or `:query` per contract, or `payload_style :form` at class level.
|
|
494
|
+
|
|
495
|
+
Path placeholders (`:owner`, `:repo`) are automatically declared as required
|
|
496
|
+
params, interpolated into the path, URI-escaped, and never duplicated into
|
|
497
|
+
the query string or payload. A colon written directly after a placeholder is
|
|
498
|
+
a literal suffix, so custom verbs keep their colon: `POST /things/:id:archive`
|
|
499
|
+
declares one param, `:id`, and expands to `/things/5:archive`.
|
|
500
|
+
|
|
501
|
+
### Per-call meta options
|
|
502
|
+
|
|
503
|
+
Underscore-prefixed keyword arguments configure the underlying request
|
|
504
|
+
without polluting the param contract: `_headers`, `_timeout`,
|
|
505
|
+
`_open_timeout`, `_read_timeout`, `_write_timeout`, `_persistent`,
|
|
506
|
+
`_max_retries`, `_retry_interval`.
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
api.list_users(_headers: { 'X-Request-Id' => SecureRandom.hex(8) },
|
|
510
|
+
_timeout: 5, _persistent: true)
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Response contracts
|
|
514
|
+
|
|
515
|
+
`responds` declares what an operation expects back: status codes (Integers or
|
|
516
|
+
Ranges) and an optional JSON body type (`Array`, `Hash`, `String`, any class,
|
|
517
|
+
or `:array`/`:hash`/`:string`/`:json`).
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
contract :show_widget, 'GET /widgets/:id' do
|
|
521
|
+
param :id, Integer
|
|
522
|
+
responds 200, 404 # 404 becomes a normal return value
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
contract :list_posts, 'GET /posts' do
|
|
526
|
+
responds (200..299), type: Array
|
|
527
|
+
verify_response { |_response, parsed| parsed.is_a?(Array) }
|
|
528
|
+
end
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
A two-parameter `verify_response` block receives the JSON-parsed body. When
|
|
532
|
+
the body is not JSON, the block is not called and the response raises
|
|
533
|
+
`UnexpectedResponse`; the block never sees `nil` for a body that failed to
|
|
534
|
+
parse. A body that is the JSON value `null` still reaches the block as `nil`.
|
|
535
|
+
|
|
536
|
+
Declaring any 4xx/5xx status suppresses contractful's default
|
|
537
|
+
exception-raising for that contract: the response is returned and judged by
|
|
538
|
+
the contract instead (redirects are still followed). Anything unexpected —
|
|
539
|
+
undeclared status, non-JSON body, wrong shape, failed `verify_response`
|
|
540
|
+
block — raises `Contractful::Contract::UnexpectedResponse` with the offending
|
|
541
|
+
`response` attached. Contracts without any response declaration keep the
|
|
542
|
+
classic contractful behavior (`ResourceNotFound`, etc.).
|
|
543
|
+
|
|
544
|
+
### Closed requests, open responses
|
|
545
|
+
|
|
546
|
+
The request and response halves of a contract follow different philosophies,
|
|
547
|
+
and the DSL encodes both:
|
|
548
|
+
|
|
549
|
+
- **Request schemas are closed.** You control what you send, so reject bad
|
|
550
|
+
input before the network call: undeclared params raise `UnknownParam`, and
|
|
551
|
+
declared params are type-checked, enum-checked and range-checked
|
|
552
|
+
(`inclusion:`, `min:`/`max:`, `length:`, `pattern:`) before the transport
|
|
553
|
+
is ever touched.
|
|
554
|
+
- **Response schemas are open.** Required fields and their types bind, but
|
|
555
|
+
new fields and new enum members are additive — unknown values are data,
|
|
556
|
+
not errors. Declare response fields with `response_field`:
|
|
557
|
+
|
|
558
|
+
```ruby
|
|
559
|
+
contract :create_order, 'POST /orders' do
|
|
560
|
+
param :security_id, String, required: true
|
|
561
|
+
param :quantity, Integer, required: true, min: 1
|
|
562
|
+
|
|
563
|
+
response_field :order_id, :string, required: true
|
|
564
|
+
response_field :status, :string # open: new states are additive
|
|
565
|
+
end
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
A required field that is missing or null, or a declared field whose value
|
|
569
|
+
does not match its type (an `orderId` that comes back as an integer, for
|
|
570
|
+
example), raises `UnexpectedResponse` on return. Undeclared fields pass
|
|
571
|
+
through untouched and are never errors; `inclusion:` on a response field is
|
|
572
|
+
not enforced unless you opt in with `strict: true`. Values are never
|
|
573
|
+
coerced — what the server sent is what you inspect.
|
|
574
|
+
|
|
575
|
+
One thing a contract cannot express is ordering, timing or cross-call state
|
|
576
|
+
machines (rate-limit windows, order lifecycles, pagination handshakes).
|
|
577
|
+
Test those separately against recorded sequences rather than trying to
|
|
578
|
+
encode them in the per-call contract.
|
|
579
|
+
|
|
580
|
+
### Auth in the contract
|
|
581
|
+
|
|
582
|
+
The contract is the source of truth for *how* to authenticate; the client
|
|
583
|
+
supplies only credentials. Values may be literals or callables — callables
|
|
584
|
+
are invoked on every request so short-lived tokens stay fresh. Multiple
|
|
585
|
+
`api_key` entries cover dual-header schemes (DhanHQ's `access-token` +
|
|
586
|
+
`client-id`, for example):
|
|
587
|
+
|
|
588
|
+
```ruby
|
|
589
|
+
class DhanHQClient
|
|
590
|
+
include Contractful::Contract
|
|
591
|
+
|
|
592
|
+
base 'https://api.dhan.co'
|
|
593
|
+
auth api_key: [
|
|
594
|
+
{ name: 'access-token', value: -> { ENV['DHAN_ACCESS_TOKEN'] } },
|
|
595
|
+
{ name: 'client-id', value: -> { ENV['DHAN_CLIENT_ID'] } },
|
|
596
|
+
]
|
|
597
|
+
# also available: auth bearer: -> { token }, auth basic: -> { [user, pass] }
|
|
598
|
+
# and auth api_key: { name: 'k', value: v, in: :query }
|
|
599
|
+
end
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
For OAuth-style flows with short-lived tokens, give the contract a
|
|
603
|
+
**token provider** — an object that answers `#token` (the current
|
|
604
|
+
credential) and `#refresh` (force a new one). The client class stays
|
|
605
|
+
credential-free, and a 401 is handled for you:
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
class TokenStore
|
|
609
|
+
def token = @token
|
|
610
|
+
def refresh
|
|
611
|
+
@token = fetch_new_token # your OAuth2 client-credentials flow
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
class DhanHQClient
|
|
616
|
+
include Contractful::Contract
|
|
617
|
+
base 'https://api.dhan.co'
|
|
618
|
+
auth token_provider: TokenStore.new
|
|
619
|
+
end
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
When a request comes back 401, the executor refreshes the provider in
|
|
623
|
+
**one flight** (concurrent 401s share a single refresh — per-provider
|
|
624
|
+
mutex, posting a `token_refresh` notification) and retries the request
|
|
625
|
+
exactly once with the fresh credential. A second 401 is judged like any other
|
|
626
|
+
status: returned when the contract declares it, otherwise raised as
|
|
627
|
+
`Contractful::Unauthorized`. There is no refresh loop. The same rule covers
|
|
628
|
+
every undeclared 4xx/5xx on a token-provider contract, including one that
|
|
629
|
+
declares no `responds` at all: it raises the mapped exception instead of
|
|
630
|
+
arriving as a silent success.
|
|
631
|
+
|
|
632
|
+
Auth is resolved right before the `before_request` callbacks run, so
|
|
633
|
+
callbacks can still inspect or override the resolved credentials. Individual
|
|
634
|
+
contracts can opt out with `auth false` inside the block. Auth config
|
|
635
|
+
inherits down the class chain, nearest definition wins.
|
|
636
|
+
|
|
637
|
+
### Idempotency keys
|
|
638
|
+
|
|
639
|
+
Flag a contract `idempotent` and every call carries an idempotency key
|
|
640
|
+
header (default `Idempotency-Key`, generated with `SecureRandom.uuid`),
|
|
641
|
+
which makes automatic retries of non-GET operations safe. The key is
|
|
642
|
+
generated once per logical call and stays stable across retries — that is
|
|
643
|
+
the entire point of the header. Customize the header name with
|
|
644
|
+
`idempotency_header` and the generator with `idempotency_generator` at
|
|
645
|
+
class level; pin a specific key per call with `_idempotency_key:`.
|
|
646
|
+
|
|
647
|
+
```ruby
|
|
648
|
+
contract :create_order, 'POST /orders' do
|
|
649
|
+
param :security_id, String, required: true
|
|
650
|
+
idempotent true
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
api.create_order(security_id: 'X1', _max_retries: 2)
|
|
654
|
+
# a 503 will be retried with the SAME Idempotency-Key
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### Pagination
|
|
658
|
+
|
|
659
|
+
Collection contracts can declare their pagination strategy; the pagination
|
|
660
|
+
params must also be declared as regular contract params. Every paginated
|
|
661
|
+
contract gets two metaprogrammed methods: `#{name}_pages` (a lazy Enumerator
|
|
662
|
+
of per-page responses, each fully validated by the contract) and
|
|
663
|
+
`all_#{name}` (a flat Array of every item across pages).
|
|
664
|
+
|
|
665
|
+
```ruby
|
|
666
|
+
contract :list_orders, 'GET /orders' do
|
|
667
|
+
param :pageToken, String
|
|
668
|
+
param :pageSize, Integer, default: 50
|
|
669
|
+
|
|
670
|
+
paginated style: :cursor, cursor_param: :pageToken,
|
|
671
|
+
next_cursor_path: 'nextPageToken', items_path: 'orders'
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
api.list_orders_pages.take(3) # first three validated pages
|
|
675
|
+
api.all_list_orders # every order across every page
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
Offset pagination is covered too: `paginated style: :offset,
|
|
679
|
+
offset_param: :offset, limit_param: :limit, items_path: 'data',
|
|
680
|
+
has_more_path: 'hasMore'` — iteration stops when `has_more_path` is false
|
|
681
|
+
or (without it) when a page comes back empty. A `_max_pages` guard (default
|
|
682
|
+
1000) turns a missing stop condition into
|
|
683
|
+
`Contractful::Contract::PaginationError` instead of an infinite request
|
|
684
|
+
loop, and page 1 can resume from an explicitly supplied cursor or offset.
|
|
685
|
+
|
|
686
|
+
### Rate-limit information
|
|
687
|
+
|
|
688
|
+
Every response exposes parsed quota state when the server sends the IETF
|
|
689
|
+
`RateLimit-*` headers or the legacy `X-RateLimit-*` convention (servers that
|
|
690
|
+
document limits only in prose — DhanHQ, for instance — simply yield `nil`;
|
|
691
|
+
data absence is not an error):
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
response = api.list_orders
|
|
695
|
+
response.rate_limit&.remaining # => 98
|
|
696
|
+
response.rate_limit&.reset_in # => 12.4 (seconds)
|
|
697
|
+
response.rate_limited? # => true on a 429 or an exhausted quota
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
A 429 that carries no `Retry-After` falls back to the `RateLimit-Reset`
|
|
701
|
+
window before resorting to exponential backoff.
|
|
702
|
+
|
|
703
|
+
### Callbacks
|
|
704
|
+
|
|
705
|
+
`before_request` receives a *mutable* context
|
|
706
|
+
(`definition:, method:, url:, headers:, payload:, query:, cookies:`) — mutating it
|
|
707
|
+
changes the outgoing request, which is ideal for auth injection.
|
|
708
|
+
`after_response` receives the validated response. Both accumulate down the
|
|
709
|
+
inheritance chain, root-most first, so a base client class can hold shared
|
|
710
|
+
middleware.
|
|
711
|
+
|
|
712
|
+
### Generated CRUD resources
|
|
713
|
+
|
|
714
|
+
`resource :posts` metaprograms the five standard REST contracts —
|
|
715
|
+
`list_posts`, `get_post(id)`, `create_post(attrs)`, `update_post(id, attrs)`,
|
|
716
|
+
`delete_post(id)` — with `only:`/`except:` filters, `update_with: :put`,
|
|
717
|
+
`path:` override and a small English singularizer (override with
|
|
718
|
+
`singular:`). Generated CRUD contracts accept free-form attributes
|
|
719
|
+
(non-strict); declare contracts by hand when you want strict validation.
|
|
720
|
+
|
|
721
|
+
```ruby
|
|
722
|
+
class BlogAPI
|
|
723
|
+
include Contractful::Contract
|
|
724
|
+
base 'https://api.example.com'
|
|
725
|
+
resource :posts, only: %i[index show create]
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
BlogAPI.new.create_post(title: 'Hello', body: '...') # POST /posts
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
### Reflection and dynamic dispatch
|
|
732
|
+
|
|
733
|
+
Everything registered through the DSL is introspectable:
|
|
734
|
+
|
|
735
|
+
```ruby
|
|
736
|
+
GitHubAPI.contract_names # => [:list_users, :create_issue]
|
|
737
|
+
GitHubAPI.contract?(:list_users) # => true
|
|
738
|
+
GitHubAPI.contract_for(:list_users) # => #<Contractful::Contract::Definition GET /users ...>
|
|
739
|
+
GitHubAPI.contract_for(:list_users).describe
|
|
740
|
+
# => "GET /users (params: :page, :per_page) responds 200 => Array"
|
|
741
|
+
|
|
742
|
+
api.list_users_contract # the Definition (dynamic dispatch)
|
|
743
|
+
api.call_list_users(page: 1) # execute by name
|
|
744
|
+
api.has_list_users? # => true
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
Clients also accept instance-level overrides: `GitHubAPI.new(base:
|
|
748
|
+
'https://staging.example.com', headers: { 'X-Env' => 'test' })`.
|
|
749
|
+
|
|
750
|
+
### Importing an OpenAPI document
|
|
751
|
+
|
|
752
|
+
If a vendor publishes a machine-readable OpenAPI 3.x or Swagger 2.0 document,
|
|
753
|
+
the opt-in import helper converts it into `contract` declarations — a
|
|
754
|
+
development-time tool, not a runtime dependency (it is not loaded by
|
|
755
|
+
`require 'contractful'`):
|
|
756
|
+
|
|
757
|
+
```ruby
|
|
758
|
+
require 'contractful/contract/openapi_import'
|
|
759
|
+
|
|
760
|
+
# Build Definition objects from a file, YAML/JSON text or a Hash:
|
|
761
|
+
import = Contractful::Contract::OpenAPIImport.import('vendor_openapi.yaml')
|
|
762
|
+
import.definitions # => { list_users: #<Definition GET /users ...>, ... }
|
|
763
|
+
import.base_url # from `servers`
|
|
764
|
+
import.notes # every lossy mapping decision, for review
|
|
765
|
+
|
|
766
|
+
# Or render a ready-to-paste client class:
|
|
767
|
+
puts Contractful::Contract::OpenAPIImport.to_ruby('vendor_openapi.yaml',
|
|
768
|
+
class_name: 'Acme::Client')
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
A Swagger 2.0 document (`swagger: '2.0'`) is converted to OpenAPI 3.0 first, in
|
|
772
|
+
the same import: `host`, `basePath` and `schemes` become the base URL; body and
|
|
773
|
+
formData parameters become a request body; definitions, responses and
|
|
774
|
+
`securityDefinitions` map to their OpenAPI 3.0 counterparts. Swagger 2.0 files
|
|
775
|
+
bundled through `$ref` are converted as they are read. The conversion keeps the
|
|
776
|
+
same notes as the OpenAPI import, so unmapped constructs stay visible.
|
|
777
|
+
|
|
778
|
+
What is mapped: path, query, header and cookie params (header and cookie
|
|
779
|
+
params become `header:` and `cookie:` options), plus the properties of a JSON or
|
|
780
|
+
form-urlencoded request body (JSON wins when both are offered), with types,
|
|
781
|
+
required, defaults,
|
|
782
|
+
enum/min/max/length/pattern constraints), `payload_style` (form for
|
|
783
|
+
urlencoded bodies, query for body-less POST/PUT/PATCH), response statuses
|
|
784
|
+
(numeric and `2XX` wildcards), JSON body types and open-schema
|
|
785
|
+
`response_field` bindings, the base URL from `servers`, and auth hints from
|
|
786
|
+
`securitySchemes` (as comments — credentials are never invented).
|
|
787
|
+
|
|
788
|
+
When the document is read from a file, `$ref`s into other local files
|
|
789
|
+
(`schemas/user.yaml#/User`) resolve relative to the file that contains them,
|
|
790
|
+
and the referenced files are bundled into the import. Remote URLs are never
|
|
791
|
+
fetched: they stay unresolved and are reported on `import.notes`. A document
|
|
792
|
+
given as a Hash or as YAML text has no file to resolve against, so its
|
|
793
|
+
external refs are reported as unresolved too.
|
|
794
|
+
|
|
795
|
+
An `allOf` whose members are objects is flattened into one set of fields
|
|
796
|
+
(first definition of a name wins), and a single scalar `allOf` reference
|
|
797
|
+
that only adds annotations is unwrapped. What is NOT mapped is reported on
|
|
798
|
+
`import.notes` instead of guessed away: header and cookie params whose name is
|
|
799
|
+
not an HTTP token or whose type is not a scalar, multipart bodies,
|
|
800
|
+
`oneOf`/`anyOf` unions and `allOf`s that mix object and scalar members,
|
|
801
|
+
`default` responses, operation-level server overrides. The generated source
|
|
802
|
+
round-trips: evaluating it yields a class whose contracts `describe`
|
|
803
|
+
exactly like the imported definitions.
|
|
804
|
+
|
|
805
|
+
### Importing a Postman collection
|
|
806
|
+
|
|
807
|
+
A Postman collection (a v2.0 or v2.1 JSON export) imports the same way, through
|
|
808
|
+
its own opt-in helper:
|
|
809
|
+
|
|
810
|
+
```ruby
|
|
811
|
+
require 'contractful/contract/postman_import'
|
|
812
|
+
|
|
813
|
+
Contractful::Contract::PostmanImport.import('acme.postman_collection.json',
|
|
814
|
+
variables: { 'baseUrl' => 'https://api.acme.test/v1' })
|
|
815
|
+
puts Contractful::Contract::PostmanImport.to_ruby('acme.postman_collection.json', class_name: 'Acme::Client')
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
Every request becomes one contract, named after the request, and folders are
|
|
819
|
+
flattened. Collection variables resolve `{{name}}` in URLs, and `variables:`
|
|
820
|
+
supplies the rest, such as an environment's `baseUrl`. A request whose origin
|
|
821
|
+
or path cannot be resolved is skipped and reported.
|
|
822
|
+
|
|
823
|
+
A collection carries example values rather than schemas, so its params are
|
|
824
|
+
untyped and none is required, except a GraphQL query. The requests must share
|
|
825
|
+
one origin, because a client has one base URL. Others are skipped and
|
|
826
|
+
reported. Static headers become header params whose default is the header's
|
|
827
|
+
value. `Authorization` is not mapped; the collection's auth becomes a
|
|
828
|
+
commented-out auth hint that reads an environment variable, so no credential is
|
|
829
|
+
invented. A JSON example body declares its keys as params and turns
|
|
830
|
+
`strict_params` off for that operation, because the example may omit fields.
|
|
831
|
+
Raw non-JSON bodies and multipart bodies with file fields are reported on
|
|
832
|
+
`import.notes`.
|
|
833
|
+
|
|
834
|
+
### Testing sequences with recorded fixtures
|
|
835
|
+
|
|
836
|
+
Contracts deliberately do not express ordering, timing or cross-call state
|
|
837
|
+
machines. The opt-in sequence helper replays recorded vendor responses
|
|
838
|
+
through a contract client and fails on the FIRST contract violation with
|
|
839
|
+
the step number attached (test-only — it uses WebMock):
|
|
840
|
+
|
|
841
|
+
```ruby
|
|
842
|
+
require 'contractful/contract/sequence'
|
|
843
|
+
|
|
844
|
+
sequence = Contractful::Contract::Sequence.load(client, 'order_lifecycle.yml')
|
|
845
|
+
sequence.after_step do |index, step, response|
|
|
846
|
+
# stateful rules contracts cannot express: token refresh sequencing,
|
|
847
|
+
# rate-limit windows (response.rate_limit), cross-step invariants
|
|
848
|
+
end
|
|
849
|
+
sequence.run # => [#<Result step 1>, ...] or raises Sequence::Failure
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
Fixture shape (YAML or JSON, under your control):
|
|
853
|
+
|
|
854
|
+
```yaml
|
|
855
|
+
name: order lifecycle
|
|
856
|
+
steps:
|
|
857
|
+
- call: create_order
|
|
858
|
+
params: { quantity: 10 }
|
|
859
|
+
response: { status: 201, body: { order_id: 'o-1', status: 'open' } }
|
|
860
|
+
expect_request:
|
|
861
|
+
json_include: { quantity: 10 } # assert the OUTGOING payload subset
|
|
862
|
+
- call: get_order
|
|
863
|
+
params: { order_id: 'o-1' }
|
|
864
|
+
response: { status: 200, body: { order_id: 'o-1', status: 'filled' } }
|
|
865
|
+
expect_request:
|
|
866
|
+
path: /orders/o-1
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
Each step stubs its recorded response, invokes the contract method and
|
|
870
|
+
lets the contract's own validation run — a recorded response the contract
|
|
871
|
+
does not declare is a violation at that step. `expect_request` pins
|
|
872
|
+
ordering-sensitive outgoing details (`path`, `query_include`,
|
|
873
|
+
`headers_include`, `json_include` — unknown keys fail the fixture loudly).
|
|
874
|
+
Unknown contract names, malformed steps and missing responses are rejected
|
|
875
|
+
at load time, before anything runs.
|
|
876
|
+
|
|
877
|
+
## Event notifications
|
|
878
|
+
|
|
879
|
+
The request pipeline posts lifecycle events to an
|
|
880
|
+
in-process hub, so metrics, tracing and alerting never require touching the
|
|
881
|
+
transport. Subscribers must not raise — a failing subscriber is logged and
|
|
882
|
+
skipped, so observability cannot break the request it observes:
|
|
883
|
+
|
|
884
|
+
```ruby
|
|
885
|
+
Contractful::Notifications.subscribe do |event|
|
|
886
|
+
puts "#{event[:name]} #{event[:payload].inspect}"
|
|
887
|
+
end
|
|
888
|
+
# request {method: 'get', url: 'https://api.test/x', attempt: 1}
|
|
889
|
+
# response {method: 'get', url: 'https://api.test/x', status: 200, duration_ms: 43.12}
|
|
890
|
+
# retry {method: 'get', url: 'https://api.test/x', attempt: 1, delay: 0.5, cause: 503}
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
The core posts `request` (method, url, attempt), `response` (status,
|
|
894
|
+
duration_ms — one per attempt, including suppressed error responses) and
|
|
895
|
+
`retry` (attempt, delay, cause — an exception class name or HTTP status).
|
|
896
|
+
Resilience features add their own events (circuit breaker state changes,
|
|
897
|
+
rate-limit sleeps, token refreshes). `subscribe` returns an unsubscribe
|
|
898
|
+
handle; `Notifications.clear!` detaches everything (test hook).
|
|
899
|
+
|
|
900
|
+
For Rails and APM integration, an opt-in bridge forwards every event to
|
|
901
|
+
ActiveSupport::Notifications as `contractful.<name>`:
|
|
902
|
+
|
|
903
|
+
```ruby
|
|
904
|
+
require 'contractful/notifications/active_support' # requires the activesupport gem
|
|
905
|
+
|
|
906
|
+
ActiveSupport::Notifications.subscribe(/^contractful\./) do |*args|
|
|
907
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
908
|
+
Rails.logger.info "#{event.name} in #{event.duration}ms #{event.payload.slice(:url, :status)}"
|
|
909
|
+
end
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
## Multipart
|
|
913
|
+
|
|
914
|
+
Yeah, that's right! This does multipart sends for you!
|
|
915
|
+
|
|
916
|
+
```ruby
|
|
917
|
+
Contractful.post '/data', :myfile => File.new("/path/to/image.jpg", 'rb')
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
This does two things for you:
|
|
921
|
+
|
|
922
|
+
- Auto-detects that you have a File value sends it as multipart
|
|
923
|
+
- Auto-detects the mime of the file and sets it in the HEAD of the payload for each entry
|
|
924
|
+
|
|
925
|
+
If you are sending params that do not contain a File object but the payload needs to be multipart then:
|
|
926
|
+
|
|
927
|
+
```ruby
|
|
928
|
+
Contractful.post '/data', {:foo => 'bar', :multipart => true}
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
## Exceptions (see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html)
|
|
932
|
+
|
|
933
|
+
- for result codes between `200` and `207`, a `Contractful::Response` will be returned
|
|
934
|
+
- for result codes `301`, `302` or `307`, the redirection will be followed if the request is a `GET` or a `HEAD`
|
|
935
|
+
- for result code `303`, the redirection will be followed and the request transformed into a `GET`
|
|
936
|
+
- for other cases, a `Contractful::ExceptionWithResponse` holding the Response will be raised; a specific exception class will be thrown for known error codes
|
|
937
|
+
- call `.response` on the exception to get the server's response
|
|
938
|
+
|
|
939
|
+
```ruby
|
|
940
|
+
>> Contractful.get 'http://example.com/nonexistent'
|
|
941
|
+
Exception: Contractful::NotFound: 404 Not Found
|
|
942
|
+
|
|
943
|
+
>> begin
|
|
944
|
+
Contractful.get 'http://example.com/nonexistent'
|
|
945
|
+
rescue Contractful::ExceptionWithResponse => e
|
|
946
|
+
e.response
|
|
947
|
+
end
|
|
948
|
+
=> <Contractful::Response 404 "<!doctype h...">
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
### Other exceptions
|
|
952
|
+
|
|
953
|
+
While most exceptions have been collected under `Contractful::RequestFailed` aka
|
|
954
|
+
`Contractful::ExceptionWithResponse`, there are a few quirky exceptions that
|
|
955
|
+
have been kept for backwards compatibility.
|
|
956
|
+
|
|
957
|
+
Contractful will propagate up exceptions like socket errors without modification:
|
|
958
|
+
|
|
959
|
+
```ruby
|
|
960
|
+
>> Contractful.get 'http://localhost:12345'
|
|
961
|
+
Exception: Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 12345
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
Contractful handles a few specific error cases separately in order to give
|
|
965
|
+
better error messages. These will hopefully be cleaned up in a future major
|
|
966
|
+
release.
|
|
967
|
+
|
|
968
|
+
`Contractful::ServerBrokeConnection` is translated from `EOFError` to give a
|
|
969
|
+
better error message.
|
|
970
|
+
|
|
971
|
+
`Contractful::SSLCertificateNotVerified` is raised when HTTPS validation fails.
|
|
972
|
+
Other `OpenSSL::SSL::SSLError` errors are raised as is.
|
|
973
|
+
|
|
974
|
+
## Lower-level access
|
|
975
|
+
|
|
976
|
+
> Redirect-chain mechanics (`response.history`, per-request URLs), the
|
|
977
|
+
> block/exception result-handling styles and the ActiveResource-style
|
|
978
|
+
> facade are documented in the
|
|
979
|
+
> [raw HTTP deep dive](docs/guides/raw-http-deep-dive.md).
|
|
980
|
+
|
|
981
|
+
For cases not covered by the general API, you can use the `Contractful::Request` class, which provides a lower-level API.
|
|
982
|
+
|
|
983
|
+
You can:
|
|
984
|
+
|
|
985
|
+
- specify ssl parameters
|
|
986
|
+
- override cookies
|
|
987
|
+
- manually handle the response — streaming request payloads and streamed
|
|
988
|
+
response downloads live in the
|
|
989
|
+
[raw HTTP deep dive](docs/guides/raw-http-deep-dive.md#streaming-request-payload)
|
|
990
|
+
|
|
991
|
+
See `Contractful::Request`'s documentation for more information.
|
|
992
|
+
|
|
993
|
+
## Logging
|
|
994
|
+
|
|
995
|
+
To enable logging globally you can:
|
|
996
|
+
|
|
997
|
+
- set Contractful.log with a Ruby Logger
|
|
998
|
+
|
|
999
|
+
```ruby
|
|
1000
|
+
Contractful.log = STDOUT
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
- or set an environment variable to avoid modifying the code (in this case you can use a file name, "stdout" or "stderr"):
|
|
1004
|
+
|
|
1005
|
+
```ruby
|
|
1006
|
+
$ CONTRACTFUL_LOG=stdout path/to/my/program
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
You can also set individual loggers when instantiating a Resource or making an
|
|
1010
|
+
individual request:
|
|
1011
|
+
|
|
1012
|
+
```ruby
|
|
1013
|
+
resource = Contractful::Resource.new 'http://example.com/resource', log: Logger.new(STDOUT)
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
```ruby
|
|
1017
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com/foo', log: Logger.new(STDERR))
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
All options produce logs like this:
|
|
1021
|
+
|
|
1022
|
+
```ruby
|
|
1023
|
+
Contractful.get "http://some/resource"
|
|
1024
|
+
# => 200 OK | text/html 250 bytes
|
|
1025
|
+
Contractful.put "http://some/resource", "payload"
|
|
1026
|
+
# => 401 Unauthorized | application/xml 340 bytes
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
Note that these logs are valid Ruby, so you can paste them into the `contractful`
|
|
1030
|
+
shell or a script to replay your sequence of rest calls.
|
|
1031
|
+
|
|
1032
|
+
## Proxy
|
|
1033
|
+
|
|
1034
|
+
All calls to Contractful, including Resources, will use the proxy specified by
|
|
1035
|
+
`Contractful.proxy`:
|
|
1036
|
+
|
|
1037
|
+
```ruby
|
|
1038
|
+
Contractful.proxy = "http://proxy.example.com/"
|
|
1039
|
+
Contractful.get "http://some/resource"
|
|
1040
|
+
# => response from some/resource as proxied through proxy.example.com
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
Often the proxy URL is set in an environment variable, so you can do this to
|
|
1044
|
+
use whatever proxy the system is configured to use:
|
|
1045
|
+
|
|
1046
|
+
```ruby
|
|
1047
|
+
Contractful.proxy = ENV['http_proxy']
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
Specify a per-request proxy by passing the :proxy option to
|
|
1051
|
+
Contractful::Request. This will override any proxies set by environment variable
|
|
1052
|
+
or by the global `Contractful.proxy` value.
|
|
1053
|
+
|
|
1054
|
+
```ruby
|
|
1055
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com',
|
|
1056
|
+
proxy: 'http://proxy.example.com')
|
|
1057
|
+
# => single request proxied through the proxy
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
This can be used to disable the use of a proxy for a particular request.
|
|
1061
|
+
|
|
1062
|
+
```ruby
|
|
1063
|
+
Contractful.proxy = "http://proxy.example.com/"
|
|
1064
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com', proxy: nil)
|
|
1065
|
+
# => single request sent without a proxy
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
## Query parameters
|
|
1069
|
+
|
|
1070
|
+
Contractful can render a hash as HTTP query parameters for GET/HEAD/DELETE
|
|
1071
|
+
requests or as HTTP post data in `x-www-form-urlencoded` format for POST
|
|
1072
|
+
requests.
|
|
1073
|
+
|
|
1074
|
+
Even though there is no standard specifying how this should
|
|
1075
|
+
work, contractful follows a similar convention to the one used by Rack / Rails
|
|
1076
|
+
servers for handling arrays, nested hashes, and null values.
|
|
1077
|
+
|
|
1078
|
+
The implementation in
|
|
1079
|
+
[./lib/contractful/utils.rb](Contractful::Utils.encode_query_string)
|
|
1080
|
+
closely follows
|
|
1081
|
+
[Rack::Utils.build_nested_query](http://www.rubydoc.info/gems/rack/Rack/Utils#build_nested_query-class_method),
|
|
1082
|
+
but treats empty arrays and hashes as `nil`. (Rack drops them entirely, which
|
|
1083
|
+
is confusing behavior.)
|
|
1084
|
+
|
|
1085
|
+
If you don't like this behavior and want more control, just serialize params
|
|
1086
|
+
yourself (e.g. with `URI.encode_www_form`) and add the query string to the URL
|
|
1087
|
+
directly for GET parameters or pass the payload as a string for POST requests.
|
|
1088
|
+
|
|
1089
|
+
Basic GET params:
|
|
1090
|
+
```ruby
|
|
1091
|
+
Contractful.get('https://httpbin.org/get', params: {foo: 'bar', baz: 'qux'})
|
|
1092
|
+
# GET "https://httpbin.org/get?foo=bar&baz=qux"
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
Basic `x-www-form-urlencoded` POST params:
|
|
1096
|
+
```ruby
|
|
1097
|
+
>> r = Contractful.post('https://httpbin.org/post', {foo: 'bar', baz: 'qux'})
|
|
1098
|
+
# POST "https://httpbin.org/post", data: "foo=bar&baz=qux"
|
|
1099
|
+
=> <Contractful::Response 200 "{\n \"args\":...">
|
|
1100
|
+
>> JSON.parse(r.body)
|
|
1101
|
+
=> {"args"=>{},
|
|
1102
|
+
"data"=>"",
|
|
1103
|
+
"files"=>{},
|
|
1104
|
+
"form"=>{"baz"=>"qux", "foo"=>"bar"},
|
|
1105
|
+
"headers"=>
|
|
1106
|
+
{"Accept"=>"*/*",
|
|
1107
|
+
"Accept-Encoding"=>"gzip, deflate",
|
|
1108
|
+
"Content-Length"=>"15",
|
|
1109
|
+
"Content-Type"=>"application/x-www-form-urlencoded",
|
|
1110
|
+
"Host"=>"httpbin.org"},
|
|
1111
|
+
"json"=>nil,
|
|
1112
|
+
"url"=>"https://httpbin.org/post"}
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
JSON payload: contractful does not speak JSON natively, so serialize your
|
|
1116
|
+
payload to a string before passing it to contractful.
|
|
1117
|
+
```ruby
|
|
1118
|
+
>> payload = {'name' => 'newrepo', 'description': 'A new repo'}
|
|
1119
|
+
>> Contractful.post('https://api.github.com/user/repos', payload.to_json, content_type: :json)
|
|
1120
|
+
=> <Contractful::Response 201 "{\"id\":75149...">
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
Advanced GET params (arrays):
|
|
1124
|
+
```ruby
|
|
1125
|
+
>> r = Contractful.get('https://http-params.herokuapp.com/get', params: {foo: [1,2,3]})
|
|
1126
|
+
# GET "https://http-params.herokuapp.com/get?foo[]=1&foo[]=2&foo[]=3"
|
|
1127
|
+
=> <Contractful::Response 200 "Method: GET...">
|
|
1128
|
+
>> puts r.body
|
|
1129
|
+
query_string: "foo[]=1&foo[]=2&foo[]=3"
|
|
1130
|
+
decoded: "foo[]=1&foo[]=2&foo[]=3"
|
|
1131
|
+
|
|
1132
|
+
GET:
|
|
1133
|
+
{"foo"=>["1", "2", "3"]}
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
Advanced GET params (nested hashes):
|
|
1137
|
+
```ruby
|
|
1138
|
+
>> r = Contractful.get('https://http-params.herokuapp.com/get', params: {outer: {foo: 123, bar: 456}})
|
|
1139
|
+
# GET "https://http-params.herokuapp.com/get?outer[foo]=123&outer[bar]=456"
|
|
1140
|
+
=> <Contractful::Response 200 "Method: GET...">
|
|
1141
|
+
>> puts r.body
|
|
1142
|
+
...
|
|
1143
|
+
query_string: "outer[foo]=123&outer[bar]=456"
|
|
1144
|
+
decoded: "outer[foo]=123&outer[bar]=456"
|
|
1145
|
+
|
|
1146
|
+
GET:
|
|
1147
|
+
{"outer"=>{"foo"=>"123", "bar"=>"456"}}
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
The `Contractful::ParamsArray` class allows callers to
|
|
1151
|
+
provide ordering even to structured parameters. This is useful for unusual
|
|
1152
|
+
cases where the server treats the order of parameters as significant or you
|
|
1153
|
+
want to pass a particular key multiple times.
|
|
1154
|
+
|
|
1155
|
+
Multiple fields with the same name using ParamsArray:
|
|
1156
|
+
```ruby
|
|
1157
|
+
>> Contractful.get('https://httpbin.org/get', params:
|
|
1158
|
+
Contractful::ParamsArray.new([[:foo, 1], [:foo, 2]]))
|
|
1159
|
+
# GET "https://httpbin.org/get?foo=1&foo=2"
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
Nested ParamsArray:
|
|
1163
|
+
```ruby
|
|
1164
|
+
>> Contractful.get('https://httpbin.org/get', params:
|
|
1165
|
+
{foo: Contractful::ParamsArray.new([[:a, 1], [:a, 2]])})
|
|
1166
|
+
# GET "https://httpbin.org/get?foo[a]=1&foo[a]=2"
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
## Headers
|
|
1170
|
+
|
|
1171
|
+
Request headers can be set by passing a ruby hash containing keys and values
|
|
1172
|
+
representing header names and values:
|
|
1173
|
+
|
|
1174
|
+
```ruby
|
|
1175
|
+
# GET request with modified headers
|
|
1176
|
+
Contractful.get 'http://example.com/resource', {:Authorization => 'Bearer cT0febFoD5lxAlNAXHo6g'}
|
|
1177
|
+
|
|
1178
|
+
# POST request with modified headers
|
|
1179
|
+
Contractful.post 'http://example.com/resource', {:foo => 'bar', :baz => 'qux'}, {:Authorization => 'Bearer cT0febFoD5lxAlNAXHo6g'}
|
|
1180
|
+
|
|
1181
|
+
# DELETE request with modified headers
|
|
1182
|
+
Contractful.delete 'http://example.com/resource', {:Authorization => 'Bearer cT0febFoD5lxAlNAXHo6g'}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
## Timeouts
|
|
1186
|
+
|
|
1187
|
+
By default the timeout for a request is 60 seconds. Timeouts for your request can
|
|
1188
|
+
be adjusted by setting the `timeout:` to the number of seconds that you would like
|
|
1189
|
+
the request to wait. Setting `timeout:` will override both `read_timeout:` and `open_timeout:`.
|
|
1190
|
+
|
|
1191
|
+
```ruby
|
|
1192
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com/resource',
|
|
1193
|
+
timeout: 120)
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
Additionally, you can set `read_timeout:`, `write_timeout:` and `open_timeout:`
|
|
1197
|
+
separately.
|
|
1198
|
+
|
|
1199
|
+
```ruby
|
|
1200
|
+
Contractful::Request.execute(method: :get, url: 'http://example.com/resource',
|
|
1201
|
+
read_timeout: 120, open_timeout: 240)
|
|
1202
|
+
|
|
1203
|
+
Contractful::Request.execute(method: :post, url: 'http://example.com/upload',
|
|
1204
|
+
payload: large_body, write_timeout: 30)
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
## Cookies
|
|
1208
|
+
|
|
1209
|
+
Request and Response objects know about HTTP cookies, and will automatically
|
|
1210
|
+
extract and set headers for them as needed:
|
|
1211
|
+
|
|
1212
|
+
```ruby
|
|
1213
|
+
response = Contractful.get 'http://example.com/action_which_sets_session_id'
|
|
1214
|
+
response.cookies
|
|
1215
|
+
# => {"_applicatioN_session_id" => "1234"}
|
|
1216
|
+
|
|
1217
|
+
response2 = Contractful.post(
|
|
1218
|
+
'http://localhost:3000/',
|
|
1219
|
+
{:param1 => "foo"},
|
|
1220
|
+
{:cookies => {:session_id => "1234"}}
|
|
1221
|
+
)
|
|
1222
|
+
# ...response body
|
|
1223
|
+
```
|
|
1224
|
+
### Full cookie jar support
|
|
1225
|
+
|
|
1226
|
+
The original cookie implementation was very naive and ignored most of the
|
|
1227
|
+
cookie RFC standards. Response objects carry a `cookie_jar` method that
|
|
1228
|
+
exposes an HTTP::CookieJar of cookies with full standards-compliant behavior.
|
|
1229
|
+
|
|
1230
|
+
## SSL/TLS support
|
|
1231
|
+
|
|
1232
|
+
Various options are supported for configuring contractful's TLS settings. By
|
|
1233
|
+
default, contractful will verify certificates using the system's CA store on
|
|
1234
|
+
all platforms. (This is intended to be similar to how browsers behave.) You can
|
|
1235
|
+
specify an :ssl_ca_file, :ssl_ca_path, or :ssl_cert_store to customize the
|
|
1236
|
+
certificate authorities accepted.
|
|
1237
|
+
|
|
1238
|
+
You can also constrain the negotiated TLS protocol versions with
|
|
1239
|
+
`:ssl_min_version` and `:ssl_max_version`, which map to
|
|
1240
|
+
`OpenSSL::SSL::SSLContext#min_version=` and `#max_version=`. Version symbols
|
|
1241
|
+
such as `:TLSv1_2` (or `:TLS1_2`) and integers such as
|
|
1242
|
+
`OpenSSL::SSL::TLS1_2_VERSION` are accepted:
|
|
1243
|
+
|
|
1244
|
+
```ruby
|
|
1245
|
+
Contractful::Request.execute(method: :get, url: 'https://example.com/resource',
|
|
1246
|
+
ssl_min_version: :TLSv1_2)
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
### SSL Client Certificates
|
|
1250
|
+
|
|
1251
|
+
```ruby
|
|
1252
|
+
Contractful::Resource.new(
|
|
1253
|
+
'https://example.com',
|
|
1254
|
+
:ssl_client_cert => OpenSSL::X509::Certificate.new(File.read("cert.pem")),
|
|
1255
|
+
:ssl_client_key => OpenSSL::PKey::RSA.new(File.read("key.pem"), "passphrase, if any"),
|
|
1256
|
+
:ssl_ca_file => "ca_certificate.pem",
|
|
1257
|
+
:verify_ssl => OpenSSL::SSL::VERIFY_PEER
|
|
1258
|
+
).get
|
|
1259
|
+
```
|
|
1260
|
+
Self-signed certificates can be generated with the openssl command-line tool.
|
|
1261
|
+
|
|
1262
|
+
## More
|
|
1263
|
+
|
|
1264
|
+
Need caching, more advanced logging or any ability provided by Rack middleware?
|
|
1265
|
+
|
|
1266
|
+
The historical rest-client-components project (Rack-middleware-style
|
|
1267
|
+
request processing for the upstream gem) lives at
|
|
1268
|
+
http://github.com/crohr/rest-client-components — it predates contractful
|
|
1269
|
+
and is not tested against it.
|
|
1270
|
+
|
|
1271
|
+
## Credits
|
|
1272
|
+
| | |
|
|
1273
|
+
|-------------------------|---------------------------------------------------------|
|
|
1274
|
+
| **REST Client Team** | Andy Brody |
|
|
1275
|
+
| **Creator** | Adam Wiggins |
|
|
1276
|
+
| **Maintainers Emeriti** | Lawrence Leonard Gilbert, Matthew Manning, Julien Kirch |
|
|
1277
|
+
| **Major contributions** | Blake Mizerany, Julien Kirch |
|
|
1278
|
+
|
|
1279
|
+
A great many generous folks have contributed features and patches.
|
|
1280
|
+
See AUTHORS for the full list.
|
|
1281
|
+
|
|
1282
|
+
## Legal
|
|
1283
|
+
|
|
1284
|
+
Released under the MIT License: https://opensource.org/licenses/MIT
|
|
1285
|
+
|
|
1286
|
+
Photo of the International Space Station was produced by NASA and is in the
|
|
1287
|
+
public domain.
|
|
1288
|
+
|
|
1289
|
+
Code for reading Windows root certificate store derived from work by Puppet;
|
|
1290
|
+
used under terms of the Apache License, Version 2.0.
|