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.
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
@@ -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
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