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 +4 -4
- data/README.md +70 -0
- data/lib/http_resource/client.rb +40 -1
- data/lib/http_resource/simulation/backend.rb +225 -0
- data/lib/http_resource/simulation/store.rb +89 -0
- data/lib/http_resource/simulation.rb +7 -0
- data/lib/http_resource/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 316f4111914f2024aa5e42083d0305a68f344a3bb5c0beb18b84d5e61228186a
|
|
4
|
+
data.tar.gz: 8851400609748c96c0380c5b0c9da199e41b988837bf79971dd5eba9196c52af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
data/lib/http_resource/client.rb
CHANGED
|
@@ -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"
|
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.
|
|
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
|