rspec-rest 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/.gitignore +69 -0
- data/.rubocop.yml +49 -0
- data/CHANGELOG.md +45 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +74 -0
- data/LICENSE +21 -0
- data/README.md +447 -0
- data/Rakefile +8 -0
- data/exe/rspec-rest +12 -0
- data/lib/rspec/rest/captures.rb +27 -0
- data/lib/rspec/rest/class_level_contracts.rb +40 -0
- data/lib/rspec/rest/class_level_presets.rb +90 -0
- data/lib/rspec/rest/config.rb +27 -0
- data/lib/rspec/rest/contract_expectations.rb +38 -0
- data/lib/rspec/rest/contract_matcher.rb +60 -0
- data/lib/rspec/rest/dsl.rb +239 -0
- data/lib/rspec/rest/error_expectations.rb +57 -0
- data/lib/rspec/rest/errors.rb +21 -0
- data/lib/rspec/rest/expectations.rb +114 -0
- data/lib/rspec/rest/formatters/helpers.rb +26 -0
- data/lib/rspec/rest/formatters/request_dump.rb +101 -0
- data/lib/rspec/rest/formatters/request_recorder.rb +76 -0
- data/lib/rspec/rest/header_expectations.rb +36 -0
- data/lib/rspec/rest/json_selector.rb +79 -0
- data/lib/rspec/rest/json_type_helpers.rb +27 -0
- data/lib/rspec/rest/pagination_expectations.rb +60 -0
- data/lib/rspec/rest/request_builders.rb +73 -0
- data/lib/rspec/rest/response.rb +37 -0
- data/lib/rspec/rest/session.rb +136 -0
- data/lib/rspec/rest/version.rb +7 -0
- data/lib/rspec/rest.rb +18 -0
- metadata +157 -0
data/README.md
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# rspec-rest
|
|
2
|
+
|
|
3
|
+
`rspec-rest` is a Ruby gem for behavior-first REST API specs built on top of
|
|
4
|
+
RSpec and Rack::Test.
|
|
5
|
+
|
|
6
|
+
It focuses on:
|
|
7
|
+
|
|
8
|
+
- concise request DSL
|
|
9
|
+
- JSON-first expectations
|
|
10
|
+
- capture/reuse of response values
|
|
11
|
+
- high-signal failure output with request/response context
|
|
12
|
+
- auto-generated `curl` reproduction commands on failures
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
The gem is pre-release and in active development toward `0.1.0`.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
When published:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Gemfile
|
|
24
|
+
gem "rspec-rest"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Until then, use GitHub:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# Gemfile
|
|
31
|
+
gem "rspec-rest", git: "https://github.com/llwebconsulting/rspec-rest.git"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
RSpec.describe "Users API" do
|
|
44
|
+
include RSpec::Rest
|
|
45
|
+
|
|
46
|
+
api do
|
|
47
|
+
app Rails.application
|
|
48
|
+
base_path "/v1"
|
|
49
|
+
base_headers "Accept" => "application/json"
|
|
50
|
+
default_format :json
|
|
51
|
+
base_url "http://localhost:3000" # used for failure-time curl reproduction
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
resource "/users" do
|
|
55
|
+
get "/" do
|
|
56
|
+
expect_status 200
|
|
57
|
+
expect_header "Content-Type", "application/json"
|
|
58
|
+
expect_json array_of(hash_including("id" => integer, "email" => string))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
post "/" do
|
|
62
|
+
json "email" => "carl@example.com", "name" => "Carl"
|
|
63
|
+
expect_status 201
|
|
64
|
+
capture :user_id, "$.id"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Before and After (Rack::Test to rspec-rest)
|
|
71
|
+
|
|
72
|
+
The example below shows the same behavior test written two ways.
|
|
73
|
+
|
|
74
|
+
Before (`Rack::Test` + manual response parsing):
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
RSpec.describe MyApp::V1::Posts, type: :request do
|
|
78
|
+
include Rack::Test::Methods
|
|
79
|
+
|
|
80
|
+
def app
|
|
81
|
+
MyApp::Base
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
let(:auth_token) { "test-token" }
|
|
85
|
+
let!(:posts) { create_list(:post, 3).sort_by(&:created_at).reverse }
|
|
86
|
+
|
|
87
|
+
before { header "Authorization", "Bearer #{auth_token}" }
|
|
88
|
+
|
|
89
|
+
it "returns posts page 1" do
|
|
90
|
+
get "/api/v1/posts", { page: 1, per_page: 10 }
|
|
91
|
+
payload = JSON.parse(last_response.body)
|
|
92
|
+
|
|
93
|
+
expect(last_response.status).to eq(200)
|
|
94
|
+
expect(payload.size).to eq(3)
|
|
95
|
+
expect(payload.first["id"]).to eq(posts.first.id)
|
|
96
|
+
expect(payload.first["author"]["id"]).to eq(posts.first.author.id)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
After (`rspec-rest` DSL):
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
RSpec.describe "Posts API" do
|
|
105
|
+
include RSpec::Rest
|
|
106
|
+
|
|
107
|
+
let(:auth_token) { "test-token" }
|
|
108
|
+
let!(:posts) { create_list(:post, 3).sort_by(&:created_at).reverse }
|
|
109
|
+
|
|
110
|
+
api do
|
|
111
|
+
app MyApp::Base
|
|
112
|
+
base_path "/api/v1"
|
|
113
|
+
default_format :json
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
with_query locale: "en"
|
|
117
|
+
with_headers "X-Tenant-Id" => "tenant-123"
|
|
118
|
+
contract :post_summary do
|
|
119
|
+
hash_including("id" => integer, "author" => hash_including("id" => integer))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
resource "/posts" do
|
|
123
|
+
with_auth auth_token
|
|
124
|
+
with_query per_page: 10
|
|
125
|
+
|
|
126
|
+
get "/" do
|
|
127
|
+
query page: 1
|
|
128
|
+
|
|
129
|
+
expect_status 200
|
|
130
|
+
expect_json array_of(expect_json_contract(:post_summary))
|
|
131
|
+
expect_json_at "$[0].id", posts.first.id
|
|
132
|
+
expect_json_at "$[0].author.id", posts.first.author.id
|
|
133
|
+
expect_page_size 10
|
|
134
|
+
expect_max_page_size 20
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
get "/{id}" do
|
|
138
|
+
path_params id: 999_999
|
|
139
|
+
expect_error status: 404, message: "Post not found"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
resource "/uploads" do
|
|
144
|
+
with_auth auth_token
|
|
145
|
+
|
|
146
|
+
post "/" do
|
|
147
|
+
multipart!
|
|
148
|
+
file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
|
|
149
|
+
expect_status 201
|
|
150
|
+
expect_json hash_including("filename" => "sample_upload.txt")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
What improves:
|
|
157
|
+
|
|
158
|
+
- Request setup is declarative (`api`, `resource`, shared presets, `query`, `multipart!`, `file`).
|
|
159
|
+
- JSON expectations are concise and structure-aware (`expect_json`, `expect_json_at`).
|
|
160
|
+
- Common API outcomes are one-liners (`expect_error`, pagination helpers).
|
|
161
|
+
- Failures include request/response context plus a reproducible `curl`.
|
|
162
|
+
|
|
163
|
+
## API Config (`api`)
|
|
164
|
+
|
|
165
|
+
`api` defines shared runtime configuration for a spec group.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
api do
|
|
169
|
+
app Rails.application
|
|
170
|
+
base_path "/v1"
|
|
171
|
+
base_headers "Accept" => "application/json"
|
|
172
|
+
default_format :json
|
|
173
|
+
base_url "http://localhost:3000"
|
|
174
|
+
redact_headers ["Authorization", "Cookie", "Set-Cookie"]
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Supported config:
|
|
179
|
+
|
|
180
|
+
- `app`: Rack app (required)
|
|
181
|
+
- `base_path`: base request path prefix
|
|
182
|
+
- `base_headers`: default headers merged into every request
|
|
183
|
+
- `default_format`: set to `:json` to default `Accept: application/json`
|
|
184
|
+
- `base_url`: used for generated curl commands (`http://example.org` default)
|
|
185
|
+
- `redact_headers`: headers redacted in failure output and curl
|
|
186
|
+
|
|
187
|
+
## Resources And Verbs
|
|
188
|
+
|
|
189
|
+
- `resource "/users" do ... end`
|
|
190
|
+
- `get`, `post`, `put`, `patch`, `delete`
|
|
191
|
+
|
|
192
|
+
Resource paths are composable and support placeholders:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
resource "/users" do
|
|
196
|
+
resource "/{id}/posts" do
|
|
197
|
+
get "/" do
|
|
198
|
+
path_params id: 1
|
|
199
|
+
expect_status 404
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Shared Request Presets
|
|
206
|
+
|
|
207
|
+
Define shared request defaults at group/resource scope:
|
|
208
|
+
|
|
209
|
+
- `with_headers(hash)`
|
|
210
|
+
- `with_query(hash)`
|
|
211
|
+
- `with_auth(token)` (sets `Authorization: Bearer <token>`)
|
|
212
|
+
|
|
213
|
+
Use presets when your API requires repeated request context across many endpoints,
|
|
214
|
+
for example auth headers, locale/tenant query params, client/app version headers,
|
|
215
|
+
or other codebase-specific defaults.
|
|
216
|
+
|
|
217
|
+
Nested resources inherit presets, and request-level builders (`header`, `query`, `bearer`) can override them.
|
|
218
|
+
|
|
219
|
+
Typical pattern:
|
|
220
|
+
- set broad defaults at top-level (`with_query`, `with_headers`)
|
|
221
|
+
- narrow defaults at resource scope (`with_auth`, resource-specific headers)
|
|
222
|
+
- override per request only when behavior differs
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
with_query locale: "en"
|
|
226
|
+
with_headers "X-Tenant-Id" => "tenant-123"
|
|
227
|
+
|
|
228
|
+
resource "/posts" do
|
|
229
|
+
with_auth ENV.fetch("API_TOKEN", "token-123")
|
|
230
|
+
with_headers "X-Client" => "mobile"
|
|
231
|
+
|
|
232
|
+
get "/" do
|
|
233
|
+
query page: 2
|
|
234
|
+
expect_status 200
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
get "/admin" do
|
|
238
|
+
header "X-Client", "internal-tool" # request-level override
|
|
239
|
+
query locale: "fr" # request-level override
|
|
240
|
+
expect_status 200
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Request Builders
|
|
246
|
+
|
|
247
|
+
Inside verb blocks:
|
|
248
|
+
|
|
249
|
+
- `header(key, value)`
|
|
250
|
+
- `headers(hash)`
|
|
251
|
+
- `bearer(token)`
|
|
252
|
+
- `unauthenticated!`
|
|
253
|
+
- `query(hash)`
|
|
254
|
+
- `json(hash_or_string)`
|
|
255
|
+
- `multipart!`
|
|
256
|
+
- `file(param_key, file_or_path, content_type: nil, filename: nil)`
|
|
257
|
+
- `path_params(hash)`
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
post "/" do
|
|
263
|
+
headers "X-Trace-Id" => "abc-123"
|
|
264
|
+
bearer "token-123"
|
|
265
|
+
query include_details: "true"
|
|
266
|
+
json "email" => "dev@example.com", "name" => "Dev"
|
|
267
|
+
expect_status 201
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Multipart upload example:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
post "/uploads" do
|
|
275
|
+
multipart!
|
|
276
|
+
file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
|
|
277
|
+
expect_status 201
|
|
278
|
+
expect_json hash_including("filename" => "sample_upload.txt")
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Expectations
|
|
283
|
+
|
|
284
|
+
Available expectation helpers:
|
|
285
|
+
|
|
286
|
+
- `expect_status(code)`
|
|
287
|
+
- `expect_header(key, value_or_regex)`
|
|
288
|
+
- `expect_json(expected = nil, &block)`
|
|
289
|
+
- `expect_json_contract(name)`
|
|
290
|
+
- `expect_json_at(selector, expected = nil, &block)`
|
|
291
|
+
- `expect_error(status:, message: nil, includes: nil, field: nil, key: "error")`
|
|
292
|
+
- `expect_page_size(size, selector: "$")`
|
|
293
|
+
- `expect_max_page_size(max, selector: "$")`
|
|
294
|
+
- `expect_ids_in_order(ids, selector: "$[*].id")`
|
|
295
|
+
|
|
296
|
+
`expect_json` supports:
|
|
297
|
+
|
|
298
|
+
- matcher mode:
|
|
299
|
+
- `expect_json hash_including("id" => integer)`
|
|
300
|
+
- equality mode:
|
|
301
|
+
- `expect_json("id" => 1, "email" => "jane@example.com", "name" => "Jane")`
|
|
302
|
+
- block mode:
|
|
303
|
+
- `expect_json { |payload| expect(payload["id"]).to integer }`
|
|
304
|
+
|
|
305
|
+
`expect_json_at` supports the same matcher/equality/block modes against a selected path:
|
|
306
|
+
|
|
307
|
+
- matcher mode:
|
|
308
|
+
- `expect_json_at "$.user.id", integer`
|
|
309
|
+
- equality mode:
|
|
310
|
+
- `expect_json_at "$.user.email", "jane@example.com"`
|
|
311
|
+
- block mode:
|
|
312
|
+
- `expect_json_at "$.items[0]" { |item| expect(item["id"]).to integer }`
|
|
313
|
+
|
|
314
|
+
`expect_error` is a convenience helper for common API error payload assertions:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
get "/{id}" do
|
|
318
|
+
path_params id: 999
|
|
319
|
+
expect_error status: 404, message: "Post not found"
|
|
320
|
+
end
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Pagination helpers:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
get "/" do
|
|
327
|
+
query page: 2, per_page: 10
|
|
328
|
+
expect_status 200
|
|
329
|
+
expect_page_size 10
|
|
330
|
+
expect_max_page_size 20
|
|
331
|
+
expect_ids_in_order [30, 29, 28, 27, 26, 25, 24, 23, 22, 21]
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Lightweight Contracts
|
|
336
|
+
|
|
337
|
+
A contract is a named, reusable JSON expectation (usually a response shape matcher).
|
|
338
|
+
Define it once in your spec group, then apply it anywhere with `expect_json_contract`.
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
contract :post_summary do
|
|
342
|
+
hash_including(
|
|
343
|
+
"id" => integer,
|
|
344
|
+
"title" => string,
|
|
345
|
+
"author" => hash_including("id" => integer)
|
|
346
|
+
)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
get "/" do
|
|
350
|
+
expect_status 200
|
|
351
|
+
expect_json array_of(expect_json_contract(:post_summary))
|
|
352
|
+
end
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
JSON type helpers:
|
|
356
|
+
|
|
357
|
+
- `integer`
|
|
358
|
+
- `string`
|
|
359
|
+
- `boolean`
|
|
360
|
+
- `array_of(matcher)`
|
|
361
|
+
- `hash_including(...)`
|
|
362
|
+
|
|
363
|
+
## Captures
|
|
364
|
+
|
|
365
|
+
Capture response values and reuse them later in the same example:
|
|
366
|
+
|
|
367
|
+
- `capture(:name, selector)`
|
|
368
|
+
- `get(:name)`
|
|
369
|
+
|
|
370
|
+
Selector syntax (minimal JSON selector):
|
|
371
|
+
|
|
372
|
+
- `$.a.b`
|
|
373
|
+
- `$.items[0].id`
|
|
374
|
+
|
|
375
|
+
Example:
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
post "/" do
|
|
379
|
+
json "email" => "flow@example.com", "name" => "Flow"
|
|
380
|
+
expect_status 201
|
|
381
|
+
capture :user_id, "$.id"
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Failure Output and curl Reproduction
|
|
386
|
+
|
|
387
|
+
When an expectation fails, output includes:
|
|
388
|
+
|
|
389
|
+
- request method/path
|
|
390
|
+
- request headers/body
|
|
391
|
+
- response status/headers/body
|
|
392
|
+
- generated `curl` command
|
|
393
|
+
|
|
394
|
+
Sensitive headers are redacted by default and can be customized via
|
|
395
|
+
`redact_headers`.
|
|
396
|
+
|
|
397
|
+
## Contributing
|
|
398
|
+
|
|
399
|
+
Contributions are welcome.
|
|
400
|
+
|
|
401
|
+
Recommended workflow:
|
|
402
|
+
|
|
403
|
+
1. Fork the repository on GitHub.
|
|
404
|
+
2. Clone your fork locally.
|
|
405
|
+
3. Create a feature branch from `main`.
|
|
406
|
+
4. Make your changes with tests/docs as needed.
|
|
407
|
+
5. Run quality checks locally:
|
|
408
|
+
- `bundle exec rspec`
|
|
409
|
+
- `bundle exec rubocop`
|
|
410
|
+
6. Commit and push your branch to your fork.
|
|
411
|
+
7. Open a Pull Request from your fork to this repository.
|
|
412
|
+
|
|
413
|
+
Pull request guidelines:
|
|
414
|
+
|
|
415
|
+
- Keep changes focused and include context in the PR description.
|
|
416
|
+
- Add or update specs for behavior changes.
|
|
417
|
+
- Update README/CHANGELOG when public behavior changes.
|
|
418
|
+
- Ensure CI is green before requesting final review.
|
|
419
|
+
|
|
420
|
+
Reporting issues and feature ideas:
|
|
421
|
+
|
|
422
|
+
- Use GitHub Issues and choose the appropriate template:
|
|
423
|
+
- Bug report for incorrect behavior (include expected vs actual behavior and repro steps).
|
|
424
|
+
- Feature request for enhancement ideas.
|
|
425
|
+
- Feature suggestions are appreciated and encouraged.
|
|
426
|
+
- The fastest path to getting a feature implemented is to open a pull request with the proposed change and tests.
|
|
427
|
+
|
|
428
|
+
## Development
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
bundle install
|
|
432
|
+
bundle exec rspec
|
|
433
|
+
bundle exec rubocop
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## Namespace
|
|
437
|
+
|
|
438
|
+
Gem name: `rspec-rest`
|
|
439
|
+
Ruby namespace: `RSpec::Rest`
|
|
440
|
+
|
|
441
|
+
## Changelog
|
|
442
|
+
|
|
443
|
+
See [CHANGELOG.md](./CHANGELOG.md).
|
|
444
|
+
|
|
445
|
+
## License
|
|
446
|
+
|
|
447
|
+
MIT. See [LICENSE](./LICENSE).
|
data/Rakefile
ADDED
data/exe/rspec-rest
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "rspec/rest"
|
|
5
|
+
|
|
6
|
+
# This executable is a placeholder for a future rspec-rest command-line interface.
|
|
7
|
+
# It currently only loads the library and informs the user that the CLI
|
|
8
|
+
# has not been implemented yet.
|
|
9
|
+
|
|
10
|
+
if $PROGRAM_NAME == __FILE__
|
|
11
|
+
warn "rspec-rest: command-line interface is not yet implemented. This executable is a placeholder."
|
|
12
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "json_selector"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Rest
|
|
8
|
+
module Captures
|
|
9
|
+
def capture(name, selector)
|
|
10
|
+
captures[name.to_sym] = JsonSelector.extract(rest_response.json, selector)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def get(name)
|
|
14
|
+
key = name.to_sym
|
|
15
|
+
return captures[key] if captures.key?(key)
|
|
16
|
+
|
|
17
|
+
raise MissingCaptureError, "No captured value found for #{key.inspect} in this example."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def captures
|
|
23
|
+
@captures ||= {}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
module ClassLevelContracts
|
|
6
|
+
def contract(name, &definition)
|
|
7
|
+
raise ArgumentError, "contract requires a block definition" unless block_given?
|
|
8
|
+
|
|
9
|
+
rest_contracts_local[normalize_contract_name!(name)] = definition
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def rest_contract_definition(name)
|
|
13
|
+
rest_contracts[name.to_sym]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def normalize_contract_name!(name)
|
|
19
|
+
raise ArgumentError, "contract name cannot be nil" if name.nil?
|
|
20
|
+
return name.to_sym if name.respond_to?(:to_sym)
|
|
21
|
+
|
|
22
|
+
raise ArgumentError,
|
|
23
|
+
"contract name must respond to #to_sym, got #{name.inspect} (#{name.class})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rest_contracts_local
|
|
27
|
+
@rest_contracts_local ||= {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rest_contracts
|
|
31
|
+
inherited = if superclass.respond_to?(:rest_contracts, true)
|
|
32
|
+
superclass.send(:rest_contracts)
|
|
33
|
+
else
|
|
34
|
+
{}
|
|
35
|
+
end
|
|
36
|
+
inherited.merge(rest_contracts_local)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
module ClassLevelPresets
|
|
6
|
+
DEFAULT_PRESETS = {
|
|
7
|
+
headers: {},
|
|
8
|
+
query: {}
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def with_headers(value)
|
|
12
|
+
current_preset_scope[:headers].merge!(value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def with_query(value)
|
|
16
|
+
current_preset_scope[:query] ||= {}
|
|
17
|
+
current_preset_scope[:query].merge!(value)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_auth(token)
|
|
21
|
+
with_headers("Authorization" => "Bearer #{token}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def blank_presets
|
|
27
|
+
deep_dup_presets(DEFAULT_PRESETS)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rest_root_presets
|
|
31
|
+
@rest_root_presets ||= blank_presets
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_preset_scope
|
|
35
|
+
stack = @rest_preset_stack || []
|
|
36
|
+
return stack.last unless stack.empty?
|
|
37
|
+
|
|
38
|
+
rest_root_presets
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current_request_presets
|
|
42
|
+
presets = inherited_root_presets
|
|
43
|
+
merge_presets!(presets, rest_root_presets)
|
|
44
|
+
(@rest_preset_stack || []).each do |scope|
|
|
45
|
+
merge_presets!(presets, scope)
|
|
46
|
+
end
|
|
47
|
+
presets
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inherited_root_presets
|
|
51
|
+
return blank_presets unless superclass.respond_to?(:rest_root_presets, true)
|
|
52
|
+
|
|
53
|
+
deep_dup_presets(superclass.send(:rest_root_presets))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def merge_presets!(base, override)
|
|
57
|
+
base[:headers].merge!(override[:headers] || {})
|
|
58
|
+
if override[:query] && !override[:query].empty?
|
|
59
|
+
base[:query] ||= {}
|
|
60
|
+
base[:query].merge!(override[:query])
|
|
61
|
+
end
|
|
62
|
+
base
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def deep_dup(value)
|
|
66
|
+
case value
|
|
67
|
+
when Hash
|
|
68
|
+
value.transform_values do |v|
|
|
69
|
+
deep_dup(v)
|
|
70
|
+
end
|
|
71
|
+
when Array
|
|
72
|
+
value.map { |v| deep_dup(v) }
|
|
73
|
+
else
|
|
74
|
+
begin
|
|
75
|
+
value.dup
|
|
76
|
+
rescue TypeError
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def deep_dup_presets(presets)
|
|
83
|
+
{
|
|
84
|
+
headers: deep_dup(presets[:headers] || {}),
|
|
85
|
+
query: deep_dup(presets[:query] || {})
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_REDACT_HEADERS = %w[
|
|
7
|
+
Authorization
|
|
8
|
+
Proxy-Authorization
|
|
9
|
+
Cookie
|
|
10
|
+
Set-Cookie
|
|
11
|
+
X-Api-Key
|
|
12
|
+
X-Auth-Token
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
attr_accessor :app, :base_path, :base_headers, :default_format, :redact_headers, :base_url
|
|
16
|
+
|
|
17
|
+
def initialize(**options)
|
|
18
|
+
@app = options[:app]
|
|
19
|
+
@base_path = options[:base_path] || ""
|
|
20
|
+
@base_headers = (options[:base_headers] || {}).dup
|
|
21
|
+
@default_format = options[:default_format]
|
|
22
|
+
@redact_headers = (options[:redact_headers] || DEFAULT_REDACT_HEADERS).dup
|
|
23
|
+
@base_url = options[:base_url] || "http://example.org"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "contract_matcher"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Rest
|
|
7
|
+
module ContractExpectations
|
|
8
|
+
def expect_json_contract(name)
|
|
9
|
+
contract_name = normalize_contract_name(name)
|
|
10
|
+
return unknown_contract_matcher(name_error_message(name)) if contract_name.nil?
|
|
11
|
+
|
|
12
|
+
definition = self.class.rest_contract_definition(contract_name)
|
|
13
|
+
return ContractMatcher.new(name: contract_name, definition: definition, context: self) unless definition.nil?
|
|
14
|
+
|
|
15
|
+
available = self.class.send(:rest_contracts).keys.map(&:inspect).sort
|
|
16
|
+
message = "Unknown contract #{contract_name.inspect}. Available contracts: [#{available.join(', ')}]"
|
|
17
|
+
unknown_contract_matcher(message)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def normalize_contract_name(name)
|
|
23
|
+
return nil if name.nil? || !name.respond_to?(:to_sym)
|
|
24
|
+
|
|
25
|
+
name.to_sym
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unknown_contract_matcher(message)
|
|
29
|
+
UnknownContractMatcher.new(message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def name_error_message(name)
|
|
33
|
+
"Invalid contract name #{name.inspect} (#{name.class}). " \
|
|
34
|
+
"expect_json_contract requires a contract name that responds to #to_sym."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|