http_resource 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d9fe8b0aeb930b24273f354fa958c855392cbc7169178aff012fc55c9349bc0
4
- data.tar.gz: c59a5d2b61d6b759f494fc15619b14a26f1040425c6d1d9eb43c90a67218e2d8
3
+ metadata.gz: 316f4111914f2024aa5e42083d0305a68f344a3bb5c0beb18b84d5e61228186a
4
+ data.tar.gz: 8851400609748c96c0380c5b0c9da199e41b988837bf79971dd5eba9196c52af
5
5
  SHA512:
6
- metadata.gz: 882a09948d72787115e9dde41bf305269355f16bc431e929e2b13d7d28372a48fbaa695682b0e73be030412d1e402cde6fe339d946db1991317e3e19be4e16d7
7
- data.tar.gz: e12dcb9fbb52ea78a319a1fa19c27f906a88c66079e03f0ec3f97dc358e7dafc48772a329509934c24cc9475809d58915d81614c8c06a10e75af886b9a672868
6
+ metadata.gz: 36d4d4964057b673e712eebecd63594fbc222c3a6fe604f853f00c2e3af595ae55417a7701df6b6b6214e3b9ab389e083c2641a38b10aadd0b8f3e6fbaed9ea2
7
+ data.tar.gz: 9905e5472ea9ec3002ad82f4893e28b6a63f9672389f5a1c767da1e25f57b54cece01d797043488628c3bbeed173f8f40d982774818ceb2101bd917dfbfed27a
data/README.md CHANGED
@@ -159,6 +159,68 @@ than silently dropping a write.
159
159
  missing keys to `nil`. Guarding `data && Contact.from(data)` means an empty 2xx
160
160
  yields `nil`, not a ghost value object.
161
161
 
162
+ ## Simulation mode (test without a backend)
163
+
164
+ Opt-in, in-memory stand-in for the transport: your resource proxies, value
165
+ objects and error handling run **unchanged** against seeded state — no server,
166
+ no WebMock stubs. Nothing is loaded unless you ask:
167
+ `require "http_resource"` never pulls simulation in; the `simulation:` kwarg does.
168
+
169
+ ```ruby
170
+ # 1. In your client gem: subclass the backend and register path handlers on it.
171
+ class MyGem::Simulation::Backend < HttpResource::Simulation::Backend; end
172
+
173
+ class ContactsHandler
174
+ def initialize(store) = @store = store
175
+
176
+ def call(verb, segments, payload:, params:)
177
+ contact = @store[:contacts].find { _1["id"].to_s == segments.first }
178
+ raise HttpResource::ApiError.for_status("not found", status: 404, body: nil) unless contact
179
+
180
+ contact
181
+ end
182
+ end
183
+ MyGem::Simulation::Backend.register("api/contacts", ContactsHandler)
184
+
185
+ # 2. Build a simulated client — or inject a prepared backend instance.
186
+ client = HttpResource::Client.new(base_url: "https://irrelevant.test", simulation: true)
187
+ client.simulation.seed(contacts: [{ id: 1, email: "a@b.se" }])
188
+
189
+ client.get(["api", "contacts", 1]) # => { "id" => 1, "email" => "a@b.se" } — no network
190
+ client.get(["api", "contacts", 9]) # => raises NotFoundError (from your handler)
191
+
192
+ # 3. Inject failures to exercise error paths.
193
+ client.simulation.fail_next(status: 422, body: '{"errors":["bad"]}', on: "contacts")
194
+ ```
195
+
196
+ The pieces:
197
+
198
+ - **`Simulation::Backend`** — same verb surface as `Client` (signatures,
199
+ `params:`/`form:`/timeouts) and the same deterministic-bug guards: blank/dot
200
+ path segments, URI-invalid String paths, payload+`form:` together, and
201
+ unserializable payloads raise exactly as they would in production, so
202
+ simulation can't mask a caller bug. Handlers see **what a real server sees**:
203
+ JSON payloads parsed (string keys), form bodies and query params as decoded
204
+ string pairs (`params: {}` converges to `nil`, as on the wire). Responses
205
+ come back as **fresh parsed-JSON copies** — mutating one can't corrupt the
206
+ store. An unhandled path raises a loud 501 `ServerError` (never `nil`).
207
+ - **Per-subclass registry** — `register(prefix, handler_class)` stores on the
208
+ receiving subclass; lookup walks the inheritance chain (nearest class with a
209
+ match wins, longest whole-segment prefix within it). Two client gems can
210
+ never leak handlers into each other. Handlers are `HandlerClass.new(store)`,
211
+ memoized per backend instance.
212
+ - **`Simulation::Store`** — collection-agnostic: `store[:anything]`
213
+ auto-vivifies, `next_id(:collection)` allocates per-collection Integer ids
214
+ (seeding an explicit id reserves it; duplicates raise), `deep_stringify` is
215
+ public for handlers building records from payloads.
216
+ - **`fail_next(status:, body:, on:)`** — FIFO one-shot failure injection,
217
+ checked before handler lookup; `on:` scopes it to paths containing the
218
+ (slash-normalized) fragment. `reset!` clears store + injections + handlers.
219
+ - **Client seam** — `simulation: true` instantiates
220
+ `self.class.simulation_backend_class` (override in your Client subclass);
221
+ `simulation: backend_instance` injects a prepared one. `client.simulation`
222
+ returns the backend (`nil` in real mode).
223
+
162
224
  ## Error hierarchy
163
225
 
164
226
  Every failure is an `HttpResource::ApiError` carrying `#status` (an `Integer`, or
@@ -247,6 +309,14 @@ adversarial spec (`spec/escape_safety_spec.rb`).
247
309
 
248
310
  ## Changelog
249
311
 
312
+ ### 0.3.0
313
+
314
+ - Add opt-in **simulation mode**: `HttpResource::Simulation::Backend`/`Store` +
315
+ a `simulation:` kwarg on `Client` — exercise a client with no live backend
316
+ and no stubs. Per-subclass handler registries, collection-agnostic seeded
317
+ store, FIFO failure injection, full transport parity (same guards, same
318
+ typed errors). Lazy-loaded: plain `require "http_resource"` is unchanged.
319
+
250
320
  ### 0.2.0
251
321
 
252
322
  - Add a `form:` keyword to `post`/`put`/`patch` for `application/x-www-form-urlencoded`
@@ -32,14 +32,27 @@ module HttpResource
32
32
 
33
33
  attr_reader :base_url
34
34
 
35
+ # The simulation backend when built with simulation:; nil in real mode.
36
+ attr_reader :simulation
37
+
38
+ # The Backend class `simulation: true` instantiates. Client-gem subclasses
39
+ # override this to point at their own Backend subclass (with its own
40
+ # registered handlers). Only called after the lazy require, so the
41
+ # constant resolves.
42
+ def self.simulation_backend_class
43
+ Simulation::Backend
44
+ end
45
+
35
46
  def initialize(base_url:, auth: nil, username: nil, password: nil,
36
- open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
47
+ open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
48
+ simulation: nil)
37
49
  raise ConfigurationError, "base_url is required" if blank?(base_url)
38
50
 
39
51
  @base_url = base_url.to_s.sub(%r{/+\z}, "")
40
52
  @auth = auth || default_auth(username, password)
41
53
  @open_timeout = open_timeout
42
54
  @read_timeout = read_timeout
55
+ @simulation = setup_simulation(simulation)
43
56
  end
44
57
 
45
58
  # Low-level REST verbs. `path` may be a String ("/api/foo") sent verbatim, or
@@ -78,6 +91,11 @@ module HttpResource
78
91
  private
79
92
 
80
93
  def request(method, path, body: nil, form: nil, params: nil, open_timeout: nil, read_timeout: nil)
94
+ # In simulation the verb call is handed to the backend BEFORE any URI
95
+ # build or network I/O — the backend enforces the same deterministic-bug
96
+ # guards, so a call that raises in production raises identically here.
97
+ return simulate(method, path, body:, form:, params:) if @simulation
98
+
81
99
  # Build the URI + request OUTSIDE the network rescue: a URI::InvalidURIError
82
100
  # (bad path), JSON::GeneratorError (un-serializable payload, e.g. a NaN
83
101
  # amount) or an ArgumentError (both a JSON and a form body) is a
@@ -182,6 +200,27 @@ module HttpResource
182
200
  body
183
201
  end
184
202
 
203
+ def simulate(method, path, body:, form:, params:)
204
+ # The public verbs only pass params: on get; reaching this with params
205
+ # on another verb means private-seam misuse — fail loud, don't drop it.
206
+ raise ArgumentError, "params: is only supported on get in simulation" if params && method != :get
207
+
208
+ case method
209
+ when :get then @simulation.get(path, params:)
210
+ when :delete then @simulation.delete(path)
211
+ else @simulation.public_send(method, path, body, form:)
212
+ end
213
+ end
214
+
215
+ # Lazy: the simulation machinery is test-facing and never loaded unless
216
+ # asked for — plain `require "http_resource"` must not pull it in.
217
+ def setup_simulation(simulation)
218
+ return nil unless simulation
219
+
220
+ require "http_resource/simulation"
221
+ simulation == true ? self.class.simulation_backend_class.new : simulation
222
+ end
223
+
185
224
  def default_auth(username, password)
186
225
  return nil if blank?(username) && blank?(password)
187
226
 
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "http_resource/errors"
6
+ require "http_resource/simulation/store"
7
+
8
+ module HttpResource
9
+ module Simulation
10
+ # In-memory stand-in for Client's transport. Same verb surface (signatures
11
+ # including params:/form:/timeouts), same deterministic-bug guards, and it
12
+ # answers with parsed-JSON-shaped Hashes (string keys) or nil, or raises
13
+ # via ApiError.for_status — so every resource proxy, value object and
14
+ # error path above it runs UNCHANGED against seeded in-memory state.
15
+ #
16
+ # Client gems SUBCLASS Backend and let handlers self-register on the
17
+ # subclass at file load:
18
+ #
19
+ # class MyGem::Simulation::Backend < HttpResource::Simulation::Backend; end
20
+ # MyGem::Simulation::Backend.register("api/contacts", ContactsHandler)
21
+ #
22
+ # Each subclass owns its OWN registry (one gem's handlers can never leak
23
+ # into another's); lookup walks the inheritance chain — the NEAREST class
24
+ # with any matching prefix wins (a subclass registration shadows the
25
+ # parent's wholesale), longest whole-segment prefix within that class.
26
+ # Handlers are HandlerClass.new(store), memoized per backend instance, and
27
+ # receive handler.call(verb, segments, payload:, params:).
28
+ class Backend
29
+ class << self
30
+ def register(prefix, handler_class)
31
+ registry[prefix_segments(prefix)] = handler_class
32
+ end
33
+
34
+ # This class's OWN registrations ({segments => handler_class}), NOT
35
+ # including inherited ones — dispatch walks the ancestry explicitly.
36
+ def registry
37
+ @registry ||= {}
38
+ end
39
+
40
+ private
41
+
42
+ def prefix_segments(prefix)
43
+ prefix.to_s.gsub(%r{\A/+|/+\z}, "").split("/").freeze
44
+ end
45
+ end
46
+
47
+ attr_reader :store
48
+
49
+ def initialize
50
+ @store = Store.new
51
+ @handlers = {}
52
+ @injections = []
53
+ end
54
+
55
+ # The timeout kwargs are accepted AND ignored on purpose: the surface
56
+ # must match Client's verbs exactly so calling code runs unchanged.
57
+ # rubocop:disable Lint/UnusedMethodArgument
58
+ def get(path, params: nil, open_timeout: nil, read_timeout: nil)
59
+ dispatch(:get, path, params: normalize_params(params))
60
+ end
61
+
62
+ def post(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
63
+ dispatch(:post, path, payload:, form:)
64
+ end
65
+
66
+ def put(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
67
+ dispatch(:put, path, payload:, form:)
68
+ end
69
+
70
+ def patch(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
71
+ dispatch(:patch, path, payload:, form:)
72
+ end
73
+
74
+ def delete(path, open_timeout: nil, read_timeout: nil)
75
+ dispatch(:delete, path)
76
+ end
77
+ # rubocop:enable Lint/UnusedMethodArgument
78
+
79
+ def seed(collections)
80
+ @store.seed(collections)
81
+ end
82
+
83
+ # Queue a one-shot failure: the next call whose normalized path contains
84
+ # `on:` (any call when omitted) raises the mapped ApiError, then the
85
+ # injection is consumed. Non-matching injections are skipped over and
86
+ # STAY queued (FIFO among matches). `body:` is coerced to the String a
87
+ # real transport error carries (a Hash/Array is JSON-encoded).
88
+ def fail_next(status:, body: nil, on: nil)
89
+ @injections << { status:, body: string_body(body), on: normalize_on(on) }
90
+ nil
91
+ end
92
+
93
+ def reset!
94
+ @store.reset!
95
+ @handlers.clear
96
+ @injections.clear
97
+ nil
98
+ end
99
+
100
+ private
101
+
102
+ # Guard order mirrors production exactly: path first (build_uri), body
103
+ # second (build_request), only then the "network" (injections + handler)
104
+ # — so a doubly-broken call raises the SAME error in both modes.
105
+ def dispatch(verb, path, payload: nil, form: nil, params: nil)
106
+ segments = normalize_path(path)
107
+ body = body_for(payload, form)
108
+ consume_injection!(segments.join("/"))
109
+
110
+ prefix, handler_class = match(segments)
111
+ unless handler_class
112
+ raise ApiError.for_status("no simulation handler for #{verb.to_s.upcase} /#{segments.join('/')}",
113
+ status: 501, body: nil)
114
+ end
115
+
116
+ # Memoized on the RESOLVED class (not the prefix), so a same-prefix
117
+ # registration on a nearer class can never serve a stale instance.
118
+ handler = (@handlers[handler_class] ||= handler_class.new(@store))
119
+ result = handler.call(verb, segments.drop(prefix.size), payload: body, params:)
120
+ # The response is a FRESH parsed-JSON copy, like a real body parse:
121
+ # callers can't corrupt the store by mutating it, and a handler's
122
+ # symbol keys are normalized to the contract's string keys.
123
+ result.nil? ? nil : JSON.parse(JSON.generate(result))
124
+ end
125
+
126
+ # Same guard as the real transport (Client#apply_body), and the payload
127
+ # reaches the handler the way a REAL server would see it: a JSON payload
128
+ # as its parsed-JSON form (string keys; an unserializable payload raises
129
+ # JSON::GeneratorError exactly like the real build_request), a form
130
+ # payload as the string pairs a form decoder yields (values to_s'd,
131
+ # repeated keys last-wins, matching a bare-key Rack parse).
132
+ def body_for(payload, form)
133
+ raise ArgumentError, "pass either a JSON payload or form:, not both" if payload && form
134
+
135
+ if form
136
+ URI.decode_www_form(URI.encode_www_form(form)).to_h
137
+ elsif payload
138
+ JSON.parse(JSON.generate(payload))
139
+ end
140
+ end
141
+
142
+ # Array segments arrive raw (the backend replaces the transport BEFORE
143
+ # any percent-encoding) but pass the SAME validation as the real
144
+ # encoder: blank and dot-segments are caller bugs there and stay caller
145
+ # bugs here. A String path may carry encoded bytes, so its segments are
146
+ # decoded to what a server-side router sees; a malformed %-escape raises
147
+ # URI::InvalidURIError, exactly as the real URI build would.
148
+ def normalize_path(path)
149
+ if path.is_a?(Array)
150
+ path.map { validate_segment(_1) }
151
+ else
152
+ stripped = path.to_s.gsub(%r{\A/+|/+\z}, "")
153
+ validate_string_path(stripped)
154
+ stripped.split("/").map { decode_segment(_1) }
155
+ end
156
+ end
157
+
158
+ # Parity: the real transport URI.parses the String path, so a
159
+ # URI-invalid character (raw space, "|", a malformed %-escape, …) raises
160
+ # URI::InvalidURIError there — it must raise here too. Note simulation
161
+ # treats a String as a PURE path (no query/fragment splitting).
162
+ def validate_string_path(stripped)
163
+ URI.parse("http://sim.invalid/#{stripped}")
164
+ rescue URI::InvalidURIError
165
+ raise URI::InvalidURIError, "invalid String path for simulation: #{stripped.inspect}"
166
+ end
167
+
168
+ # Query params reach the handler the way a real server sees them:
169
+ # string pairs (repeated keys last-wins), with an empty hash — which
170
+ # produces no query string on the wire — converging to nil, exactly
171
+ # like build_uri's `params && !params.empty?` guard.
172
+ def normalize_params(params)
173
+ return nil if params.nil? || params.empty?
174
+
175
+ URI.decode_www_form(URI.encode_www_form(params)).to_h
176
+ end
177
+
178
+ def validate_segment(segment)
179
+ str = segment.to_s
180
+ raise ArgumentError, "path segment may not be blank" if str.empty?
181
+ raise ArgumentError, "path segment may not be a '.' or '..' dot-segment" if [".", ".."].include?(str)
182
+
183
+ str
184
+ end
185
+
186
+ def decode_segment(segment)
187
+ URI.decode_uri_component(segment)
188
+ rescue ArgumentError
189
+ raise URI::InvalidURIError, "malformed percent-encoding in path segment #{segment.inspect}"
190
+ end
191
+
192
+ # Nearest class with ANY match wins; longest prefix within that class.
193
+ def match(segments)
194
+ klass = self.class
195
+ while klass.respond_to?(:registry)
196
+ hit = klass.registry
197
+ .select { |prefix, _| segments.first(prefix.size) == prefix }
198
+ .max_by { |prefix, _| prefix.size }
199
+ return hit if hit
200
+
201
+ klass = klass.superclass
202
+ end
203
+ nil
204
+ end
205
+
206
+ def consume_injection!(joined_path)
207
+ index = @injections.index { _1[:on].nil? || joined_path.include?(_1[:on]) }
208
+ return unless index
209
+
210
+ injection = @injections.delete_at(index)
211
+ raise ApiError.for_status("injected failure", status: injection[:status], body: injection[:body])
212
+ end
213
+
214
+ def normalize_on(on)
215
+ on&.to_s&.gsub(%r{\A/+|/+\z}, "")
216
+ end
217
+
218
+ def string_body(body)
219
+ return body if body.nil? || body.is_a?(String)
220
+
221
+ JSON.generate(body)
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpResource
4
+ module Simulation
5
+ # Per-Backend in-memory record store. Collection-agnostic: ANY name
6
+ # auto-vivifies an empty collection on first access. Records are Hashes
7
+ # with STRING keys — they stand in for parsed JSON, so the value objects
8
+ # and resource proxies above read them exactly as they would a live
9
+ # response body.
10
+ class Store
11
+ def initialize
12
+ @collections = Hash.new { |hash, key| hash[key] = [] }
13
+ @sequences = Hash.new(0)
14
+ end
15
+
16
+ # store[:contacts] — the collection's Array (auto-vivified, live
17
+ # reference: handlers mutate it in place).
18
+ def [](collection)
19
+ @collections[collection.to_sym]
20
+ end
21
+
22
+ # Allocate the next Integer id for a collection. MUTATING — every call
23
+ # consumes an id. Never call it from a test assertion; read the
24
+ # records' "id" values instead.
25
+ def next_id(collection)
26
+ @sequences[collection.to_sym] += 1
27
+ end
28
+
29
+ # Append records under ANY collection names, assigning "id" via next_id
30
+ # when absent: seed(contacts: [...], "invoices" => [...]). Symbol- or
31
+ # string-keyed records; all keys are normalized (deeply) to strings.
32
+ def seed(collections)
33
+ collections.each do |name, records|
34
+ unless records.is_a?(Array)
35
+ raise ArgumentError,
36
+ "records for #{name.inspect} must be an Array of Hashes, got #{records.class}"
37
+ end
38
+
39
+ records.each { append(name, _1) }
40
+ end
41
+ nil
42
+ end
43
+
44
+ # Clears the collections IN PLACE, so previously-obtained collection
45
+ # references (see #[]) stay live across a reset.
46
+ def reset!
47
+ @collections.each_value(&:clear)
48
+ @sequences.clear
49
+ nil
50
+ end
51
+
52
+ # Public because handlers need the same JSON-shaping for records they
53
+ # build from request payloads.
54
+ def deep_stringify(value)
55
+ case value
56
+ when Hash then value.to_h { |key, val| [key.to_s, deep_stringify(val)] }
57
+ when Array then value.map { deep_stringify(_1) }
58
+ else value
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def append(collection, record)
65
+ record = deep_stringify(record)
66
+ if record["id"]
67
+ reserve_id(collection, record["id"])
68
+ else
69
+ record["id"] = next_id(collection)
70
+ end
71
+ self[collection] << record
72
+ end
73
+
74
+ # An explicitly seeded id is RESERVED: it must be unique within its
75
+ # collection — compared as STRINGS, the way handlers look ids up from
76
+ # path segments — and a duplicate is a test-authoring bug that raises.
77
+ # The sequence advances past integer-LIKE ids (5 and "5" alike) so a
78
+ # later next_id can never hand out an id that aliases a reserved one.
79
+ # Non-numeric String ids (UUIDs…) are kept as-is.
80
+ def reserve_id(collection, id)
81
+ raise ArgumentError, "duplicate id #{id.inspect} in #{collection.inspect}" if
82
+ self[collection].any? { _1["id"].to_s == id.to_s }
83
+
84
+ key = collection.to_sym
85
+ @sequences[key] = [@sequences[key], id.to_i].max if id.to_s.match?(/\A\d+\z/)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test-facing, opt-in machinery: exercise an http_resource-based client with NO
4
+ # live backend. NOT loaded by `require "http_resource"` — Client requires this
5
+ # file lazily, only when built with a truthy `simulation:` kwarg.
6
+ require "http_resource/simulation/store"
7
+ require "http_resource/simulation/backend"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpResource
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http_resource
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Skiftet
@@ -32,6 +32,9 @@ files:
32
32
  - lib/http_resource/configuration.rb
33
33
  - lib/http_resource/errors.rb
34
34
  - lib/http_resource/resource.rb
35
+ - lib/http_resource/simulation.rb
36
+ - lib/http_resource/simulation/backend.rb
37
+ - lib/http_resource/simulation/store.rb
35
38
  - lib/http_resource/value_object.rb
36
39
  - lib/http_resource/version.rb
37
40
  homepage: https://github.com/Skiftet/http_resource