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.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ module HeaderExpectations
6
+ def expect_header(key, value_or_regex)
7
+ with_request_dump_on_failure do
8
+ actual = header_value_for(key)
9
+ available_keys = rest_response.headers.keys.map(&:to_s).sort.join(", ")
10
+ message = "Expected response header #{key.inspect} to be present. " \
11
+ "Available headers: [#{available_keys}]"
12
+ raise ::RSpec::Expectations::ExpectationNotMetError, message if actual.nil?
13
+
14
+ if value_or_regex.is_a?(Regexp)
15
+ expect(actual).to match(value_or_regex)
16
+ else
17
+ expect(actual).to eq(value_or_regex)
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def header_value_for(key)
25
+ headers = rest_response.headers
26
+ return headers[key] if headers.key?(key)
27
+
28
+ key_str = key.to_s
29
+ pair = headers.find do |header_key, _|
30
+ header_key.to_s.casecmp(key_str).zero?
31
+ end
32
+ pair&.last
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ module RSpec
5
+ module Rest
6
+ class JsonSelector
7
+ TOKEN_PATTERN = /
8
+ (?:
9
+ \.([a-zA-Z_][a-zA-Z0-9_]*)
10
+ )|
11
+ (?:
12
+ \[(\d+)\]
13
+ )
14
+ /x
15
+
16
+ class << self
17
+ def extract(json, selector)
18
+ tokens = parse(selector)
19
+ current = json
20
+
21
+ tokens.each do |token|
22
+ current = dig(current, token, selector)
23
+ end
24
+
25
+ current
26
+ end
27
+
28
+ private
29
+
30
+ def parse(selector)
31
+ selector_str = selector.to_s
32
+ unless selector_str.start_with?("$")
33
+ raise InvalidJsonSelectorError, "Invalid selector #{selector.inspect}. Selector must start with '$'."
34
+ end
35
+
36
+ remaining = selector_str[1..]
37
+ tokens = []
38
+
39
+ until remaining.empty?
40
+ match = TOKEN_PATTERN.match(remaining)
41
+ if match.nil? || match.begin(0) != 0
42
+ raise InvalidJsonSelectorError,
43
+ "Invalid selector #{selector.inspect}. Supported forms include '$.a.b' and '$.items[0].id'."
44
+ end
45
+
46
+ tokens << if match[1]
47
+ [:key, match[1]]
48
+ else
49
+ [:index, match[2].to_i]
50
+ end
51
+
52
+ remaining = remaining[match[0].length..]
53
+ end
54
+
55
+ tokens
56
+ end
57
+
58
+ def dig(value, token, selector)
59
+ type, key = token
60
+
61
+ case type
62
+ when :key
63
+ unless value.is_a?(Hash) && value.key?(key)
64
+ raise MissingJsonPathError, "Selector #{selector.inspect} did not match path segment #{key.inspect}."
65
+ end
66
+
67
+ value[key]
68
+ when :index
69
+ unless value.is_a?(Array) && key < value.length
70
+ raise MissingJsonPathError, "Selector #{selector.inspect} did not match array index #{key}."
71
+ end
72
+
73
+ value[key]
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ module JsonTypeHelpers
6
+ def integer
7
+ be_a(Integer)
8
+ end
9
+
10
+ def string
11
+ be_a(String)
12
+ end
13
+
14
+ def boolean
15
+ satisfy("be boolean") { |value| [true, false].include?(value) }
16
+ end
17
+
18
+ def array_of(matcher)
19
+ all(matcher)
20
+ end
21
+
22
+ def hash_including(*)
23
+ a_hash_including(*)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ module PaginationExpectations
6
+ def expect_page_size(size, selector: "$")
7
+ with_request_dump_on_failure do
8
+ collection = extract_collection(selector)
9
+ expect(collection.size).to eq(size)
10
+ collection
11
+ end
12
+ end
13
+
14
+ def expect_max_page_size(max, selector: "$")
15
+ with_request_dump_on_failure do
16
+ collection = extract_collection(selector)
17
+ expect(collection.size).to be <= max
18
+ collection
19
+ end
20
+ end
21
+
22
+ def expect_ids_in_order(ids, selector: "$[*].id")
23
+ with_request_dump_on_failure do
24
+ actual_ids = extract_id_list(selector)
25
+ expect(actual_ids).to eq(ids)
26
+ actual_ids
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def extract_collection(selector)
33
+ payload = rest_response.json
34
+ selected = selector == "$" ? payload : JsonSelector.extract(payload, selector)
35
+
36
+ unless selected.is_a?(Array)
37
+ raise ::RSpec::Expectations::ExpectationNotMetError,
38
+ "Expected selector #{selector.inspect} to resolve to an Array, got #{selected.class}."
39
+ end
40
+
41
+ selected
42
+ end
43
+
44
+ def extract_id_list(selector)
45
+ wildcard_match = /\A\$\[\*\]\.([a-zA-Z_][a-zA-Z0-9_]*)\z/.match(selector.to_s)
46
+ return JsonSelector.extract(rest_response.json, selector) unless wildcard_match
47
+
48
+ key = wildcard_match[1]
49
+ extract_collection("$").map.with_index do |item, index|
50
+ unless item.is_a?(Hash) && item.key?(key)
51
+ raise ::RSpec::Expectations::ExpectationNotMetError,
52
+ "Selector #{selector.inspect} did not match element #{index} for key #{key.inspect}."
53
+ end
54
+
55
+ item[key]
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/test"
4
+
5
+ module RSpec
6
+ module Rest
7
+ module RequestBuilders
8
+ def header(key, value)
9
+ rest_request_state[:headers][key] = value
10
+ end
11
+
12
+ def headers(value)
13
+ rest_request_state[:headers].merge!(value)
14
+ end
15
+
16
+ def query(value)
17
+ rest_request_state[:query] ||= {}
18
+ rest_request_state[:query].merge!(value)
19
+ end
20
+
21
+ def json(value)
22
+ rest_request_state[:json] = value
23
+ end
24
+
25
+ def multipart!
26
+ rest_request_state[:multipart] = true
27
+ rest_request_state[:params] ||= {}
28
+ end
29
+
30
+ def file(param_key, file_or_path, content_type: nil, filename: nil)
31
+ multipart!
32
+ rest_request_state[:params][param_key] = build_uploaded_file(
33
+ file_or_path,
34
+ content_type: content_type,
35
+ filename: filename
36
+ )
37
+ end
38
+
39
+ def path_params(value)
40
+ rest_request_state[:path_params].merge!(value.transform_keys(&:to_s))
41
+ end
42
+
43
+ def bearer(token)
44
+ header("Authorization", "Bearer #{token}")
45
+ end
46
+
47
+ def unauthenticated!
48
+ header("Authorization", nil)
49
+ end
50
+
51
+ private
52
+
53
+ def build_uploaded_file(file_or_path, content_type:, filename:)
54
+ if file_or_path.is_a?(Rack::Test::UploadedFile)
55
+ if content_type || filename
56
+ raise ArgumentError,
57
+ "content_type and filename cannot be specified when file_or_path is a Rack::Test::UploadedFile"
58
+ end
59
+
60
+ return file_or_path
61
+ end
62
+
63
+ path = if file_or_path.respond_to?(:to_path)
64
+ file_or_path.to_path
65
+ else
66
+ file_or_path.to_s
67
+ end
68
+
69
+ Rack::Test::UploadedFile.new(path, content_type, false, original_filename: filename)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+
6
+ module RSpec
7
+ module Rest
8
+ class Response
9
+ attr_reader :raw_response
10
+
11
+ def initialize(raw_response)
12
+ @raw_response = raw_response
13
+ end
14
+
15
+ def status
16
+ raw_response.status
17
+ end
18
+
19
+ def headers
20
+ raw_response.headers
21
+ end
22
+
23
+ def body
24
+ raw_response.body.to_s
25
+ end
26
+
27
+ def json
28
+ return @json if instance_variable_defined?(:@json)
29
+
30
+ @json = JSON.parse(body)
31
+ rescue JSON::ParserError => e
32
+ snippet = body[0, 200]
33
+ raise InvalidJsonError, "Failed to parse JSON response: #{e.message}. Body snippet: #{snippet.inspect}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack/test"
5
+ require "rack/utils"
6
+ require_relative "errors"
7
+ require_relative "response"
8
+
9
+ module RSpec
10
+ module Rest
11
+ class Session
12
+ SUPPORTED_HTTP_METHODS = %i[get post put patch delete].freeze
13
+
14
+ attr_reader :config, :last_request
15
+
16
+ def initialize(config)
17
+ @config = config
18
+ validate_config!
19
+ @rack_session = Rack::Test::Session.new(Rack::MockSession.new(config.app))
20
+ @last_request = nil
21
+ end
22
+
23
+ def request(method:, path:, **options)
24
+ method = validate_http_method!(method)
25
+ resource_path = options[:resource_path]
26
+ headers = options[:headers]
27
+ query = options[:query]
28
+ json = options[:json]
29
+ params = options[:params]
30
+
31
+ request_path = build_path(config.base_path, resource_path, path)
32
+ request_path = append_query(request_path, query)
33
+
34
+ request_headers = build_headers(headers, include_json_content_type: !json.nil?)
35
+ rack_env_headers = build_rack_env_headers(request_headers)
36
+ request_payload = build_payload(json: json, params: params)
37
+
38
+ @last_request = {
39
+ method: method.upcase,
40
+ path: request_path,
41
+ url: build_url(config.base_url, request_path),
42
+ headers: request_headers,
43
+ env: rack_env_headers,
44
+ body: request_payload
45
+ }
46
+
47
+ @rack_session.public_send(method, request_path, request_payload, rack_env_headers)
48
+ response
49
+ end
50
+
51
+ def response
52
+ Response.new(@rack_session.last_response)
53
+ end
54
+
55
+ private
56
+
57
+ def validate_config!
58
+ return unless config.app.nil?
59
+
60
+ raise MissingAppError, "Config#app is required to initialize RSpec::Rest::Session"
61
+ end
62
+
63
+ def validate_http_method!(method)
64
+ normalized = method.to_s.downcase
65
+ return normalized if SUPPORTED_HTTP_METHODS.include?(normalized.to_sym)
66
+
67
+ supported = SUPPORTED_HTTP_METHODS.map(&:to_s).join(", ")
68
+ raise UnsupportedHttpMethodError,
69
+ "Unsupported HTTP method: #{method.inspect}. Supported methods: #{supported}"
70
+ end
71
+
72
+ def build_headers(request_headers, include_json_content_type:)
73
+ headers = config.base_headers.dup
74
+
75
+ if config.default_format == :json
76
+ headers["Accept"] ||= "application/json"
77
+ end
78
+
79
+ merge_request_headers!(headers, request_headers || {})
80
+ if include_json_content_type
81
+ headers["Content-Type"] ||= "application/json"
82
+ end
83
+
84
+ headers
85
+ end
86
+
87
+ def merge_request_headers!(headers, request_headers)
88
+ request_headers.each do |key, value|
89
+ if value.nil?
90
+ headers.delete_if { |header_key, _| header_key.to_s.casecmp(key.to_s).zero? }
91
+ next
92
+ end
93
+
94
+ existing_key = headers.keys.find { |header_key| header_key.to_s.casecmp(key.to_s).zero? }
95
+ headers.delete(existing_key) if existing_key && existing_key != key
96
+ headers[key] = value
97
+ end
98
+ end
99
+
100
+ def build_payload(json:, params:)
101
+ return JSON.dump(json) unless json.nil?
102
+
103
+ params || {}
104
+ end
105
+
106
+ def build_rack_env_headers(headers)
107
+ headers.transform_keys { |key| normalize_header_key(key) }
108
+ end
109
+
110
+ def normalize_header_key(key)
111
+ key_str = key.to_s
112
+ return key_str if key_str.start_with?("HTTP_", "CONTENT_TYPE", "CONTENT_LENGTH", "rack.")
113
+ return "CONTENT_TYPE" if key_str.casecmp("Content-Type").zero?
114
+ return "CONTENT_LENGTH" if key_str.casecmp("Content-Length").zero?
115
+
116
+ "HTTP_#{key_str.tr('-', '_').upcase}"
117
+ end
118
+
119
+ def append_query(path, query)
120
+ return path if query.nil? || query.empty?
121
+
122
+ "#{path}?#{Rack::Utils.build_query(query)}"
123
+ end
124
+
125
+ def build_path(base_path, resource_path, endpoint_path)
126
+ segments = [base_path, resource_path, endpoint_path].compact.map(&:to_s)
127
+ normalized = segments.map { |segment| segment.gsub(%r{\A/+|/+\z}, "") }.reject(&:empty?)
128
+ "/#{normalized.join('/')}"
129
+ end
130
+
131
+ def build_url(base_url, path)
132
+ "#{base_url.to_s.sub(%r{/+\z}, '')}#{path}"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/rspec/rest.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rest/version"
4
+ require_relative "rest/errors"
5
+ require_relative "rest/config"
6
+ require_relative "rest/response"
7
+ require_relative "rest/session"
8
+ require_relative "rest/json_selector"
9
+ require_relative "rest/formatters/request_recorder"
10
+ require_relative "rest/dsl"
11
+
12
+ module RSpec
13
+ module Rest
14
+ def self.included(base)
15
+ base.include(DSL)
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,157 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-rest
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Carl
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack-test
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '1.72'
62
+ - - "<"
63
+ - !ruby/object:Gem::Version
64
+ version: '2.0'
65
+ type: :development
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '1.72'
72
+ - - "<"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rubocop-rspec
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.4'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.4'
89
+ description: A Ruby gem for concise, behavior-first REST API specs backed by Rack::Test
90
+ and RSpec.
91
+ email:
92
+ - carl@example.com
93
+ executables:
94
+ - rspec-rest
95
+ extensions: []
96
+ extra_rdoc_files: []
97
+ files:
98
+ - ".gitignore"
99
+ - ".rubocop.yml"
100
+ - CHANGELOG.md
101
+ - Gemfile
102
+ - Gemfile.lock
103
+ - LICENSE
104
+ - README.md
105
+ - Rakefile
106
+ - exe/rspec-rest
107
+ - lib/rspec/rest.rb
108
+ - lib/rspec/rest/captures.rb
109
+ - lib/rspec/rest/class_level_contracts.rb
110
+ - lib/rspec/rest/class_level_presets.rb
111
+ - lib/rspec/rest/config.rb
112
+ - lib/rspec/rest/contract_expectations.rb
113
+ - lib/rspec/rest/contract_matcher.rb
114
+ - lib/rspec/rest/dsl.rb
115
+ - lib/rspec/rest/error_expectations.rb
116
+ - lib/rspec/rest/errors.rb
117
+ - lib/rspec/rest/expectations.rb
118
+ - lib/rspec/rest/formatters/helpers.rb
119
+ - lib/rspec/rest/formatters/request_dump.rb
120
+ - lib/rspec/rest/formatters/request_recorder.rb
121
+ - lib/rspec/rest/header_expectations.rb
122
+ - lib/rspec/rest/json_selector.rb
123
+ - lib/rspec/rest/json_type_helpers.rb
124
+ - lib/rspec/rest/pagination_expectations.rb
125
+ - lib/rspec/rest/request_builders.rb
126
+ - lib/rspec/rest/response.rb
127
+ - lib/rspec/rest/session.rb
128
+ - lib/rspec/rest/version.rb
129
+ homepage: https://github.com/llwebconsulting/rspec-rest
130
+ licenses:
131
+ - MIT
132
+ metadata:
133
+ allowed_push_host: https://rubygems.org
134
+ homepage_uri: https://github.com/llwebconsulting/rspec-rest
135
+ source_code_uri: https://github.com/llwebconsulting/rspec-rest
136
+ changelog_uri: https://github.com/llwebconsulting/rspec-rest/blob/main/CHANGELOG.md
137
+ rubygems_mfa_required: 'true'
138
+ post_install_message:
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: 3.2.0
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubygems_version: 3.5.18
154
+ signing_key:
155
+ specification_version: 4
156
+ summary: Rack::Test + RSpec DSL for REST API testing
157
+ test_files: []