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.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpFake
4
+ # Matches an incoming (method, path) pair against a list of Route objects.
5
+ # Supports :param segments in patterns, e.g. "/v1/charges/:id".
6
+ class Router
7
+ Match = Struct.new(:route, :params, keyword_init: true)
8
+
9
+ def initialize(routes)
10
+ @routes = routes
11
+ end
12
+
13
+ # Returns a Match or nil.
14
+ def match(method, path, scenario: nil)
15
+ http_method = method.to_s.upcase
16
+ @routes.each do |route|
17
+ next unless route.method == http_method
18
+ next unless route.scenario == scenario
19
+
20
+ captures = extract_params(route.pattern, path)
21
+ next if captures.nil?
22
+
23
+ return Match.new(route: route, params: captures)
24
+ end
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ def extract_params(pattern, path)
31
+ regex = pattern_to_regex(pattern)
32
+ m = path.match(regex)
33
+ return nil unless m
34
+
35
+ m.named_captures.transform_keys(&:to_sym)
36
+ end
37
+
38
+ def pattern_to_regex(pattern)
39
+ escaped = pattern.gsub(/:([a-zA-Z_][a-zA-Z0-9_]*)/) { "(?<#{Regexp.last_match(1)}>[^/]+)" }
40
+ /\A#{escaped}\z/
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "route_map"
5
+ require_relative "server"
6
+ require_relative "webmock_integration"
7
+
8
+ module HttpFake
9
+ # RSpec integration.
10
+ #
11
+ # Two equivalent usage patterns:
12
+ #
13
+ # Pattern A — inline (per describe block):
14
+ #
15
+ # RSpec.describe MyService do
16
+ # include HttpFake::RSpec
17
+ #
18
+ # fake_server(:payments) do
19
+ # post "/charges" do
20
+ # respond 201, json: { id: "ch_abc" }
21
+ # end
22
+ # end
23
+ #
24
+ # it "creates a charge" do
25
+ # MyService.charge(100)
26
+ # expect(fake_server(:payments)).to have_received_request(:post, "/charges").once
27
+ # end
28
+ # end
29
+ #
30
+ # Pattern B — suite-wide definition (most common):
31
+ #
32
+ # FakeStripe = HttpFake.define(:stripe) do
33
+ # base_url "https://api.stripe.com"
34
+ # post "/v1/charges" do
35
+ # respond 200, json: { id: "ch_123" }
36
+ # end
37
+ # end
38
+ #
39
+ # RSpec.configure { |c| c.include FakeStripe.rspec_helpers }
40
+ #
41
+ # it "charges the card" do
42
+ # StripeService.charge(500)
43
+ # expect(fake_server(:stripe)).to have_received_request(:post, "/v1/charges").once
44
+ # end
45
+ #
46
+ module RSpec
47
+ def self.included(base)
48
+ base.extend(ClassMethods)
49
+ end
50
+
51
+ module ClassMethods
52
+ # Class-level macro. Evaluates the block into a RouteMap once at class-load
53
+ # time, then registers before/after hooks for per-example server lifecycle.
54
+ def fake_server(name, &)
55
+ route_map = RouteMap.new
56
+ route_map.instance_eval(&)
57
+ _httpfake_register(name, route_map)
58
+ end
59
+
60
+ # Internal: register before/after hooks for a pre-built RouteMap.
61
+ # Called by both the inline macro and Definition#rspec_helpers.
62
+ def _httpfake_register(name, route_map)
63
+ before(:each) do
64
+ server = Server.new(route_map)
65
+ server.start
66
+ stub = WebMockIntegration.setup(server)
67
+
68
+ @_httpfake_servers ||= {}
69
+ @_httpfake_webmock_stubs ||= {}
70
+ @_httpfake_servers[name] = server
71
+ @_httpfake_webmock_stubs[name] = stub
72
+ end
73
+
74
+ after(:each) do
75
+ server = @_httpfake_servers&.[](name)
76
+ stub = @_httpfake_webmock_stubs&.[](name)
77
+ WebMockIntegration.teardown(stub)
78
+ server&.stop
79
+ end
80
+ end
81
+ end
82
+
83
+ # Instance-level accessor — returns the live Server for this example.
84
+ def fake_server(name)
85
+ @_httpfake_servers[name]
86
+ end
87
+
88
+ # Run a block with a named scenario active.
89
+ # server_name defaults to the only server if exactly one is registered.
90
+ def with_scenario(scenario_name, server_name = nil, &)
91
+ name = server_name || begin
92
+ servers = @_httpfake_servers || {}
93
+ raise ArgumentError, "server_name required when multiple fake servers are active" if servers.size > 1
94
+ raise ArgumentError, "No fake servers are active" if servers.empty?
95
+
96
+ servers.keys.first
97
+ end
98
+
99
+ server = @_httpfake_servers[name]
100
+ raise ArgumentError, "No fake server named #{name.inspect}" unless server
101
+
102
+ server.with_scenario(scenario_name, &)
103
+ end
104
+ end
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # RSpec matchers
108
+ # ---------------------------------------------------------------------------
109
+
110
+ ::RSpec::Matchers.define :have_received_request do |method, path|
111
+ match do |server|
112
+ entries = server.request_log.for(method, path)
113
+ next false if entries.empty?
114
+ next false if @times && entries.count != @times
115
+ next false if @body_matcher && entries.none? { |e| body_matches?(e.body, @body_matcher) }
116
+
117
+ true
118
+ end
119
+
120
+ chain :once do
121
+ @times = 1
122
+ end
123
+ chain :twice do
124
+ @times = 2
125
+ end
126
+ chain :times do |n|
127
+ @times = n
128
+ end
129
+
130
+ chain :with do |body: nil|
131
+ @body_matcher = body
132
+ end
133
+
134
+ failure_message do |server|
135
+ entries = server.request_log.for(method, path)
136
+ if entries.empty?
137
+ "expected #{method.to_s.upcase} #{path} to have been received, but it was never called"
138
+ elsif @times && entries.count != @times
139
+ "expected #{method.to_s.upcase} #{path} to have been received #{@times} time(s), " \
140
+ "but it was received #{entries.count} time(s)"
141
+ else
142
+ "expected #{method.to_s.upcase} #{path} body to match #{@body_matcher.inspect}, " \
143
+ "but received: #{entries.map(&:body).inspect}"
144
+ end
145
+ end
146
+
147
+ failure_message_when_negated do |_server|
148
+ "expected #{method.to_s.upcase} #{path} not to have been received"
149
+ end
150
+
151
+ description do
152
+ desc = "have received #{method.to_s.upcase} #{path}"
153
+ desc += " #{@times} time(s)" if @times
154
+ desc += " with body matching #{@body_matcher.inspect}" if @body_matcher
155
+ desc
156
+ end
157
+
158
+ def body_matches?(actual, matcher)
159
+ case matcher
160
+ when Hash
161
+ actual.is_a?(Hash) && matcher.all? do |k, v|
162
+ actual_val = actual[k] || actual[k.to_s]
163
+ v === actual_val
164
+ end
165
+ else
166
+ matcher === actual
167
+ end
168
+ end
169
+ end
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Definition — returned by HttpFake.define
173
+ # ---------------------------------------------------------------------------
174
+
175
+ # Wraps a named RouteMap and generates an anonymous RSpec helper module.
176
+ class Definition
177
+ attr_reader :name, :route_map
178
+
179
+ def initialize(name, route_map)
180
+ @name = name
181
+ @route_map = route_map
182
+ end
183
+
184
+ # Returns an anonymous module. Include it in RSpec.configure to register
185
+ # the server lifecycle for every example group in the suite.
186
+ #
187
+ # RSpec.configure { |c| c.include FakeStripe.rspec_helpers }
188
+ #
189
+ def rspec_helpers
190
+ definition = self
191
+
192
+ Module.new do
193
+ include HttpFake::RSpec
194
+
195
+ # define_singleton_method closes over `definition` from the outer scope.
196
+ # `def self.included` would NOT — def never captures outer locals.
197
+ define_singleton_method(:included) do |base|
198
+ super(base)
199
+ base._httpfake_register(definition.name, definition.route_map)
200
+ end
201
+
202
+ define_method(:_httpfake_definition) { definition }
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "rack"
5
+ require "stringio"
6
+ require "json"
7
+ require_relative "request_log"
8
+ require_relative "handler_context"
9
+
10
+ module HttpFake
11
+ # A real WEBrick HTTP server that runs in a background thread.
12
+ #
13
+ # Uses WEBrick directly (no Rack::Handler) so it works with both
14
+ # Rack 2.x and Rack 3.x without the rackup gem.
15
+ #
16
+ # Port 0 lets the OS pick a free port atomically — parallel test
17
+ # runners never collide.
18
+ class Server
19
+ attr_reader :route_map, :request_log, :port
20
+
21
+ def initialize(route_map)
22
+ @route_map = route_map
23
+ @request_log = RequestLog.new
24
+ @webrick = nil
25
+ @thread = nil
26
+ @port = nil
27
+ @scenario = nil
28
+ @scenario_mu = Mutex.new
29
+ end
30
+
31
+ def start
32
+ rack_app = build_rack_app
33
+
34
+ # WEBrick binds to the OS-assigned port during initialization.
35
+ @webrick = WEBrick::HTTPServer.new(
36
+ Port: 0,
37
+ Logger: WEBrick::Log.new(File::NULL),
38
+ AccessLog: []
39
+ )
40
+ @port = @webrick.config[:Port]
41
+
42
+ @webrick.mount_proc("/") do |req, res|
43
+ status, headers, body = rack_app.call(rack_env_from(req))
44
+ res.status = status.to_i
45
+ headers.each { |k, v| res[k] = v }
46
+ res.body = Array(body).join
47
+ rescue StandardError => e
48
+ res.status = 500
49
+ res["Content-Type"] = "application/json"
50
+ res.body = JSON.generate(error: "#{e.class}: #{e.message}")
51
+ end
52
+
53
+ @thread = Thread.new { @webrick.start }
54
+ @thread.abort_on_exception = true
55
+
56
+ # Poll until WEBrick enters its accept loop.
57
+ deadline = Time.now + 5
58
+ sleep(0.005) until @webrick.status == :Running || Time.now > deadline
59
+ raise "httpfake: server failed to start within 5 seconds" unless @webrick.status == :Running
60
+
61
+ self
62
+ end
63
+
64
+ def stop
65
+ @webrick&.shutdown
66
+ @thread&.join(3)
67
+ @thread = nil
68
+ @webrick = nil
69
+ end
70
+
71
+ def base_url = "http://127.0.0.1:#{@port}"
72
+
73
+ # The Rack app is also exposed for WebMock's to_rack() interception.
74
+ def rack_app
75
+ @rack_app ||= build_rack_app
76
+ end
77
+
78
+ def with_scenario(name)
79
+ @scenario_mu.synchronize { @scenario = name }
80
+ yield
81
+ ensure
82
+ @scenario_mu.synchronize { @scenario = nil }
83
+ end
84
+
85
+ def current_scenario
86
+ @scenario_mu.synchronize { @scenario }
87
+ end
88
+
89
+ private
90
+
91
+ def build_rack_app
92
+ route_map = @route_map
93
+ request_log = @request_log
94
+ server = self
95
+
96
+ lambda do |env|
97
+ req = Rack::Request.new(env)
98
+ method = req.request_method
99
+ path = req.path_info
100
+ scenario = server.current_scenario
101
+
102
+ result = route_map.router.match(method, path, scenario: scenario)
103
+ result ||= route_map.router.match(method, path, scenario: nil) if scenario
104
+
105
+ return json_response(404, error: "No route matches #{method} #{path}") unless result
106
+
107
+ call_index = request_log.for(method, path).count
108
+ ctx = HandlerContext.new(req, result.params, call_index: call_index)
109
+
110
+ begin
111
+ ctx.instance_eval(&result.route.handler_block)
112
+ rescue HandlerContext::ContractError => e
113
+ return json_response(422, error: e.message)
114
+ end
115
+
116
+ request_log.record(
117
+ method: method,
118
+ path: path,
119
+ body: ctx.body,
120
+ headers: env.select { |k, _| k.start_with?("HTTP_") },
121
+ query_params: ctx.query_params
122
+ )
123
+
124
+ ctx.response || json_response(200, status: "ok")
125
+ end
126
+ end
127
+
128
+ # Convert a WEBrick::HTTPRequest into a Rack-compatible env hash.
129
+ # Uses req.header (a Hash with lowercase keys) rather than each_header,
130
+ # which does not exist on WEBrick::HTTPRequest.
131
+ def rack_env_from(req)
132
+ body_str = req.body || ""
133
+
134
+ env = {
135
+ "REQUEST_METHOD" => req.request_method,
136
+ "SCRIPT_NAME" => "",
137
+ "PATH_INFO" => req.path,
138
+ "QUERY_STRING" => req.query_string || "",
139
+ "SERVER_NAME" => "127.0.0.1",
140
+ "SERVER_PORT" => @port.to_s,
141
+ "CONTENT_TYPE" => req.content_type || "",
142
+ "CONTENT_LENGTH" => body_str.bytesize.to_s,
143
+ "rack.input" => StringIO.new(body_str),
144
+ "rack.errors" => $stderr,
145
+ "rack.multithread" => true,
146
+ "rack.multiprocess" => false,
147
+ "rack.run_once" => false,
148
+ "rack.url_scheme" => "http"
149
+ }
150
+
151
+ # req.header is a Hash of { "lowercase-name" => ["value"] }
152
+ req.header.each do |key, values|
153
+ rack_key = key.upcase.tr("-", "_")
154
+ next if %w[CONTENT_TYPE CONTENT_LENGTH].include?(rack_key)
155
+
156
+ env["HTTP_#{rack_key}"] = Array(values).join(", ")
157
+ end
158
+
159
+ env
160
+ end
161
+
162
+ def json_response(status, payload)
163
+ [status.to_i, { "Content-Type" => "application/json" }, [JSON.generate(payload)]]
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpFake
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpFake
4
+ # Manages the WebMock stub that routes a declared base_url to the server's Rack app.
5
+ #
6
+ # Auto-detect: if WebMock is loaded and HttpFake.configuration.auto_intercept is true,
7
+ # requests to the declared base_url are intercepted transparently.
8
+ #
9
+ # Teardown removes only the stub httpfake created — never calls WebMock.reset!.
10
+ # If WebMock/RSpec has already cleared the registry (its own after(:each) hook),
11
+ # we rescue silently rather than crashing.
12
+ module WebMockIntegration
13
+ class << self
14
+ def available?
15
+ defined?(WebMock) && WebMock.respond_to?(:stub_request)
16
+ end
17
+
18
+ # Install an interception stub for the given server.
19
+ # Returns the stub object so it can be removed precisely during teardown.
20
+ def setup(server)
21
+ return nil unless available? && HttpFake.configuration.auto_intercept
22
+ return nil unless server.route_map.declared_base_url
23
+
24
+ # Match the full base URL (scheme + host) so the regex anchors correctly.
25
+ # e.g. "https://api.stripe.com" → /\Ahttps:\/\/api\.stripe\.com/
26
+ base = server.route_map.declared_base_url.chomp("/")
27
+ pattern = /\A#{Regexp.escape(base)}/
28
+
29
+ WebMock.stub_request(:any, pattern).to_rack(server.rack_app)
30
+ end
31
+
32
+ # Remove only the stub we created.
33
+ # Rescues silently if webmock/rspec already cleared the registry between examples.
34
+ def teardown(stub)
35
+ return unless stub && available?
36
+
37
+ WebMock::StubRegistry.instance.remove_request_stub(stub)
38
+ rescue RuntimeError
39
+ # Already removed by WebMock.reset! (e.g. webmock/rspec after(:each) hook). Fine.
40
+ end
41
+ end
42
+ end
43
+ end
data/lib/httpfake.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "httpfake/version"
4
+ require_relative "httpfake/configuration"
5
+ require_relative "httpfake/route"
6
+ require_relative "httpfake/route_map"
7
+ require_relative "httpfake/router"
8
+ require_relative "httpfake/request_log"
9
+ require_relative "httpfake/handler_context"
10
+ require_relative "httpfake/server"
11
+ require_relative "httpfake/webmock_integration"
12
+
13
+ module HttpFake
14
+ class << self
15
+ # Global configuration.
16
+ #
17
+ # HttpFake.configure do |c|
18
+ # c.auto_intercept = false # opt out of WebMock auto-interception
19
+ # end
20
+ def configure
21
+ yield configuration
22
+ end
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ # Define a named fake service.
29
+ #
30
+ # FakeStripe = HttpFake.define(:stripe) do
31
+ # base_url "https://api.stripe.com"
32
+ #
33
+ # post "/v1/charges" do
34
+ # requires_body :amount, :currency, :payment_method
35
+ # respond 200, json: { id: -> { "ch_#{SecureRandom.hex(8)}" } }
36
+ # end
37
+ # end
38
+ #
39
+ # RSpec.configure { |c| c.include FakeStripe.rspec_helpers }
40
+ #
41
+ def define(name = :default, &)
42
+ require_relative "httpfake/rspec"
43
+ route_map = RouteMap.new
44
+ route_map.instance_eval(&)
45
+ Definition.new(name, route_map)
46
+ end
47
+ end
48
+ end
data/sig/httpfake.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Httpfake
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: http_decoy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jibran Usman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webrick
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.8'
41
+ description: httpfake spins up a real Rack server inside your tests with a clean DSL.
42
+ Define routes, validate request contracts, return dynamic fixtures, and tear down
43
+ automatically. No VCR cassettes. No scattered WebMock stubs.
44
+ email:
45
+ - jibran.usman@hotmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - ".rubocop.yml"
51
+ - CHANGELOG.md
52
+ - CONTRIBUTING.md
53
+ - LICENSE
54
+ - README.md
55
+ - Rakefile
56
+ - lib/httpfake.rb
57
+ - lib/httpfake/configuration.rb
58
+ - lib/httpfake/handler_context.rb
59
+ - lib/httpfake/request_log.rb
60
+ - lib/httpfake/route.rb
61
+ - lib/httpfake/route_map.rb
62
+ - lib/httpfake/router.rb
63
+ - lib/httpfake/rspec.rb
64
+ - lib/httpfake/server.rb
65
+ - lib/httpfake/version.rb
66
+ - lib/httpfake/webmock_integration.rb
67
+ - sig/httpfake.rbs
68
+ homepage: https://github.com/jibranusman95/httpfake
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ homepage_uri: https://github.com/jibranusman95/httpfake
73
+ source_code_uri: https://github.com/jibranusman95/httpfake
74
+ changelog_uri: https://github.com/jibranusman95/httpfake/blob/main/CHANGELOG.md
75
+ rubygems_mfa_required: 'true'
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.1.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.5.22
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: Declarative fake HTTP servers for RSpec. Real server. Real requests. Zero
95
+ cassettes.
96
+ test_files: []