http_decoy 0.1.0 → 0.2.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 +4 -4
- data/.rubocop.yml +3 -3
- data/CHANGELOG.md +34 -11
- data/CONTRIBUTING.md +22 -50
- data/README.md +123 -31
- data/Rakefile +10 -1
- data/lib/http_decoy/body_matcher.rb +27 -0
- data/lib/{httpfake → http_decoy}/configuration.rb +1 -1
- data/lib/http_decoy/definition.rb +18 -0
- data/lib/{httpfake → http_decoy}/handler_context.rb +9 -5
- data/lib/http_decoy/minitest.rb +185 -0
- data/lib/{httpfake → http_decoy}/request_log.rb +1 -1
- data/lib/{httpfake → http_decoy}/route.rb +1 -1
- data/lib/{httpfake → http_decoy}/route_map.rb +1 -1
- data/lib/{httpfake → http_decoy}/router.rb +1 -1
- data/lib/{httpfake → http_decoy}/rspec.rb +29 -39
- data/lib/{httpfake → http_decoy}/server.rb +36 -2
- data/lib/http_decoy/version.rb +5 -0
- data/lib/{httpfake → http_decoy}/webmock_integration.rb +4 -4
- data/lib/http_decoy.rb +53 -0
- metadata +26 -23
- data/lib/httpfake/version.rb +0 -5
- data/lib/httpfake.rb +0 -48
- /data/sig/{httpfake.rbs → http_decoy.rbs} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c07411035e25bff04ba5ca53164ae73c6848e6431823f2e47332548c8214a7d
|
|
4
|
+
data.tar.gz: f1a9a1c4ee898db259bd4cafae877647d7a91f8066cee5e89378acdcb8e9866d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f28725b5310aa60d176203562a2120aa9a3b115dcaa2acc86f5b25a1e4c3497dd37acc9b0e8b7a470c605f56edb6ab36e96f9c50d5fde52452b7871fc089066
|
|
7
|
+
data.tar.gz: 5480510e433a2c1719191c19fa275c6bd5fe476831eb7a578d19b782d85d363e93ea259faab4bf91f355078cc93f4957a5c5d5f07df322f4655e46712ae6fbac
|
data/.rubocop.yml
CHANGED
|
@@ -53,7 +53,7 @@ Metrics/BlockLength:
|
|
|
53
53
|
Max: 60
|
|
54
54
|
Exclude:
|
|
55
55
|
- "spec/**/*"
|
|
56
|
-
- "
|
|
56
|
+
- "http_decoy.gemspec"
|
|
57
57
|
|
|
58
58
|
Metrics/AbcSize:
|
|
59
59
|
Max: 40
|
|
@@ -68,7 +68,7 @@ Metrics/PerceivedComplexity:
|
|
|
68
68
|
Max: 12
|
|
69
69
|
|
|
70
70
|
# ── RuboCop-RSpec path convention ──────────────────────────────────────────
|
|
71
|
-
# Our module is
|
|
71
|
+
# Our module is HttpDecoy but the gem/dir is http_decoy (no underscore).
|
|
72
72
|
# RSpec/SpecFilePathFormat expects http_fake/ — disable it.
|
|
73
73
|
RSpec/SpecFilePathFormat:
|
|
74
74
|
Enabled: false
|
|
@@ -78,7 +78,7 @@ RSpec/SpecFilePathFormat:
|
|
|
78
78
|
# matcher (e.g. RSpec matchers, Ranges, Regexps all respond to ===).
|
|
79
79
|
Style/CaseEquality:
|
|
80
80
|
Exclude:
|
|
81
|
-
- "lib/
|
|
81
|
+
- "lib/http_decoy/rspec.rb"
|
|
82
82
|
|
|
83
83
|
# Entry uses :http_method not :method, so this is moot — kept for awareness.
|
|
84
84
|
Lint/StructNewOverride:
|
data/CHANGELOG.md
CHANGED
|
@@ -7,16 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] — 2026-07-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HttpDecoy::Minitest` — Minitest integration, parallel to `HttpDecoy::RSpec`:
|
|
15
|
+
- `include HttpDecoy::Minitest` + `fake_server(name) { ... }` class macro (inline pattern)
|
|
16
|
+
- `HttpDecoy.define(:name) { ... }.minitest_helpers` — suite-wide helper module, shared with RSpec via the same `Definition`
|
|
17
|
+
- `assert_received_request(server, method, path, times:, body:)` / `refute_received_request(server, method, path)` assertions
|
|
18
|
+
- `with_scenario(:name) { ... }` instance method, same semantics as the RSpec version
|
|
19
|
+
- `require "http_decoy/minitest"` never loads RSpec, and `require "http_decoy/rspec"` never loads Minitest
|
|
20
|
+
- `respond(status, ..., after: seconds)` — delay a response by a real, measurable amount of time; useful for testing timeout thresholds and loading states against a wall clock rather than a raised exception. Works with `respond_sequence` entries too.
|
|
21
|
+
- `raise_error(:timeout | :reset | :refused)` now actually terminates the TCP connection when the fake server is reached over a real socket (not via WebMock interception) — previously this path silently returned a normal `500` response instead of simulating a dropped connection, so code that specifically handles `Errno::ECONNRESET`/timeouts was never exercised unless WebMock was in the loop. Uses `SO_LINGER` to force a real RST rather than a clean EOF.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- `HttpDecoy.define(:name) { ... }.rspec_helpers` used standalone via `RSpec.configure { |c| c.include Foo.rspec_helpers }` (the primary documented usage) raised `NoMethodError` on `_http_decoy_register` unless the example group also separately did `include HttpDecoy::RSpec`. Ruby's `included` hook doesn't cascade through nested `include`s, so the generated helper module's `included` callback now explicitly extends `ClassMethods` onto the includer.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `HttpDecoy::Definition` moved to its own file (`definition.rb`) so `HttpDecoy.define` no longer force-loads RSpec — a Minitest-only project including `http_decoy/minitest` never pulls in `rspec/core`, and vice versa.
|
|
30
|
+
- Deduplicated the request-body matcher shared by the RSpec `have_received_request(...).with(body:)` chain and the new Minitest `assert_received_request(..., body:)` into `HttpDecoy::BodyMatcher`.
|
|
31
|
+
|
|
10
32
|
## [0.1.0] — 2026-06-02
|
|
11
33
|
|
|
12
34
|
### Added
|
|
13
35
|
|
|
14
|
-
- `
|
|
15
|
-
- `
|
|
16
|
-
- `
|
|
17
|
-
- `
|
|
18
|
-
- `
|
|
19
|
-
- `
|
|
36
|
+
- `HttpDecoy.define` — declare a named fake service with a DSL block
|
|
37
|
+
- `HttpDecoy::RouteMap` — route definition DSL (`get`, `post`, `put`, `patch`, `delete`)
|
|
38
|
+
- `HttpDecoy::Router` — path matching with `:param` template segments
|
|
39
|
+
- `HttpDecoy::Server` — real WEBrick HTTP server on OS-assigned port (port 0); starts in a background thread, stops cleanly
|
|
40
|
+
- `HttpDecoy::RequestLog` — thread-safe store of every received request; powers assertion helpers
|
|
41
|
+
- `HttpDecoy::HandlerContext` — `instance_eval` DSL surface inside handler blocks:
|
|
20
42
|
- `respond(status, json:, text:, headers:)` — build a response
|
|
21
43
|
- `respond_sequence(*entries)` — return different responses on successive calls
|
|
22
44
|
- `requires_body(*keys)` — assert required fields are present; raises `ContractError` with descriptive message if not
|
|
@@ -25,15 +47,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
25
47
|
- `body`, `path_params`, `query_params` — request data accessors
|
|
26
48
|
- Lambdas in JSON response values are resolved at request time (`-> { body[:amount] }`)
|
|
27
49
|
- `fake_server(name) { ... }` — class-level RSpec macro; starts a fresh server per example, tears it down after
|
|
28
|
-
- `
|
|
50
|
+
- `HttpDecoy.define(:name) { ... }.rspec_helpers` — suite-wide helper module pattern
|
|
29
51
|
- `with_scenario(:name) { ... }` — activate a named failure/alternate scenario in a test block
|
|
30
52
|
- `have_received_request(method, path)` — RSpec matcher with `.once`, `.twice`, `.times(n)`, `.with(body:)` chains
|
|
31
|
-
- WebMock auto-detection: if WebMock is loaded and `base_url` is declared, requests are intercepted automatically; teardown removes only
|
|
32
|
-
- `
|
|
53
|
+
- WebMock auto-detection: if WebMock is loaded and `base_url` is declared, requests are intercepted automatically; teardown removes only http_decoy's stub
|
|
54
|
+
- `HttpDecoy.configure { |c| c.auto_intercept = false }` — opt out of automatic WebMock interception
|
|
33
55
|
- Supports `application/json`, `application/x-www-form-urlencoded`, and raw bodies
|
|
34
56
|
- Compatible with Rack 2.x and Rack 3.x
|
|
35
57
|
- Ruby 3.1+ support
|
|
36
58
|
- 92% test coverage (SimpleCov), 66 examples
|
|
37
59
|
|
|
38
|
-
[Unreleased]: https://github.com/
|
|
39
|
-
[0.
|
|
60
|
+
[Unreleased]: https://github.com/jibranusman95/http_decoy/compare/v0.2.0...HEAD
|
|
61
|
+
[0.2.0]: https://github.com/jibranusman95/http_decoy/compare/v0.1.0...v0.2.0
|
|
62
|
+
[0.1.0]: https://github.com/jibranusman95/http_decoy/releases/tag/v0.1.0
|
data/CONTRIBUTING.md
CHANGED
|
@@ -1,67 +1,39 @@
|
|
|
1
|
-
# Contributing to
|
|
1
|
+
# Contributing to http_decoy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
First: thank you. It genuinely means a lot.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
git clone https://github.com/
|
|
9
|
-
cd
|
|
8
|
+
git clone https://github.com/jibranusman95/http_decoy
|
|
9
|
+
cd http_decoy
|
|
10
10
|
bundle install
|
|
11
|
+
bundle exec rspec # RSpec suite — make sure everything is green before you start
|
|
12
|
+
bundle exec rake test # Minitest suite
|
|
13
|
+
bundle exec rubocop # no existing offenses to inherit
|
|
11
14
|
```
|
|
12
15
|
|
|
13
|
-
##
|
|
16
|
+
## How to contribute
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
bundle exec rspec # full suite
|
|
17
|
-
bundle exec rspec spec/httpfake/server_spec.rb # single file
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## Linting
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
bundle exec rubocop # check
|
|
24
|
-
bundle exec rubocop -a # autocorrect safe offenses
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Both must be green before a PR can be merged. CI enforces this on every push.
|
|
28
|
-
|
|
29
|
-
## Submitting a pull request
|
|
30
|
-
|
|
31
|
-
1. Fork the repo and create a branch from `main`
|
|
32
|
-
2. Write a failing test that describes the bug or feature
|
|
33
|
-
3. Make it pass
|
|
34
|
-
4. Run `bundle exec rspec` and `bundle exec rubocop` — both must be clean
|
|
35
|
-
5. Update `CHANGELOG.md` under `[Unreleased]`
|
|
36
|
-
6. Open a PR with a clear description of what and why
|
|
37
|
-
|
|
38
|
-
## What we're looking for
|
|
18
|
+
**Found a bug?** Open an issue. Include a minimal reproduction if you can — it'll get fixed faster.
|
|
39
19
|
|
|
40
|
-
|
|
41
|
-
- New DSL features (propose in an issue first if it's non-trivial)
|
|
42
|
-
- Additional body content-type support
|
|
43
|
-
- Better error messages
|
|
44
|
-
- Real-world usage examples in the README
|
|
20
|
+
**Want a feature?** Open an issue first so we can align before you build. Nothing worse than putting work into something that doesn't fit.
|
|
45
21
|
|
|
46
|
-
|
|
22
|
+
**Have a fix ready?** Just open a PR. One thing per PR. Include a failing test if the change is non-trivial.
|
|
47
23
|
|
|
48
|
-
|
|
24
|
+
**Docs or README improvements?** Also very welcome — just send the PR directly.
|
|
49
25
|
|
|
50
|
-
##
|
|
26
|
+
## Before you submit
|
|
51
27
|
|
|
52
|
-
- `
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
28
|
+
- `bundle exec rspec` — all green
|
|
29
|
+
- `bundle exec rake test` — all green (Minitest suite)
|
|
30
|
+
- `bundle exec rubocop` — no new offenses
|
|
31
|
+
- Focused scope — one fix or feature per PR
|
|
56
32
|
|
|
57
|
-
##
|
|
33
|
+
## What happens next
|
|
58
34
|
|
|
59
|
-
|
|
60
|
-
- Ruby version (`ruby --version`)
|
|
61
|
-
- httpfake version
|
|
62
|
-
- Minimal reproduction case (ideally a failing RSpec example)
|
|
63
|
-
- What you expected vs what happened
|
|
35
|
+
I review promptly — usually within a day or two, often faster. I'll either merge it, ask for changes with clear reasoning, or explain why it doesn't fit. No ghosting.
|
|
64
36
|
|
|
65
|
-
##
|
|
37
|
+
## License
|
|
66
38
|
|
|
67
|
-
|
|
39
|
+
By contributing, you agree your changes are released under the same [MIT License](LICENSE) as this project.
|
data/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# http_decoy
|
|
2
2
|
|
|
3
3
|
**A real fake HTTP server. For real tests.**
|
|
4
4
|
|
|
5
|
-
[](https://github.com/jibranusman95/http_decoy/actions)
|
|
6
|
+
[](https://badge.fury.io/rb/http_decoy)
|
|
7
|
+
[](https://rubygems.org/gems/http_decoy)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
10
|
---
|
|
@@ -13,7 +13,7 @@ Your WebMock stubs are lying to you.
|
|
|
13
13
|
|
|
14
14
|
They test that your code constructs the right HTTP call. Not that the API would accept it. Not that the response shape matches what your code expects. Not that you haven't been sending a stale request format for six months while production quietly breaks.
|
|
15
15
|
|
|
16
|
-
**
|
|
16
|
+
**http_decoy spins up a real Rack server inside your tests** — one that validates incoming request contracts, computes dynamic responses from real inputs, and fails loudly the moment your code sends something wrong.
|
|
17
17
|
|
|
18
18
|
No cassettes. No scattered stubs. No surprises on deploy day.
|
|
19
19
|
|
|
@@ -56,10 +56,10 @@ This test passes even if:
|
|
|
56
56
|
|
|
57
57
|
You end up with 50 YAML files nobody touches, all slowly diverging from reality.
|
|
58
58
|
|
|
59
|
-
### Test 3 —
|
|
59
|
+
### Test 3 — http_decoy (what tests should look like)
|
|
60
60
|
|
|
61
61
|
```ruby
|
|
62
|
-
FakeStripe =
|
|
62
|
+
FakeStripe = HttpDecoy.define(:stripe) do
|
|
63
63
|
base_url "https://api.stripe.com"
|
|
64
64
|
|
|
65
65
|
post "/v1/charges" do
|
|
@@ -95,7 +95,7 @@ Now your tests:
|
|
|
95
95
|
```ruby
|
|
96
96
|
# Gemfile
|
|
97
97
|
group :test do
|
|
98
|
-
gem "
|
|
98
|
+
gem "http_decoy"
|
|
99
99
|
end
|
|
100
100
|
```
|
|
101
101
|
|
|
@@ -111,7 +111,7 @@ bundle install
|
|
|
111
111
|
|
|
112
112
|
```ruby
|
|
113
113
|
# spec/support/fakes/fake_stripe.rb
|
|
114
|
-
FakeStripe =
|
|
114
|
+
FakeStripe = HttpDecoy.define(:stripe) do
|
|
115
115
|
base_url "https://api.stripe.com"
|
|
116
116
|
|
|
117
117
|
post "/v1/charges" do
|
|
@@ -148,7 +148,7 @@ end
|
|
|
148
148
|
|
|
149
149
|
```ruby
|
|
150
150
|
# spec/spec_helper.rb
|
|
151
|
-
require "
|
|
151
|
+
require "http_decoy"
|
|
152
152
|
require "support/fakes/fake_stripe"
|
|
153
153
|
|
|
154
154
|
RSpec.configure do |config|
|
|
@@ -182,9 +182,9 @@ RSpec.describe StripeService do
|
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
it "catches bad requests before they reach prod" do
|
|
185
|
-
# Missing payment_method —
|
|
185
|
+
# Missing payment_method — http_decoy raises immediately with a descriptive error
|
|
186
186
|
expect { StripeService.charge(amount: 2000, currency: "usd") }
|
|
187
|
-
.to raise_error(
|
|
187
|
+
.to raise_error(HttpDecoy::HandlerContext::ContractError, /payment_method is required/)
|
|
188
188
|
end
|
|
189
189
|
end
|
|
190
190
|
end
|
|
@@ -199,7 +199,7 @@ No setup per test. No per-test `stub_request`. No cassette files.
|
|
|
199
199
|
### Defining a server
|
|
200
200
|
|
|
201
201
|
```ruby
|
|
202
|
-
MyFakeService =
|
|
202
|
+
MyFakeService = HttpDecoy.define(:my_service) do
|
|
203
203
|
base_url "https://api.example.com" # intercepted via WebMock automatically
|
|
204
204
|
# ...routes
|
|
205
205
|
end
|
|
@@ -243,7 +243,7 @@ post "/orders" do
|
|
|
243
243
|
end
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
-
When validation fails,
|
|
246
|
+
When validation fails, http_decoy raises `HttpDecoy::HandlerContext::ContractError` with a message naming the exact field and rule. Your test fails at the right place, with the right message.
|
|
247
247
|
|
|
248
248
|
### Dynamic responses
|
|
249
249
|
|
|
@@ -279,6 +279,25 @@ end
|
|
|
279
279
|
|
|
280
280
|
Available transport errors: `:timeout`, `:reset`, `:refused`.
|
|
281
281
|
|
|
282
|
+
How the client observes this depends on how the request reached http_decoy:
|
|
283
|
+
|
|
284
|
+
- **Through WebMock's interception** (the default, whenever `base_url` is declared) — the exact matching Ruby exception (`Timeout::Error`, `Errno::ECONNRESET`, `Errno::ECONNREFUSED`) is raised directly at your HTTP client's call site, so `rescue Errno::ECONNRESET` in your code sees a real instance of that class.
|
|
285
|
+
- **Hitting the server directly over a real socket** (`server.base_url` with no WebMock in between — e.g. an SDK-under-test that owns its own connection) — there's no HTTP status code for "the connection died," so http_decoy actually terminates the TCP connection (via `SO_LINGER`, which forces the kernel to send RST) rather than returning a response. Your client sees a genuine connection failure, not a 500.
|
|
286
|
+
|
|
287
|
+
Either way, an unhandled exception from your *own* handler code (a real bug, not `raise_error`) still returns a normal `500` with the error message in the body — that path is unchanged.
|
|
288
|
+
|
|
289
|
+
### Simulating latency
|
|
290
|
+
|
|
291
|
+
`respond` accepts `after:` (seconds) to delay the response — useful for testing timeout thresholds, loading states, or spinners against a real clock instead of a raised exception:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
get "/slow-report" do
|
|
295
|
+
respond 200, json: { status: "ready" }, after: 2.5
|
|
296
|
+
end
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Works with `respond_sequence` too — set `after:` per entry to simulate a service that degrades over successive calls.
|
|
300
|
+
|
|
282
301
|
### Stateful sequences
|
|
283
302
|
|
|
284
303
|
```ruby
|
|
@@ -324,7 +343,7 @@ Inline per describe block:
|
|
|
324
343
|
|
|
325
344
|
```ruby
|
|
326
345
|
RSpec.describe "degraded upstream" do
|
|
327
|
-
include
|
|
346
|
+
include HttpDecoy::RSpec
|
|
328
347
|
|
|
329
348
|
fake_server(:api) do
|
|
330
349
|
get "/status" do
|
|
@@ -340,11 +359,50 @@ end
|
|
|
340
359
|
|
|
341
360
|
---
|
|
342
361
|
|
|
362
|
+
## Minitest integration
|
|
363
|
+
|
|
364
|
+
Same DSL, same `HttpDecoy.define` definitions — `require "http_decoy/minitest"` never loads RSpec, and vice versa.
|
|
365
|
+
|
|
366
|
+
Suite-wide (recommended) — same `FakeStripe` definition as above, shared across both frameworks:
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
class ChargeTest < Minitest::Test
|
|
370
|
+
include FakeStripe.minitest_helpers
|
|
371
|
+
|
|
372
|
+
def test_charges_the_card
|
|
373
|
+
StripeService.charge(500)
|
|
374
|
+
assert_received_request fake_server(:stripe), :post, "/v1/charges"
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Inline per test class:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
class DegradedUpstreamTest < Minitest::Test
|
|
383
|
+
include HttpDecoy::Minitest
|
|
384
|
+
|
|
385
|
+
fake_server(:api) do
|
|
386
|
+
get "/status" do
|
|
387
|
+
respond 503, json: { status: "degraded" }
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def test_handles_it_gracefully
|
|
392
|
+
assert_equal :degraded, MyApp.health_check
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Assertions: `assert_received_request(server, method, path, times:, body:)` and `refute_received_request(server, method, path)` mirror the RSpec `have_received_request` matcher's chains.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
343
401
|
## Configuration
|
|
344
402
|
|
|
345
403
|
```ruby
|
|
346
404
|
# Opt out of WebMock auto-interception (e.g. if you manage stubs manually)
|
|
347
|
-
|
|
405
|
+
HttpDecoy.configure do |config|
|
|
348
406
|
config.auto_intercept = false
|
|
349
407
|
end
|
|
350
408
|
```
|
|
@@ -353,19 +411,19 @@ end
|
|
|
353
411
|
|
|
354
412
|
## Why not WebMock / VCR? (honest comparison)
|
|
355
413
|
|
|
356
|
-
| | WebMock | VCR | **
|
|
414
|
+
| | WebMock | VCR | **http_decoy** |
|
|
357
415
|
|---|---|---|---|
|
|
358
|
-
| Real server |
|
|
359
|
-
| Request contract validation |
|
|
360
|
-
| Dynamic responses |
|
|
416
|
+
| Real server | optional | No | **Yes** |
|
|
417
|
+
| Request contract validation | possible | No | **Yes** |
|
|
418
|
+
| Dynamic responses | yes | No | **Yes** |
|
|
361
419
|
| Failure scenario testing | Verbose | Very hard | **One line** |
|
|
362
420
|
| Works offline | Yes | First run: No | **Yes** |
|
|
363
421
|
| Secrets in version control | No | **Risk** | No |
|
|
364
422
|
| Cassettes to maintain | No | **Yes** | No |
|
|
365
423
|
| Define once, use everywhere | Requires setup | Yes | **Yes** |
|
|
366
|
-
| Catches API drift |
|
|
424
|
+
| Catches API drift | possible | No | **Yes** |
|
|
367
425
|
|
|
368
|
-
|
|
426
|
+
http_decoy uses WebMock internally to intercept requests — complementary, not a replacement.
|
|
369
427
|
|
|
370
428
|
---
|
|
371
429
|
|
|
@@ -375,7 +433,7 @@ httpfake uses WebMock internally to intercept requests — complementary, not a
|
|
|
375
433
|
<summary>Stripe (payments)</summary>
|
|
376
434
|
|
|
377
435
|
```ruby
|
|
378
|
-
FakeStripe =
|
|
436
|
+
FakeStripe = HttpDecoy.define(:stripe) do
|
|
379
437
|
base_url "https://api.stripe.com"
|
|
380
438
|
|
|
381
439
|
post "/v1/payment_intents" do
|
|
@@ -404,7 +462,7 @@ end
|
|
|
404
462
|
<summary>SendGrid (email)</summary>
|
|
405
463
|
|
|
406
464
|
```ruby
|
|
407
|
-
FakeSendGrid =
|
|
465
|
+
FakeSendGrid = HttpDecoy.define(:sendgrid) do
|
|
408
466
|
base_url "https://api.sendgrid.com"
|
|
409
467
|
|
|
410
468
|
post "/v3/mail/send" do
|
|
@@ -423,7 +481,7 @@ end
|
|
|
423
481
|
<summary>Internal microservice</summary>
|
|
424
482
|
|
|
425
483
|
```ruby
|
|
426
|
-
FakeInventory =
|
|
484
|
+
FakeInventory = HttpDecoy.define(:inventory) do
|
|
427
485
|
base_url "https://inventory.internal"
|
|
428
486
|
|
|
429
487
|
get "/products/:sku/stock" do
|
|
@@ -452,27 +510,61 @@ end
|
|
|
452
510
|
- Ruby 3.1+
|
|
453
511
|
- Runtime dependencies: `webrick`, `rack` (both lightweight)
|
|
454
512
|
- Optional: `webmock` for URL interception
|
|
513
|
+
- Works with RSpec or Minitest — neither is a runtime dependency; only the integration you `require` gets loaded
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## Further Reading
|
|
518
|
+
|
|
519
|
+
- [Why WebMock Stubs Lie (And What To Do About It)](https://dev.to/jibranusman95/why-webmock-stubs-lie-and-what-to-do-about-it-4990) — the problem http_decoy was built to solve, with examples
|
|
455
520
|
|
|
456
521
|
---
|
|
457
522
|
|
|
458
523
|
## Contributing
|
|
459
524
|
|
|
525
|
+
I built this myself — which means it works great for the cases I thought of, and probably has rough edges for the ones I didn't. If you hit something weird, **open an issue**. I read them all and respond fast.
|
|
526
|
+
|
|
527
|
+
Want to fix something or add a feature? **Send a PR.** No CLA, no process overhead, no committee review. If the tests pass and the change makes sense, it's getting merged. I'm one person and I genuinely appreciate the help — you can take this further than I can alone.
|
|
528
|
+
|
|
529
|
+
Not sure where to start? Look for [`good first issue`](https://github.com/jibranusman95/http_decoy/issues?q=label%3A%22good+first+issue%22) labels, or just open an issue and ask.
|
|
530
|
+
|
|
460
531
|
```bash
|
|
461
|
-
git clone https://github.com/
|
|
462
|
-
cd
|
|
532
|
+
git clone https://github.com/jibranusman95/http_decoy
|
|
533
|
+
cd http_decoy
|
|
463
534
|
bundle install
|
|
464
|
-
bundle exec rspec
|
|
465
|
-
bundle exec
|
|
535
|
+
bundle exec rspec # RSpec suite
|
|
536
|
+
bundle exec rake test # Minitest suite
|
|
537
|
+
bundle exec rubocop # no new offenses
|
|
466
538
|
```
|
|
467
539
|
|
|
468
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines.
|
|
540
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines.
|
|
541
|
+
|
|
542
|
+
### Contributors
|
|
543
|
+
|
|
544
|
+
Everyone who's made this better:
|
|
545
|
+
|
|
546
|
+
<a href="https://github.com/jibranusman95/http_decoy/graphs/contributors">
|
|
547
|
+
<img src="https://contrib.rocks/image?repo=jibranusman95/http_decoy" />
|
|
548
|
+
</a>
|
|
469
549
|
|
|
470
550
|
---
|
|
471
551
|
|
|
552
|
+
## From the same author
|
|
553
|
+
|
|
554
|
+
Small, sharp Ruby gems built to the same standard — 100% test coverage, zero dependencies beyond what's needed.
|
|
555
|
+
|
|
556
|
+
| Gem | What it does |
|
|
557
|
+
|-----|-------------|
|
|
558
|
+
| [llm_cassette](https://github.com/jibranusman95/llm_cassette) | VCR for LLMs — streaming-aware cassette recorder for OpenAI and Anthropic |
|
|
559
|
+
| [turbo_presence](https://github.com/jibranusman95/turbo_presence) | Figma-style live cursors, avatar stacks, and typing indicators for Rails/Hotwire |
|
|
560
|
+
| [promptscrub](https://github.com/jibranusman95/promptscrub) | PII redaction middleware for LLM calls — strip sensitive data from prompts, rehydrate in responses |
|
|
561
|
+
| [webhook_inbox](https://github.com/jibranusman95/webhook_inbox) | Transactional inbox for Rails webhook receivers — deduplication, async processing, replay, dashboard |
|
|
562
|
+
| [agent_jail](https://github.com/jibranusman95/agent_jail) | Fork-based sandbox for LLM tool calls — timeout, memory limit, and filesystem restrictions |
|
|
563
|
+
|
|
472
564
|
## License
|
|
473
565
|
|
|
474
566
|
MIT. See [LICENSE](LICENSE).
|
|
475
567
|
|
|
476
568
|
---
|
|
477
569
|
|
|
478
|
-
*
|
|
570
|
+
*http_decoy — stop testing your assumptions, start testing your contracts.*
|
data/Rakefile
CHANGED
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rspec/core/rake_task"
|
|
5
|
+
require "rake/testtask"
|
|
5
6
|
|
|
6
7
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
Rake::TestTask.new(:test) do |t|
|
|
10
|
+
t.libs << "test" << "lib"
|
|
11
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
12
|
+
# Default -w flag surfaces a harmless "circular require" warning from
|
|
13
|
+
# webmock/minitest loading against minitest's own loader — not our bug.
|
|
14
|
+
t.warning = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
task default: %i[spec test]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpDecoy
|
|
4
|
+
# Shared by the RSpec `have_received_request(...).with(body:)` chain and the
|
|
5
|
+
# Minitest `assert_received_request(..., body:)` keyword — matches a logged
|
|
6
|
+
# request body against either an exact Hash of expected key/value matchers
|
|
7
|
+
# (each value tested via `===`, so RSpec matchers, classes, and regexes all
|
|
8
|
+
# work) or a single matcher applied to the whole body.
|
|
9
|
+
module BodyMatcher
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# rubocop:disable Style/CaseEquality -- `===` is the point: callers pass
|
|
13
|
+
# RSpec matchers, Regexp, Class, or plain values as `matcher`/`v`.
|
|
14
|
+
def matches?(actual, matcher)
|
|
15
|
+
case matcher
|
|
16
|
+
when Hash
|
|
17
|
+
actual.is_a?(Hash) && matcher.all? do |k, v|
|
|
18
|
+
actual_val = actual[k] || actual[k.to_s]
|
|
19
|
+
v === actual_val
|
|
20
|
+
end
|
|
21
|
+
else
|
|
22
|
+
matcher === actual
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
# rubocop:enable Style/CaseEquality
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpDecoy
|
|
4
|
+
# Wraps a named RouteMap so it can be shared across multiple test files
|
|
5
|
+
# and reused across test frameworks.
|
|
6
|
+
#
|
|
7
|
+
# Framework-specific helper methods are added by whichever integration
|
|
8
|
+
# file is loaded: `require "http_decoy/rspec"` adds #rspec_helpers,
|
|
9
|
+
# `require "http_decoy/minitest"` adds #minitest_helpers.
|
|
10
|
+
class Definition
|
|
11
|
+
attr_reader :name, :route_map
|
|
12
|
+
|
|
13
|
+
def initialize(name, route_map)
|
|
14
|
+
@name = name
|
|
15
|
+
@route_map = route_map
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module HttpDecoy
|
|
6
6
|
# The `self` inside every route handler block.
|
|
7
7
|
# Provides the full DSL surface: respond, requires_body, validates,
|
|
8
8
|
# body, path_params, query_params, respond_sequence, raise_error.
|
|
@@ -42,7 +42,11 @@ module HttpFake
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Build and store the response tuple for this request.
|
|
45
|
-
|
|
45
|
+
# `after:` delays the response by the given number of seconds — useful
|
|
46
|
+
# for testing timeout thresholds and loading states against a real clock,
|
|
47
|
+
# not just a raised exception.
|
|
48
|
+
def respond(status, json: nil, text: nil, headers: {}, after: nil)
|
|
49
|
+
sleep(after) if after&.positive?
|
|
46
50
|
body_str = json ? JSON.generate(resolve(json)) : text.to_s
|
|
47
51
|
content_type = json ? "application/json" : "text/plain"
|
|
48
52
|
@_response = [status.to_i, { "Content-Type" => content_type }.merge(headers), [body_str]]
|
|
@@ -81,9 +85,9 @@ module HttpFake
|
|
|
81
85
|
# Simulate transport-level failures.
|
|
82
86
|
def raise_error(type)
|
|
83
87
|
case type
|
|
84
|
-
when :timeout then raise Timeout::Error, "
|
|
85
|
-
when :reset then raise Errno::ECONNRESET, "
|
|
86
|
-
when :refused then raise Errno::ECONNREFUSED, "
|
|
88
|
+
when :timeout then raise Timeout::Error, "http_decoy simulated timeout"
|
|
89
|
+
when :reset then raise Errno::ECONNRESET, "http_decoy simulated connection reset"
|
|
90
|
+
when :refused then raise Errno::ECONNREFUSED, "http_decoy simulated connection refused"
|
|
87
91
|
else raise type.is_a?(Class) ? type : RuntimeError, type.to_s
|
|
88
92
|
end
|
|
89
93
|
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "route_map"
|
|
4
|
+
require_relative "server"
|
|
5
|
+
require_relative "webmock_integration"
|
|
6
|
+
require_relative "definition"
|
|
7
|
+
require_relative "body_matcher"
|
|
8
|
+
|
|
9
|
+
module HttpDecoy
|
|
10
|
+
# Minitest integration. Same server lifecycle and semantics as
|
|
11
|
+
# HttpDecoy::RSpec (fresh Server per test, WebMock stub scoped to that
|
|
12
|
+
# server, teardown never calls WebMock.reset!) hooked into Minitest's
|
|
13
|
+
# setup/teardown instead of RSpec's before/after.
|
|
14
|
+
#
|
|
15
|
+
# Pattern A — inline (per test class):
|
|
16
|
+
#
|
|
17
|
+
# class ChargeTest < Minitest::Test
|
|
18
|
+
# include HttpDecoy::Minitest
|
|
19
|
+
#
|
|
20
|
+
# fake_server(:payments) do
|
|
21
|
+
# post "/charges" do
|
|
22
|
+
# respond 201, json: { id: "ch_abc" }
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# def test_creates_a_charge
|
|
27
|
+
# MyService.charge(100)
|
|
28
|
+
# assert_received_request fake_server(:payments), :post, "/charges"
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Pattern B — suite-wide definition (shared with RSpec):
|
|
33
|
+
#
|
|
34
|
+
# FakeStripe = HttpDecoy.define(:stripe) do
|
|
35
|
+
# base_url "https://api.stripe.com"
|
|
36
|
+
# post "/v1/charges" do
|
|
37
|
+
# respond 200, json: { id: "ch_123" }
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# class ChargeTest < Minitest::Test
|
|
42
|
+
# include FakeStripe.minitest_helpers
|
|
43
|
+
#
|
|
44
|
+
# def test_charges_the_card
|
|
45
|
+
# StripeService.charge(500)
|
|
46
|
+
# assert_received_request fake_server(:stripe), :post, "/v1/charges"
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
module Minitest
|
|
50
|
+
def self.included(base)
|
|
51
|
+
base.extend(ClassMethods)
|
|
52
|
+
base.include(Assertions)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module ClassMethods
|
|
56
|
+
# Class-level macro. Evaluates the block into a RouteMap once at class-load
|
|
57
|
+
# time, then registers it for the shared setup/teardown hooks.
|
|
58
|
+
def fake_server(name, &)
|
|
59
|
+
route_map = RouteMap.new
|
|
60
|
+
route_map.instance_eval(&)
|
|
61
|
+
_http_decoy_register(name, route_map)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Internal: register a pre-built RouteMap and install setup/teardown
|
|
65
|
+
# (once per class) that start/stop every registered server.
|
|
66
|
+
# Called by both the fake_server macro and Definition#minitest_helpers.
|
|
67
|
+
def _http_decoy_register(name, route_map)
|
|
68
|
+
_http_decoy_route_maps[name] = route_map
|
|
69
|
+
_http_decoy_install_hooks
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def _http_decoy_route_maps
|
|
73
|
+
@_http_decoy_route_maps ||= {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def _http_decoy_install_hooks
|
|
77
|
+
return if @_http_decoy_hooks_installed
|
|
78
|
+
|
|
79
|
+
@_http_decoy_hooks_installed = true
|
|
80
|
+
|
|
81
|
+
define_method(:setup) do
|
|
82
|
+
super()
|
|
83
|
+
@_http_decoy_servers = {}
|
|
84
|
+
@_http_decoy_webmock_stubs = {}
|
|
85
|
+
|
|
86
|
+
self.class._http_decoy_route_maps.each do |server_name, route_map|
|
|
87
|
+
server = Server.new(route_map)
|
|
88
|
+
server.start
|
|
89
|
+
stub = WebMockIntegration.setup(server)
|
|
90
|
+
@_http_decoy_servers[server_name] = server
|
|
91
|
+
@_http_decoy_webmock_stubs[server_name] = stub
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
define_method(:teardown) do
|
|
96
|
+
(@_http_decoy_servers || {}).each_key do |server_name|
|
|
97
|
+
server = @_http_decoy_servers[server_name]
|
|
98
|
+
stub = @_http_decoy_webmock_stubs[server_name]
|
|
99
|
+
WebMockIntegration.teardown(stub)
|
|
100
|
+
server&.stop
|
|
101
|
+
end
|
|
102
|
+
super()
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Instance-level accessor — returns the live Server for this test.
|
|
108
|
+
def fake_server(name)
|
|
109
|
+
@_http_decoy_servers[name]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Run a block with a named scenario active.
|
|
113
|
+
# server_name defaults to the only server if exactly one is registered.
|
|
114
|
+
def with_scenario(scenario_name, server_name = nil, &)
|
|
115
|
+
name = server_name || begin
|
|
116
|
+
servers = @_http_decoy_servers || {}
|
|
117
|
+
raise ArgumentError, "server_name required when multiple fake servers are active" if servers.size > 1
|
|
118
|
+
raise ArgumentError, "No fake servers are active" if servers.empty?
|
|
119
|
+
|
|
120
|
+
servers.keys.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
server = @_http_decoy_servers[name]
|
|
124
|
+
raise ArgumentError, "No fake server named #{name.inspect}" unless server
|
|
125
|
+
|
|
126
|
+
server.with_scenario(scenario_name, &)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Minitest assertions
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
module Assertions
|
|
133
|
+
def assert_received_request(server, method, path, times: nil, body: nil)
|
|
134
|
+
entries = server.request_log.for(method, path)
|
|
135
|
+
description = "#{method.to_s.upcase} #{path}"
|
|
136
|
+
|
|
137
|
+
if times
|
|
138
|
+
assert_equal times, entries.count,
|
|
139
|
+
"expected #{description} to have been received #{times} time(s), " \
|
|
140
|
+
"but it was received #{entries.count} time(s)"
|
|
141
|
+
else
|
|
142
|
+
assert entries.any?, "expected #{description} to have been received, but it was never called"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return unless body
|
|
146
|
+
|
|
147
|
+
assert entries.any? { |e| HttpDecoy::BodyMatcher.matches?(e.body, body) },
|
|
148
|
+
"expected #{description} body to match #{body.inspect}, but received: #{entries.map(&:body).inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def refute_received_request(server, method, path)
|
|
152
|
+
entries = server.request_log.for(method, path)
|
|
153
|
+
assert entries.empty?, "expected #{method.to_s.upcase} #{path} not to have been received"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Reopens Definition (see definition.rb) to add the Minitest-specific helper.
|
|
159
|
+
class Definition
|
|
160
|
+
# Returns an anonymous module. Include it in a Minitest::Test subclass to
|
|
161
|
+
# register the server lifecycle for every test in that class.
|
|
162
|
+
#
|
|
163
|
+
# include FakeStripe.minitest_helpers
|
|
164
|
+
#
|
|
165
|
+
def minitest_helpers
|
|
166
|
+
definition = self
|
|
167
|
+
|
|
168
|
+
Module.new do
|
|
169
|
+
include HttpDecoy::Minitest
|
|
170
|
+
|
|
171
|
+
# See the matching comment in rspec.rb — the explicit `extend` is
|
|
172
|
+
# required because Ruby's `included` hook does not cascade through
|
|
173
|
+
# nested includes.
|
|
174
|
+
define_singleton_method(:included) do |base|
|
|
175
|
+
super(base)
|
|
176
|
+
base.extend(HttpDecoy::Minitest::ClassMethods)
|
|
177
|
+
base.include(HttpDecoy::Minitest::Assertions)
|
|
178
|
+
base._http_decoy_register(definition.name, definition.route_map)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
define_method(:_http_decoy_definition) { definition }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -4,8 +4,10 @@ require "rspec/core"
|
|
|
4
4
|
require_relative "route_map"
|
|
5
5
|
require_relative "server"
|
|
6
6
|
require_relative "webmock_integration"
|
|
7
|
+
require_relative "definition"
|
|
8
|
+
require_relative "body_matcher"
|
|
7
9
|
|
|
8
|
-
module
|
|
10
|
+
module HttpDecoy
|
|
9
11
|
# RSpec integration.
|
|
10
12
|
#
|
|
11
13
|
# Two equivalent usage patterns:
|
|
@@ -13,7 +15,7 @@ module HttpFake
|
|
|
13
15
|
# Pattern A — inline (per describe block):
|
|
14
16
|
#
|
|
15
17
|
# RSpec.describe MyService do
|
|
16
|
-
# include
|
|
18
|
+
# include HttpDecoy::RSpec
|
|
17
19
|
#
|
|
18
20
|
# fake_server(:payments) do
|
|
19
21
|
# post "/charges" do
|
|
@@ -29,7 +31,7 @@ module HttpFake
|
|
|
29
31
|
#
|
|
30
32
|
# Pattern B — suite-wide definition (most common):
|
|
31
33
|
#
|
|
32
|
-
# FakeStripe =
|
|
34
|
+
# FakeStripe = HttpDecoy.define(:stripe) do
|
|
33
35
|
# base_url "https://api.stripe.com"
|
|
34
36
|
# post "/v1/charges" do
|
|
35
37
|
# respond 200, json: { id: "ch_123" }
|
|
@@ -54,26 +56,26 @@ module HttpFake
|
|
|
54
56
|
def fake_server(name, &)
|
|
55
57
|
route_map = RouteMap.new
|
|
56
58
|
route_map.instance_eval(&)
|
|
57
|
-
|
|
59
|
+
_http_decoy_register(name, route_map)
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
# Internal: register before/after hooks for a pre-built RouteMap.
|
|
61
63
|
# Called by both the inline macro and Definition#rspec_helpers.
|
|
62
|
-
def
|
|
64
|
+
def _http_decoy_register(name, route_map)
|
|
63
65
|
before(:each) do
|
|
64
66
|
server = Server.new(route_map)
|
|
65
67
|
server.start
|
|
66
68
|
stub = WebMockIntegration.setup(server)
|
|
67
69
|
|
|
68
|
-
@
|
|
69
|
-
@
|
|
70
|
-
@
|
|
71
|
-
@
|
|
70
|
+
@_http_decoy_servers ||= {}
|
|
71
|
+
@_http_decoy_webmock_stubs ||= {}
|
|
72
|
+
@_http_decoy_servers[name] = server
|
|
73
|
+
@_http_decoy_webmock_stubs[name] = stub
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
after(:each) do
|
|
75
|
-
server = @
|
|
76
|
-
stub = @
|
|
77
|
+
server = @_http_decoy_servers&.[](name)
|
|
78
|
+
stub = @_http_decoy_webmock_stubs&.[](name)
|
|
77
79
|
WebMockIntegration.teardown(stub)
|
|
78
80
|
server&.stop
|
|
79
81
|
end
|
|
@@ -82,21 +84,21 @@ module HttpFake
|
|
|
82
84
|
|
|
83
85
|
# Instance-level accessor — returns the live Server for this example.
|
|
84
86
|
def fake_server(name)
|
|
85
|
-
@
|
|
87
|
+
@_http_decoy_servers[name]
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
# Run a block with a named scenario active.
|
|
89
91
|
# server_name defaults to the only server if exactly one is registered.
|
|
90
92
|
def with_scenario(scenario_name, server_name = nil, &)
|
|
91
93
|
name = server_name || begin
|
|
92
|
-
servers = @
|
|
94
|
+
servers = @_http_decoy_servers || {}
|
|
93
95
|
raise ArgumentError, "server_name required when multiple fake servers are active" if servers.size > 1
|
|
94
96
|
raise ArgumentError, "No fake servers are active" if servers.empty?
|
|
95
97
|
|
|
96
98
|
servers.keys.first
|
|
97
99
|
end
|
|
98
100
|
|
|
99
|
-
server = @
|
|
101
|
+
server = @_http_decoy_servers[name]
|
|
100
102
|
raise ArgumentError, "No fake server named #{name.inspect}" unless server
|
|
101
103
|
|
|
102
104
|
server.with_scenario(scenario_name, &)
|
|
@@ -112,7 +114,7 @@ module HttpFake
|
|
|
112
114
|
entries = server.request_log.for(method, path)
|
|
113
115
|
next false if entries.empty?
|
|
114
116
|
next false if @times && entries.count != @times
|
|
115
|
-
next false if @body_matcher && entries.none? { |e|
|
|
117
|
+
next false if @body_matcher && entries.none? { |e| HttpDecoy::BodyMatcher.matches?(e.body, @body_matcher) }
|
|
116
118
|
|
|
117
119
|
true
|
|
118
120
|
end
|
|
@@ -154,33 +156,14 @@ module HttpFake
|
|
|
154
156
|
desc += " with body matching #{@body_matcher.inspect}" if @body_matcher
|
|
155
157
|
desc
|
|
156
158
|
end
|
|
157
|
-
|
|
158
|
-
def body_matches?(actual, matcher)
|
|
159
|
-
case matcher
|
|
160
|
-
when Hash
|
|
161
|
-
actual.is_a?(Hash) && matcher.all? do |k, v|
|
|
162
|
-
actual_val = actual[k] || actual[k.to_s]
|
|
163
|
-
v === actual_val
|
|
164
|
-
end
|
|
165
|
-
else
|
|
166
|
-
matcher === actual
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
159
|
end
|
|
170
160
|
|
|
171
161
|
# ---------------------------------------------------------------------------
|
|
172
|
-
# Definition — returned by
|
|
162
|
+
# Definition — returned by HttpDecoy.define
|
|
173
163
|
# ---------------------------------------------------------------------------
|
|
174
164
|
|
|
175
|
-
#
|
|
165
|
+
# Reopens Definition (see definition.rb) to add the RSpec-specific helper.
|
|
176
166
|
class Definition
|
|
177
|
-
attr_reader :name, :route_map
|
|
178
|
-
|
|
179
|
-
def initialize(name, route_map)
|
|
180
|
-
@name = name
|
|
181
|
-
@route_map = route_map
|
|
182
|
-
end
|
|
183
|
-
|
|
184
167
|
# Returns an anonymous module. Include it in RSpec.configure to register
|
|
185
168
|
# the server lifecycle for every example group in the suite.
|
|
186
169
|
#
|
|
@@ -190,16 +173,23 @@ module HttpFake
|
|
|
190
173
|
definition = self
|
|
191
174
|
|
|
192
175
|
Module.new do
|
|
193
|
-
include
|
|
176
|
+
include HttpDecoy::RSpec
|
|
194
177
|
|
|
195
178
|
# define_singleton_method closes over `definition` from the outer scope.
|
|
196
179
|
# `def self.included` would NOT — def never captures outer locals.
|
|
180
|
+
#
|
|
181
|
+
# `extend` is required here: `include HttpDecoy::RSpec` above only ran
|
|
182
|
+
# HttpDecoy::RSpec.included against *this anonymous module*, not against
|
|
183
|
+
# `base` — Ruby's included hook does not cascade through nested includes.
|
|
184
|
+
# Without this line, `RSpec.configure { |c| c.include Foo.rspec_helpers }`
|
|
185
|
+
# used on its own raises NoMethodError on `_http_decoy_register`.
|
|
197
186
|
define_singleton_method(:included) do |base|
|
|
198
187
|
super(base)
|
|
199
|
-
base.
|
|
188
|
+
base.extend(HttpDecoy::RSpec::ClassMethods)
|
|
189
|
+
base._http_decoy_register(definition.name, definition.route_map)
|
|
200
190
|
end
|
|
201
191
|
|
|
202
|
-
define_method(:
|
|
192
|
+
define_method(:_http_decoy_definition) { definition }
|
|
203
193
|
end
|
|
204
194
|
end
|
|
205
195
|
end
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "webrick"
|
|
4
4
|
require "rack"
|
|
5
|
+
require "socket"
|
|
5
6
|
require "stringio"
|
|
6
7
|
require "json"
|
|
7
8
|
require_relative "request_log"
|
|
8
9
|
require_relative "handler_context"
|
|
9
10
|
|
|
10
|
-
module
|
|
11
|
+
module HttpDecoy
|
|
11
12
|
# A real WEBrick HTTP server that runs in a background thread.
|
|
12
13
|
#
|
|
13
14
|
# Uses WEBrick directly (no Rack::Handler) so it works with both
|
|
@@ -44,6 +45,13 @@ module HttpFake
|
|
|
44
45
|
res.status = status.to_i
|
|
45
46
|
headers.each { |k, v| res[k] = v }
|
|
46
47
|
res.body = Array(body).join
|
|
48
|
+
rescue Timeout::Error, Errno::ECONNRESET, Errno::ECONNREFUSED
|
|
49
|
+
# raise_error(:timeout/:reset/:refused): over a real socket there's no
|
|
50
|
+
# response that means "the connection died" — the only faithful
|
|
51
|
+
# simulation is to actually kill the connection. (Over WebMock's
|
|
52
|
+
# to_rack interception this same exception instead propagates directly
|
|
53
|
+
# to the caller, which is the more common path and needs no help here.)
|
|
54
|
+
drop_connection(req)
|
|
47
55
|
rescue StandardError => e
|
|
48
56
|
res.status = 500
|
|
49
57
|
res["Content-Type"] = "application/json"
|
|
@@ -56,7 +64,7 @@ module HttpFake
|
|
|
56
64
|
# Poll until WEBrick enters its accept loop.
|
|
57
65
|
deadline = Time.now + 5
|
|
58
66
|
sleep(0.005) until @webrick.status == :Running || Time.now > deadline
|
|
59
|
-
raise "
|
|
67
|
+
raise "http_decoy: server failed to start within 5 seconds" unless @webrick.status == :Running
|
|
60
68
|
|
|
61
69
|
self
|
|
62
70
|
end
|
|
@@ -162,5 +170,31 @@ module HttpFake
|
|
|
162
170
|
def json_response(status, payload)
|
|
163
171
|
[status.to_i, { "Content-Type" => "application/json" }, [JSON.generate(payload)]]
|
|
164
172
|
end
|
|
173
|
+
|
|
174
|
+
# Forcibly terminate the underlying TCP connection instead of sending a
|
|
175
|
+
# response. WEBrick::HTTPRequest stores the raw socket in @socket once
|
|
176
|
+
# #parse has run (it's not exposed through the public API) — reaching in
|
|
177
|
+
# is the only way to make a "connection reset" simulation actually reset
|
|
178
|
+
# the connection rather than returning a normal 500 response.
|
|
179
|
+
#
|
|
180
|
+
# SO_LINGER 0 makes the kernel send RST on close instead of a graceful
|
|
181
|
+
# FIN, so the client observes a real ECONNRESET (or equivalent) rather
|
|
182
|
+
# than a clean EOF. Any failure here (unsupported socket, already closed)
|
|
183
|
+
# is swallowed — the surrounding WEBrick request loop still tries to
|
|
184
|
+
# write a response to the now-closed socket and fails safely on its own.
|
|
185
|
+
def drop_connection(req)
|
|
186
|
+
sock = req.instance_variable_get(:@socket)
|
|
187
|
+
return unless sock
|
|
188
|
+
|
|
189
|
+
begin
|
|
190
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [1, 0].pack("ii"))
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
sock.close
|
|
196
|
+
rescue StandardError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
165
199
|
end
|
|
166
200
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module HttpDecoy
|
|
4
4
|
# Manages the WebMock stub that routes a declared base_url to the server's Rack app.
|
|
5
5
|
#
|
|
6
|
-
# Auto-detect: if WebMock is loaded and
|
|
6
|
+
# Auto-detect: if WebMock is loaded and HttpDecoy.configuration.auto_intercept is true,
|
|
7
7
|
# requests to the declared base_url are intercepted transparently.
|
|
8
8
|
#
|
|
9
|
-
# Teardown removes only the stub
|
|
9
|
+
# Teardown removes only the stub http_decoy created — never calls WebMock.reset!.
|
|
10
10
|
# If WebMock/RSpec has already cleared the registry (its own after(:each) hook),
|
|
11
11
|
# we rescue silently rather than crashing.
|
|
12
12
|
module WebMockIntegration
|
|
@@ -18,7 +18,7 @@ module HttpFake
|
|
|
18
18
|
# Install an interception stub for the given server.
|
|
19
19
|
# Returns the stub object so it can be removed precisely during teardown.
|
|
20
20
|
def setup(server)
|
|
21
|
-
return nil unless available? &&
|
|
21
|
+
return nil unless available? && HttpDecoy.configuration.auto_intercept
|
|
22
22
|
return nil unless server.route_map.declared_base_url
|
|
23
23
|
|
|
24
24
|
# Match the full base URL (scheme + host) so the regex anchors correctly.
|
data/lib/http_decoy.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "http_decoy/version"
|
|
4
|
+
require_relative "http_decoy/configuration"
|
|
5
|
+
require_relative "http_decoy/route"
|
|
6
|
+
require_relative "http_decoy/route_map"
|
|
7
|
+
require_relative "http_decoy/router"
|
|
8
|
+
require_relative "http_decoy/request_log"
|
|
9
|
+
require_relative "http_decoy/handler_context"
|
|
10
|
+
require_relative "http_decoy/server"
|
|
11
|
+
require_relative "http_decoy/webmock_integration"
|
|
12
|
+
require_relative "http_decoy/definition"
|
|
13
|
+
|
|
14
|
+
module HttpDecoy
|
|
15
|
+
class << self
|
|
16
|
+
# Global configuration.
|
|
17
|
+
#
|
|
18
|
+
# HttpDecoy.configure do |c|
|
|
19
|
+
# c.auto_intercept = false # opt out of WebMock auto-interception
|
|
20
|
+
# end
|
|
21
|
+
def configure
|
|
22
|
+
yield configuration
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Define a named fake service, reusable across RSpec and Minitest.
|
|
30
|
+
#
|
|
31
|
+
# FakeStripe = HttpDecoy.define(:stripe) do
|
|
32
|
+
# base_url "https://api.stripe.com"
|
|
33
|
+
#
|
|
34
|
+
# post "/v1/charges" do
|
|
35
|
+
# requires_body :amount, :currency, :payment_method
|
|
36
|
+
# respond 200, json: { id: -> { "ch_#{SecureRandom.hex(8)}" } }
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# RSpec.configure { |c| c.include FakeStripe.rspec_helpers }
|
|
41
|
+
# # or, in a Minitest::Test subclass:
|
|
42
|
+
# include FakeStripe.minitest_helpers
|
|
43
|
+
#
|
|
44
|
+
# #rspec_helpers requires "http_decoy/rspec" to be loaded; #minitest_helpers
|
|
45
|
+
# requires "http_decoy/minitest" — this method itself pulls in neither, so
|
|
46
|
+
# a Minitest-only project never loads RSpec (and vice versa).
|
|
47
|
+
def define(name = :default, &)
|
|
48
|
+
route_map = RouteMap.new
|
|
49
|
+
route_map.instance_eval(&)
|
|
50
|
+
Definition.new(name, route_map)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: http_decoy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jibran Usman
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -38,9 +38,9 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '1.8'
|
|
41
|
-
description:
|
|
42
|
-
Define routes, validate request contracts, return dynamic fixtures, and tear
|
|
43
|
-
automatically. No VCR cassettes. No scattered WebMock stubs.
|
|
41
|
+
description: http_decoy spins up a real Rack server inside your tests with a clean
|
|
42
|
+
DSL. Define routes, validate request contracts, return dynamic fixtures, and tear
|
|
43
|
+
down automatically. No VCR cassettes. No scattered WebMock stubs.
|
|
44
44
|
email:
|
|
45
45
|
- jibran.usman@hotmail.com
|
|
46
46
|
executables: []
|
|
@@ -53,25 +53,28 @@ files:
|
|
|
53
53
|
- LICENSE
|
|
54
54
|
- README.md
|
|
55
55
|
- Rakefile
|
|
56
|
-
- lib/
|
|
57
|
-
- lib/
|
|
58
|
-
- lib/
|
|
59
|
-
- lib/
|
|
60
|
-
- lib/
|
|
61
|
-
- lib/
|
|
62
|
-
- lib/
|
|
63
|
-
- lib/
|
|
64
|
-
- lib/
|
|
65
|
-
- lib/
|
|
66
|
-
- lib/
|
|
67
|
-
-
|
|
68
|
-
|
|
56
|
+
- lib/http_decoy.rb
|
|
57
|
+
- lib/http_decoy/body_matcher.rb
|
|
58
|
+
- lib/http_decoy/configuration.rb
|
|
59
|
+
- lib/http_decoy/definition.rb
|
|
60
|
+
- lib/http_decoy/handler_context.rb
|
|
61
|
+
- lib/http_decoy/minitest.rb
|
|
62
|
+
- lib/http_decoy/request_log.rb
|
|
63
|
+
- lib/http_decoy/route.rb
|
|
64
|
+
- lib/http_decoy/route_map.rb
|
|
65
|
+
- lib/http_decoy/router.rb
|
|
66
|
+
- lib/http_decoy/rspec.rb
|
|
67
|
+
- lib/http_decoy/server.rb
|
|
68
|
+
- lib/http_decoy/version.rb
|
|
69
|
+
- lib/http_decoy/webmock_integration.rb
|
|
70
|
+
- sig/http_decoy.rbs
|
|
71
|
+
homepage: https://github.com/jibranusman95/http_decoy
|
|
69
72
|
licenses:
|
|
70
73
|
- MIT
|
|
71
74
|
metadata:
|
|
72
|
-
homepage_uri: https://github.com/jibranusman95/
|
|
73
|
-
source_code_uri: https://github.com/jibranusman95/
|
|
74
|
-
changelog_uri: https://github.com/jibranusman95/
|
|
75
|
+
homepage_uri: https://github.com/jibranusman95/http_decoy
|
|
76
|
+
source_code_uri: https://github.com/jibranusman95/http_decoy
|
|
77
|
+
changelog_uri: https://github.com/jibranusman95/http_decoy/blob/main/CHANGELOG.md
|
|
75
78
|
rubygems_mfa_required: 'true'
|
|
76
79
|
post_install_message:
|
|
77
80
|
rdoc_options: []
|
|
@@ -91,6 +94,6 @@ requirements: []
|
|
|
91
94
|
rubygems_version: 3.5.22
|
|
92
95
|
signing_key:
|
|
93
96
|
specification_version: 4
|
|
94
|
-
summary: Declarative fake HTTP servers for RSpec. Real server. Real requests.
|
|
95
|
-
cassettes.
|
|
97
|
+
summary: Declarative fake HTTP servers for RSpec/Minitest. Real server. Real requests.
|
|
98
|
+
Zero cassettes.
|
|
96
99
|
test_files: []
|
data/lib/httpfake/version.rb
DELETED
data/lib/httpfake.rb
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "httpfake/version"
|
|
4
|
-
require_relative "httpfake/configuration"
|
|
5
|
-
require_relative "httpfake/route"
|
|
6
|
-
require_relative "httpfake/route_map"
|
|
7
|
-
require_relative "httpfake/router"
|
|
8
|
-
require_relative "httpfake/request_log"
|
|
9
|
-
require_relative "httpfake/handler_context"
|
|
10
|
-
require_relative "httpfake/server"
|
|
11
|
-
require_relative "httpfake/webmock_integration"
|
|
12
|
-
|
|
13
|
-
module HttpFake
|
|
14
|
-
class << self
|
|
15
|
-
# Global configuration.
|
|
16
|
-
#
|
|
17
|
-
# HttpFake.configure do |c|
|
|
18
|
-
# c.auto_intercept = false # opt out of WebMock auto-interception
|
|
19
|
-
# end
|
|
20
|
-
def configure
|
|
21
|
-
yield configuration
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def configuration
|
|
25
|
-
@configuration ||= Configuration.new
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Define a named fake service.
|
|
29
|
-
#
|
|
30
|
-
# FakeStripe = HttpFake.define(:stripe) do
|
|
31
|
-
# base_url "https://api.stripe.com"
|
|
32
|
-
#
|
|
33
|
-
# post "/v1/charges" do
|
|
34
|
-
# requires_body :amount, :currency, :payment_method
|
|
35
|
-
# respond 200, json: { id: -> { "ch_#{SecureRandom.hex(8)}" } }
|
|
36
|
-
# end
|
|
37
|
-
# end
|
|
38
|
-
#
|
|
39
|
-
# RSpec.configure { |c| c.include FakeStripe.rspec_helpers }
|
|
40
|
-
#
|
|
41
|
-
def define(name = :default, &)
|
|
42
|
-
require_relative "httpfake/rspec"
|
|
43
|
-
route_map = RouteMap.new
|
|
44
|
-
route_map.instance_eval(&)
|
|
45
|
-
Definition.new(name, route_map)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
File without changes
|