http_decoy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,478 @@
1
+ # httpfake
2
+
3
+ **A real fake HTTP server. For real tests.**
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)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
+
10
+ ---
11
+
12
+ Your WebMock stubs are lying to you.
13
+
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
+
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.
17
+
18
+ No cassettes. No scattered stubs. No surprises on deploy day.
19
+
20
+ ---
21
+
22
+ ## The problem, illustrated
23
+
24
+ Three tests. Same feature. Different levels of lying.
25
+
26
+ ### Test 1 — WebMock (stub at the adapter layer)
27
+
28
+ ```ruby
29
+ stub_request(:post, "https://api.stripe.com/v1/charges")
30
+ .with(body: { amount: "2000", currency: "usd" })
31
+ .to_return(status: 200, body: '{"id":"ch_123","status":"succeeded"}')
32
+ ```
33
+
34
+ This test passes even if:
35
+ - Your code sends `paymet_method` instead of `payment_method` (typo, ships to prod)
36
+ - Stripe adds a required field next week (stub keeps returning 200, forever)
37
+ - The response shape changes (your parser breaks in prod, not in tests)
38
+ - Your code sends `"2000"` as a string but Stripe requires an integer
39
+
40
+ The stub doesn't know anything about Stripe. It just pattern-matches and returns JSON.
41
+
42
+ ### Test 2 — VCR (record once, replay forever)
43
+
44
+ ```ruby
45
+ it "charges the customer", vcr: { cassette_name: "stripe/charge" } do
46
+ result = StripeService.charge(amount: 2000)
47
+ expect(result.status).to eq "succeeded"
48
+ end
49
+ ```
50
+
51
+ This test passes even if:
52
+ - The cassette was recorded in 2022 and `payment_method` became required in 2023
53
+ - The cassette contains your actual Stripe test key (committed to git, forever)
54
+ - You need to test what happens when a card is declined (good luck editing cassette YAML)
55
+ - CI has no network access for the initial recording run
56
+
57
+ You end up with 50 YAML files nobody touches, all slowly diverging from reality.
58
+
59
+ ### Test 3 — httpfake (what tests should look like)
60
+
61
+ ```ruby
62
+ FakeStripe = HttpFake.define(:stripe) do
63
+ base_url "https://api.stripe.com"
64
+
65
+ post "/v1/charges" do
66
+ requires_body :amount, :currency, :payment_method
67
+ validates :amount, type: Integer, min: 50
68
+ validates :currency, inclusion: %w[usd gbp eur]
69
+
70
+ respond 200, json: {
71
+ id: -> { "ch_#{SecureRandom.hex(8)}" },
72
+ status: "succeeded",
73
+ amount: -> { body[:amount] },
74
+ currency: -> { body[:currency] }
75
+ }
76
+ end
77
+
78
+ post "/v1/charges", scenario: :card_declined do
79
+ respond 402, json: { error: { code: "card_declined" } }
80
+ end
81
+ end
82
+ ```
83
+
84
+ Now your tests:
85
+ - **Fail immediately** if your code sends a missing or invalid field
86
+ - **Reflect real request data** back in responses — no frozen stubs
87
+ - **Test failure paths** with one line: `with_scenario(:card_declined) { ... }`
88
+ - **Work offline**, in CI, on a plane, in an airgapped environment
89
+ - **Live in one place** — define once, use across every test in the suite
90
+
91
+ ---
92
+
93
+ ## Install
94
+
95
+ ```ruby
96
+ # Gemfile
97
+ group :test do
98
+ gem "httpfake"
99
+ end
100
+ ```
101
+
102
+ ```bash
103
+ bundle install
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Quickstart (5 minutes)
109
+
110
+ ### 1. Define your fake service
111
+
112
+ ```ruby
113
+ # spec/support/fakes/fake_stripe.rb
114
+ FakeStripe = HttpFake.define(:stripe) do
115
+ base_url "https://api.stripe.com"
116
+
117
+ post "/v1/charges" do
118
+ requires_body :amount, :currency, :payment_method
119
+ validates :amount, type: Integer, min: 50
120
+
121
+ respond 200, json: {
122
+ id: -> { "ch_#{SecureRandom.hex(8)}" },
123
+ status: "succeeded",
124
+ amount: -> { body[:amount] }
125
+ }
126
+ end
127
+
128
+ get "/v1/charges/:id" do
129
+ respond 200, json: {
130
+ id: -> { path_params[:id] },
131
+ status: "succeeded"
132
+ }
133
+ end
134
+
135
+ post "/v1/charges", scenario: :card_declined do
136
+ respond 402, json: {
137
+ error: { code: "card_declined", message: "Your card was declined." }
138
+ }
139
+ end
140
+
141
+ post "/v1/charges", scenario: :network_error do
142
+ raise_error :timeout
143
+ end
144
+ end
145
+ ```
146
+
147
+ ### 2. Load it in spec_helper
148
+
149
+ ```ruby
150
+ # spec/spec_helper.rb
151
+ require "httpfake"
152
+ require "support/fakes/fake_stripe"
153
+
154
+ RSpec.configure do |config|
155
+ config.include FakeStripe.rspec_helpers
156
+ end
157
+ ```
158
+
159
+ ### 3. Write tests
160
+
161
+ ```ruby
162
+ RSpec.describe StripeService do
163
+ describe "#charge" do
164
+ it "creates a charge and returns the id" do
165
+ result = StripeService.charge(amount: 2000, currency: "usd", payment_method: "pm_card_visa")
166
+ expect(result.id).to match(/\Ach_/)
167
+ expect(result.amount).to eq 2000
168
+ end
169
+
170
+ it "raises PaymentError on card decline" do
171
+ with_scenario(:card_declined) do
172
+ expect { StripeService.charge(amount: 2000, currency: "usd", payment_method: "pm_card_visa") }
173
+ .to raise_error(StripeService::PaymentError, /declined/)
174
+ end
175
+ end
176
+
177
+ it "raises NetworkError on timeout" do
178
+ with_scenario(:network_error) do
179
+ expect { StripeService.charge(amount: 2000, currency: "usd", payment_method: "pm_card_visa") }
180
+ .to raise_error(StripeService::NetworkError)
181
+ end
182
+ end
183
+
184
+ it "catches bad requests before they reach prod" do
185
+ # Missing payment_method — httpfake raises immediately with a descriptive error
186
+ expect { StripeService.charge(amount: 2000, currency: "usd") }
187
+ .to raise_error(HttpFake::HandlerContext::ContractError, /payment_method is required/)
188
+ end
189
+ end
190
+ end
191
+ ```
192
+
193
+ No setup per test. No per-test `stub_request`. No cassette files.
194
+
195
+ ---
196
+
197
+ ## DSL Reference
198
+
199
+ ### Defining a server
200
+
201
+ ```ruby
202
+ MyFakeService = HttpFake.define(:my_service) do
203
+ base_url "https://api.example.com" # intercepted via WebMock automatically
204
+ # ...routes
205
+ end
206
+ ```
207
+
208
+ ### Routes
209
+
210
+ ```ruby
211
+ get "/path"
212
+ post "/path"
213
+ put "/path"
214
+ patch "/path"
215
+ delete "/path"
216
+ ```
217
+
218
+ Path parameters:
219
+
220
+ ```ruby
221
+ get "/users/:id/posts/:post_id" do
222
+ respond 200, json: { user_id: path_params[:id], post_id: path_params[:post_id] }
223
+ end
224
+ ```
225
+
226
+ Query parameters:
227
+
228
+ ```ruby
229
+ get "/search" do
230
+ respond 200, json: { results: [], query: query_params[:q] }
231
+ end
232
+ ```
233
+
234
+ ### Request contract validation
235
+
236
+ ```ruby
237
+ post "/orders" do
238
+ requires_body :item_id, :quantity # presence check
239
+ validates :quantity, type: Integer, min: 1 # type + range
240
+ validates :status, inclusion: %w[pending paid] # enum
241
+
242
+ respond 201, json: { order_id: -> { SecureRandom.uuid } }
243
+ end
244
+ ```
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.
247
+
248
+ ### Dynamic responses
249
+
250
+ Use lambdas anywhere in the response body — evaluated at request time with access to the full request:
251
+
252
+ ```ruby
253
+ post "/echo" do
254
+ respond 200, json: {
255
+ received_at: -> { Time.now.iso8601 },
256
+ you_sent: -> { body },
257
+ your_ip: -> { request.ip }
258
+ }
259
+ end
260
+ ```
261
+
262
+ ### Scenarios (failure simulation)
263
+
264
+ ```ruby
265
+ # Definition
266
+ post "/payments", scenario: :rate_limited do
267
+ respond 429, json: { error: "Too many requests" }, headers: { "Retry-After" => "30" }
268
+ end
269
+
270
+ post "/payments", scenario: :timeout do
271
+ raise_error :timeout
272
+ end
273
+
274
+ # Usage in tests
275
+ with_scenario(:rate_limited) do
276
+ expect { PaymentService.pay(100) }.to raise_error(PaymentService::RateLimitError)
277
+ end
278
+ ```
279
+
280
+ Available transport errors: `:timeout`, `:reset`, `:refused`.
281
+
282
+ ### Stateful sequences
283
+
284
+ ```ruby
285
+ get "/account/balance" do
286
+ respond_sequence(
287
+ [200, { json: { balance: 1000, status: "active" } }],
288
+ [200, { json: { balance: 0, status: "active" } }],
289
+ [403, { json: { error: "Account suspended" } }]
290
+ )
291
+ end
292
+ ```
293
+
294
+ First call → 1000. Second call → 0. Third call → 403. Wraps automatically.
295
+
296
+ ### Request assertions
297
+
298
+ ```ruby
299
+ it "sends the right payload" do
300
+ StripeService.charge(amount: 500, currency: "usd", payment_method: "pm_123")
301
+
302
+ expect(fake_server(:stripe)).to have_received_request(:post, "/v1/charges")
303
+ .once
304
+ .with(body: { amount: 500, currency: "usd" })
305
+ end
306
+ ```
307
+
308
+ Chains: `.once`, `.twice`, `.times(n)`, `.with(body: ...)`.
309
+
310
+ ---
311
+
312
+ ## RSpec integration
313
+
314
+ Suite-wide (recommended):
315
+
316
+ ```ruby
317
+ RSpec.configure do |config|
318
+ config.include FakeStripe.rspec_helpers
319
+ config.include FakeSendGrid.rspec_helpers
320
+ end
321
+ ```
322
+
323
+ Inline per describe block:
324
+
325
+ ```ruby
326
+ RSpec.describe "degraded upstream" do
327
+ include HttpFake::RSpec
328
+
329
+ fake_server(:api) do
330
+ get "/status" do
331
+ respond 503, json: { status: "degraded" }
332
+ end
333
+ end
334
+
335
+ it "handles it gracefully" do
336
+ expect(MyApp.health_check).to eq :degraded
337
+ end
338
+ end
339
+ ```
340
+
341
+ ---
342
+
343
+ ## Configuration
344
+
345
+ ```ruby
346
+ # Opt out of WebMock auto-interception (e.g. if you manage stubs manually)
347
+ HttpFake.configure do |config|
348
+ config.auto_intercept = false
349
+ end
350
+ ```
351
+
352
+ ---
353
+
354
+ ## Why not WebMock / VCR? (honest comparison)
355
+
356
+ | | WebMock | VCR | **httpfake** |
357
+ |---|---|---|---|
358
+ | Real server | No | No | **Yes** |
359
+ | Request contract validation | No | No | **Yes** |
360
+ | Dynamic responses | No | No | **Yes** |
361
+ | Failure scenario testing | Verbose | Very hard | **One line** |
362
+ | Works offline | Yes | First run: No | **Yes** |
363
+ | Secrets in version control | No | **Risk** | No |
364
+ | Cassettes to maintain | No | **Yes** | No |
365
+ | Define once, use everywhere | Requires setup | Yes | **Yes** |
366
+ | Catches API drift | No | No | **Yes** |
367
+
368
+ httpfake uses WebMock internally to intercept requests — complementary, not a replacement.
369
+
370
+ ---
371
+
372
+ ## Real-world examples
373
+
374
+ <details>
375
+ <summary>Stripe (payments)</summary>
376
+
377
+ ```ruby
378
+ FakeStripe = HttpFake.define(:stripe) do
379
+ base_url "https://api.stripe.com"
380
+
381
+ post "/v1/payment_intents" do
382
+ requires_body :amount, :currency, :payment_method
383
+ validates :amount, type: Integer, min: 50
384
+
385
+ respond 200, json: {
386
+ id: -> { "pi_#{SecureRandom.hex(12)}" },
387
+ status: "succeeded",
388
+ amount: -> { body[:amount] },
389
+ currency: -> { body[:currency] },
390
+ payment_method: -> { body[:payment_method] }
391
+ }
392
+ end
393
+
394
+ post "/v1/payment_intents", scenario: :insufficient_funds do
395
+ respond 402, json: {
396
+ error: { code: "insufficient_funds", decline_code: "insufficient_funds" }
397
+ }
398
+ end
399
+ end
400
+ ```
401
+ </details>
402
+
403
+ <details>
404
+ <summary>SendGrid (email)</summary>
405
+
406
+ ```ruby
407
+ FakeSendGrid = HttpFake.define(:sendgrid) do
408
+ base_url "https://api.sendgrid.com"
409
+
410
+ post "/v3/mail/send" do
411
+ requires_body :to, :from, :subject, :content
412
+ respond 202, text: ""
413
+ end
414
+
415
+ post "/v3/mail/send", scenario: :invalid_email do
416
+ respond 400, json: { errors: [{ message: "Invalid email address" }] }
417
+ end
418
+ end
419
+ ```
420
+ </details>
421
+
422
+ <details>
423
+ <summary>Internal microservice</summary>
424
+
425
+ ```ruby
426
+ FakeInventory = HttpFake.define(:inventory) do
427
+ base_url "https://inventory.internal"
428
+
429
+ get "/products/:sku/stock" do
430
+ respond 200, json: {
431
+ sku: -> { path_params[:sku] },
432
+ stock: -> { rand(0..100) },
433
+ unit: "each"
434
+ }
435
+ end
436
+
437
+ get "/products/:sku/stock", scenario: :out_of_stock do
438
+ respond 200, json: { sku: -> { path_params[:sku] }, stock: 0 }
439
+ end
440
+
441
+ get "/products/:sku/stock", scenario: :service_down do
442
+ respond 503, json: { error: "Inventory service is down" }
443
+ end
444
+ end
445
+ ```
446
+ </details>
447
+
448
+ ---
449
+
450
+ ## Requirements
451
+
452
+ - Ruby 3.1+
453
+ - Runtime dependencies: `webrick`, `rack` (both lightweight)
454
+ - Optional: `webmock` for URL interception
455
+
456
+ ---
457
+
458
+ ## Contributing
459
+
460
+ ```bash
461
+ git clone https://github.com/jibranusman/httpfake
462
+ cd httpfake
463
+ bundle install
464
+ bundle exec rspec # run all tests
465
+ bundle exec rubocop # lint
466
+ ```
467
+
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).
469
+
470
+ ---
471
+
472
+ ## License
473
+
474
+ MIT. See [LICENSE](LICENSE).
475
+
476
+ ---
477
+
478
+ *httpfake — stop testing your assumptions, start testing your contracts.*
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpFake
4
+ class Configuration
5
+ attr_accessor :auto_intercept
6
+
7
+ def initialize
8
+ @auto_intercept = true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module HttpFake
6
+ # The `self` inside every route handler block.
7
+ # Provides the full DSL surface: respond, requires_body, validates,
8
+ # body, path_params, query_params, respond_sequence, raise_error.
9
+ class HandlerContext
10
+ class ContractError < StandardError
11
+ end
12
+
13
+ attr_reader :path_params, :query_params, :request
14
+
15
+ # call_index: how many prior requests to this same (method, path) have been logged.
16
+ # Used by respond_sequence to pick the right entry without storing mutable state.
17
+ def initialize(rack_request, path_params, call_index: 0)
18
+ @request = rack_request
19
+ @path_params = path_params
20
+ @query_params = Rack::Utils.parse_nested_query(rack_request.query_string.to_s)
21
+ .transform_keys(&:to_sym)
22
+ @call_index = call_index
23
+ @_body = :unset
24
+ @_response = nil
25
+ end
26
+
27
+ # Lazily parsed request body — memoized.
28
+ def body
29
+ return @_body unless @_body == :unset
30
+
31
+ raw = @request.body&.read || ""
32
+ @request.body&.rewind
33
+ content_type = @request.content_type.to_s
34
+
35
+ @_body = if content_type.include?("application/json")
36
+ raw.empty? ? {} : JSON.parse(raw, symbolize_names: true)
37
+ elsif content_type.include?("application/x-www-form-urlencoded")
38
+ Rack::Utils.parse_nested_query(raw).transform_keys(&:to_sym)
39
+ else
40
+ raw
41
+ end
42
+ end
43
+
44
+ # Build and store the response tuple for this request.
45
+ def respond(status, json: nil, text: nil, headers: {})
46
+ body_str = json ? JSON.generate(resolve(json)) : text.to_s
47
+ content_type = json ? "application/json" : "text/plain"
48
+ @_response = [status.to_i, { "Content-Type" => content_type }.merge(headers), [body_str]]
49
+ end
50
+
51
+ # Stateful sequence: call_index picks which response to use.
52
+ # Each entry is [status, { json: ..., text: ..., headers: ... }].
53
+ # Wraps around if more calls are made than entries defined.
54
+ def respond_sequence(*responses)
55
+ entry = responses[@call_index % responses.length]
56
+ status = entry[0]
57
+ opts = entry[1] || {}
58
+ respond(status, **opts)
59
+ end
60
+
61
+ # Contract assertion: raises ContractError if any key is absent from body.
62
+ def requires_body(*keys)
63
+ keys.each do |key|
64
+ present = body.is_a?(Hash) && (body.key?(key) || body.key?(key.to_s))
65
+ raise ContractError, "#{key} is required in request body" unless present
66
+ end
67
+ end
68
+
69
+ # Type / range / enum validation on a body field.
70
+ def validates(key, type: nil, min: nil, max: nil, inclusion: nil)
71
+ value = body.is_a?(Hash) ? (body[key] || body[key.to_s]) : nil
72
+
73
+ raise ContractError, "#{key} must be a #{type}, got #{value.class}" if type && !value.is_a?(type)
74
+ raise ContractError, "#{key} must be >= #{min}, got #{value.inspect}" if min && value < min
75
+ raise ContractError, "#{key} must be <= #{max}, got #{value.inspect}" if max && value > max
76
+ return unless inclusion && !inclusion.include?(value)
77
+
78
+ raise ContractError, "#{key} must be one of #{inclusion.inspect}, got #{value.inspect}"
79
+ end
80
+
81
+ # Simulate transport-level failures.
82
+ def raise_error(type)
83
+ 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"
87
+ else raise type.is_a?(Class) ? type : RuntimeError, type.to_s
88
+ end
89
+ end
90
+
91
+ # Internal: the built response tuple, or nil if none was set.
92
+ def response
93
+ @_response
94
+ end
95
+
96
+ private
97
+
98
+ # Recursively resolve lambdas in response bodies so users can write:
99
+ # respond 200, json: { id: -> { SecureRandom.uuid }, amount: -> { body[:amount] } }
100
+ def resolve(obj)
101
+ case obj
102
+ when Hash then obj.transform_values { |v| resolve(v) }
103
+ when Array then obj.map { |v| resolve(v) }
104
+ when Proc then resolve(instance_exec(&obj))
105
+ else obj
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpFake
4
+ # Thread-safe store of every request received by the fake server.
5
+ # Cleared between examples via Server#stop → new Server per example.
6
+ class RequestLog
7
+ # Use http_method instead of method to avoid overriding Struct#method.
8
+ Entry = Struct.new(:http_method, :path, :body, :headers, :query_params, keyword_init: true)
9
+
10
+ def initialize
11
+ @entries = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def record(method:, path:, body:, headers:, query_params:)
16
+ @mutex.synchronize do
17
+ @entries << Entry.new(
18
+ http_method: method.to_s.upcase,
19
+ path: path,
20
+ body: body,
21
+ headers: headers,
22
+ query_params: query_params
23
+ )
24
+ end
25
+ end
26
+
27
+ def all
28
+ @mutex.synchronize { @entries.dup }
29
+ end
30
+
31
+ # Returns all entries matching the given HTTP method and exact path.
32
+ def for(method, path)
33
+ all.select { |e| e.http_method == method.to_s.upcase && e.path == path }
34
+ end
35
+
36
+ def clear
37
+ @mutex.synchronize { @entries.clear }
38
+ end
39
+
40
+ def count
41
+ @mutex.synchronize { @entries.size }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpFake
4
+ # Represents a single declared route: method + path pattern + optional scenario.
5
+ class Route
6
+ attr_reader :method, :pattern, :scenario, :handler_block
7
+
8
+ def initialize(method, pattern, scenario: nil, &block)
9
+ @method = method.to_s.upcase
10
+ @pattern = pattern
11
+ @scenario = scenario
12
+ @handler_block = block
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "route"
4
+ require_relative "router"
5
+
6
+ module HttpFake
7
+ # DSL target for defining a set of routes.
8
+ # Instantiated once at class-load time; immutable after definition.
9
+ class RouteMap
10
+ attr_reader :declared_base_url
11
+
12
+ def initialize
13
+ @routes = []
14
+ @declared_base_url = nil
15
+ end
16
+
17
+ def base_url(url = nil)
18
+ url ? @declared_base_url = url : @declared_base_url
19
+ end
20
+
21
+ %i[get post put patch delete head options].each do |verb|
22
+ define_method(verb) do |pattern, scenario: nil, &block|
23
+ @routes << Route.new(verb, pattern, scenario: scenario, &block)
24
+ end
25
+ end
26
+
27
+ def routes
28
+ @routes.dup
29
+ end
30
+
31
+ def router
32
+ # Memoize — route list is never mutated after definition.
33
+ @router ||= Router.new(@routes)
34
+ end
35
+ end
36
+ end