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
data/README.md ADDED
@@ -0,0 +1,1290 @@
1
+ # Contractful — a contract-first HTTP client for Ruby
2
+
3
+ [![Gem Downloads](https://img.shields.io/gem/dt/contractful.svg)](https://rubygems.org/gems/contractful)
4
+ [![CI](https://github.com/shubhamtaywade82/contractful/actions/workflows/ci.yml/badge.svg)](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.