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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e30669122e0653b4327cc91460096cfc2a560004188b0bf8e678fb475a2ba8f
4
- data.tar.gz: 3258d93287f014e00dff0795c0a217203c24ed3d877e2d3fb0cfebd75ff574bb
3
+ metadata.gz: 6c07411035e25bff04ba5ca53164ae73c6848e6431823f2e47332548c8214a7d
4
+ data.tar.gz: f1a9a1c4ee898db259bd4cafae877647d7a91f8066cee5e89378acdcb8e9866d
5
5
  SHA512:
6
- metadata.gz: 0a63f8490e679a0e8909fdaadd8db7f94af4751c076d5119bf5e8246587551c28f3b5bb16778b3a7ded2cae1d2b65a1ec9da04d881d8395042c55616d391570d
7
- data.tar.gz: 2930b0ac10c72d3f2a5c65cfb7de35ee6d6bd1c01c261a0a6478c2bda7c700f409c7f937abfbf8c30d544be77c2703598dbbd967bd65e2d0a02fcea3ed4b01cc
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
- - "httpfake.gemspec"
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 HttpFake but the gem/dir is httpfake (no underscore).
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/httpfake/rspec.rb"
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
- - `HttpFake.define` — declare a named fake service with a DSL block
15
- - `HttpFake::RouteMap` — route definition DSL (`get`, `post`, `put`, `patch`, `delete`)
16
- - `HttpFake::Router` — path matching with `:param` template segments
17
- - `HttpFake::Server` — real WEBrick HTTP server on OS-assigned port (port 0); starts in a background thread, stops cleanly
18
- - `HttpFake::RequestLog` — thread-safe store of every received request; powers assertion helpers
19
- - `HttpFake::HandlerContext` — `instance_eval` DSL surface inside handler blocks:
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
- - `HttpFake.define(:name) { ... }.rspec_helpers` — suite-wide helper module pattern
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 httpfake's stub
32
- - `HttpFake.configure { |c| c.auto_intercept = false }` — opt out of automatic WebMock interception
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/jibranusman/httpfake/compare/v0.1.0...HEAD
39
- [0.1.0]: https://github.com/jibranusman/httpfake/releases/tag/v0.1.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 httpfake
1
+ # Contributing to http_decoy
2
2
 
3
- Thanks for taking the time. Bug reports, documentation improvements, and feature proposals are all welcome.
3
+ First: thank you. It genuinely means a lot.
4
4
 
5
- ## Setup
5
+ ## Quick start
6
6
 
7
7
  ```bash
8
- git clone https://github.com/jibranusman/httpfake
9
- cd httpfake
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
- ## Running the tests
16
+ ## How to contribute
14
17
 
15
- ```bash
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
- - Bug fixes with a reproducing spec
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
- ## Good first issues
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
- Check the [`good first issue`](https://github.com/jibranusman/httpfake/issues?q=label%3A%22good+first+issue%22) label for beginner-friendly tasks.
24
+ **Docs or README improvements?** Also very welcome just send the PR directly.
49
25
 
50
- ## Code style
26
+ ## Before you submit
51
27
 
52
- - `frozen_string_literal: true` on every file
53
- - Follow the existing RuboCop config (`.rubocop.yml`)
54
- - Keep handler blocks and DSL methods small and focused
55
- - No clever metaprogramming without a comment explaining why
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
- ## Reporting bugs
33
+ ## What happens next
58
34
 
59
- Open a GitHub issue with:
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
- ## Security issues
37
+ ## License
66
38
 
67
- Do not open a public issue for security vulnerabilities. Email the maintainer directly (address in the gemspec).
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
- # httpfake
1
+ # http_decoy
2
2
 
3
3
  **A real fake HTTP server. For real tests.**
4
4
 
5
- [![CI](https://github.com/jibranusman/httpfake/actions/workflows/ci.yml/badge.svg)](https://github.com/jibranusman/httpfake/actions)
6
- [![Gem Version](https://badge.fury.io/rb/httpfake.svg)](https://badge.fury.io/rb/httpfake)
7
- [![Downloads](https://img.shields.io/gem/dt/httpfake)](https://rubygems.org/gems/httpfake)
5
+ [![CI](https://github.com/jibranusman95/http_decoy/actions/workflows/ci.yml/badge.svg)](https://github.com/jibranusman95/http_decoy/actions)
6
+ [![Gem Version](https://badge.fury.io/rb/http_decoy.svg)](https://badge.fury.io/rb/http_decoy)
7
+ [![Downloads](https://img.shields.io/gem/dt/http_decoy)](https://rubygems.org/gems/http_decoy)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
- **httpfake 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.
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 — httpfake (what tests should look like)
59
+ ### Test 3 — http_decoy (what tests should look like)
60
60
 
61
61
  ```ruby
62
- FakeStripe = HttpFake.define(:stripe) do
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 "httpfake"
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 = HttpFake.define(:stripe) do
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 "httpfake"
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 — httpfake raises immediately with a descriptive error
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(HttpFake::HandlerContext::ContractError, /payment_method is required/)
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 = HttpFake.define(:my_service) do
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, httpfake raises `HttpFake::HandlerContext::ContractError` with a message naming the exact field and rule. Your test fails at the right place, with the right message.
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 HttpFake::RSpec
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
- HttpFake.configure do |config|
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 | **httpfake** |
414
+ | | WebMock | VCR | **http_decoy** |
357
415
  |---|---|---|---|
358
- | Real server | No | No | **Yes** |
359
- | Request contract validation | No | No | **Yes** |
360
- | Dynamic responses | No | No | **Yes** |
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 | No | No | **Yes** |
424
+ | Catches API drift | possible | No | **Yes** |
367
425
 
368
- httpfake uses WebMock internally to intercept requests — complementary, not a replacement.
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 = HttpFake.define(:stripe) do
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 = HttpFake.define(:sendgrid) do
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 = HttpFake.define(:inventory) do
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/jibranusman/httpfake
462
- cd httpfake
532
+ git clone https://github.com/jibranusman95/http_decoy
533
+ cd http_decoy
463
534
  bundle install
464
- bundle exec rspec # run all tests
465
- bundle exec rubocop # lint
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. Good first issues are labeled [`good first issue`](https://github.com/jibranusman/httpfake/issues?q=label%3A%22good+first+issue%22).
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
- *httpfake — stop testing your assumptions, start testing your contracts.*
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
- task default: :spec
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HttpFake
3
+ module HttpDecoy
4
4
  class Configuration
5
5
  attr_accessor :auto_intercept
6
6
 
@@ -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 HttpFake
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
- def respond(status, json: nil, text: nil, headers: {})
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, "httpfake simulated timeout"
85
- when :reset then raise Errno::ECONNRESET, "httpfake simulated connection reset"
86
- when :refused then raise Errno::ECONNREFUSED, "httpfake simulated connection refused"
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HttpFake
3
+ module HttpDecoy
4
4
  # Thread-safe store of every request received by the fake server.
5
5
  # Cleared between examples via Server#stop → new Server per example.
6
6
  class RequestLog
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HttpFake
3
+ module HttpDecoy
4
4
  # Represents a single declared route: method + path pattern + optional scenario.
5
5
  class Route
6
6
  attr_reader :method, :pattern, :scenario, :handler_block
@@ -3,7 +3,7 @@
3
3
  require_relative "route"
4
4
  require_relative "router"
5
5
 
6
- module HttpFake
6
+ module HttpDecoy
7
7
  # DSL target for defining a set of routes.
8
8
  # Instantiated once at class-load time; immutable after definition.
9
9
  class RouteMap
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HttpFake
3
+ module HttpDecoy
4
4
  # Matches an incoming (method, path) pair against a list of Route objects.
5
5
  # Supports :param segments in patterns, e.g. "/v1/charges/:id".
6
6
  class Router
@@ -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 HttpFake
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 HttpFake::RSpec
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 = HttpFake.define(:stripe) do
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
- _httpfake_register(name, route_map)
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 _httpfake_register(name, route_map)
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
- @_httpfake_servers ||= {}
69
- @_httpfake_webmock_stubs ||= {}
70
- @_httpfake_servers[name] = server
71
- @_httpfake_webmock_stubs[name] = stub
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 = @_httpfake_servers&.[](name)
76
- stub = @_httpfake_webmock_stubs&.[](name)
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
- @_httpfake_servers[name]
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 = @_httpfake_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 = @_httpfake_servers[name]
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| body_matches?(e.body, @body_matcher) }
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 HttpFake.define
162
+ # Definition — returned by HttpDecoy.define
173
163
  # ---------------------------------------------------------------------------
174
164
 
175
- # Wraps a named RouteMap and generates an anonymous RSpec helper module.
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 HttpFake::RSpec
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._httpfake_register(definition.name, definition.route_map)
188
+ base.extend(HttpDecoy::RSpec::ClassMethods)
189
+ base._http_decoy_register(definition.name, definition.route_map)
200
190
  end
201
191
 
202
- define_method(:_httpfake_definition) { definition }
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 HttpFake
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 "httpfake: server failed to start within 5 seconds" unless @webrick.status == :Running
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpDecoy
4
+ VERSION = "0.2.0"
5
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module HttpFake
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 HttpFake.configuration.auto_intercept is true,
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 httpfake created — never calls WebMock.reset!.
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? && HttpFake.configuration.auto_intercept
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.1.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-06-02 00:00:00.000000000 Z
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: httpfake spins up a real Rack server inside your tests with a clean DSL.
42
- Define routes, validate request contracts, return dynamic fixtures, and tear down
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/httpfake.rb
57
- - lib/httpfake/configuration.rb
58
- - lib/httpfake/handler_context.rb
59
- - lib/httpfake/request_log.rb
60
- - lib/httpfake/route.rb
61
- - lib/httpfake/route_map.rb
62
- - lib/httpfake/router.rb
63
- - lib/httpfake/rspec.rb
64
- - lib/httpfake/server.rb
65
- - lib/httpfake/version.rb
66
- - lib/httpfake/webmock_integration.rb
67
- - sig/httpfake.rbs
68
- homepage: https://github.com/jibranusman95/httpfake
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/httpfake
73
- source_code_uri: https://github.com/jibranusman95/httpfake
74
- changelog_uri: https://github.com/jibranusman95/httpfake/blob/main/CHANGELOG.md
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. Zero
95
- cassettes.
97
+ summary: Declarative fake HTTP servers for RSpec/Minitest. Real server. Real requests.
98
+ Zero cassettes.
96
99
  test_files: []
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module HttpFake
4
- VERSION = "0.1.0"
5
- end
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