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.
- checksums.yaml +7 -0
- data/.rubocop.yml +102 -0
- data/CHANGELOG.md +39 -0
- data/CONTRIBUTING.md +67 -0
- data/LICENSE +21 -0
- data/README.md +478 -0
- data/Rakefile +8 -0
- data/lib/httpfake/configuration.rb +11 -0
- data/lib/httpfake/handler_context.rb +109 -0
- data/lib/httpfake/request_log.rb +44 -0
- data/lib/httpfake/route.rb +15 -0
- data/lib/httpfake/route_map.rb +36 -0
- data/lib/httpfake/router.rb +43 -0
- data/lib/httpfake/rspec.rb +206 -0
- data/lib/httpfake/server.rb +166 -0
- data/lib/httpfake/version.rb +5 -0
- data/lib/httpfake/webmock_integration.rb +43 -0
- data/lib/httpfake.rb +48 -0
- data/sig/httpfake.rbs +4 -0
- metadata +96 -0
data/README.md
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# httpfake
|
|
2
|
+
|
|
3
|
+
**A real fake HTTP server. For real tests.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/jibranusman/httpfake/actions)
|
|
6
|
+
[](https://badge.fury.io/rb/httpfake)
|
|
7
|
+
[](https://rubygems.org/gems/httpfake)
|
|
8
|
+
[](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,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
|