http_resource 0.1.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 +118 -0
- data/lib/http_resource/client.rb +89 -22
- 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
|
@@ -54,6 +54,7 @@ client = HttpResource::Client.new(
|
|
|
54
54
|
|
|
55
55
|
client.get(["api", "contacts", id]) # GET, id escaped as ONE path segment
|
|
56
56
|
client.post(["api", "actions"], { foo: 1 }) # POST a JSON body
|
|
57
|
+
client.put(["api", "contacts", id], { name: "Anna" })
|
|
57
58
|
client.patch(["api", "contacts", email], { name: "Anna" })
|
|
58
59
|
client.delete(["api", "contacts", id])
|
|
59
60
|
```
|
|
@@ -65,6 +66,37 @@ Reads return parsed JSON (a `Hash`/`Array`, or `nil` on an empty body). Every
|
|
|
65
66
|
call raises an `HttpResource::ApiError` subclass on a non-2xx response or a
|
|
66
67
|
transport failure.
|
|
67
68
|
|
|
69
|
+
### Form-encoded bodies (OAuth)
|
|
70
|
+
|
|
71
|
+
`post`/`put`/`patch` take a `form:` keyword to send an
|
|
72
|
+
`application/x-www-form-urlencoded` body instead of JSON — for the form-encoded
|
|
73
|
+
endpoints OAuth consumers hit (RFC 6749 token, RFC 7662 introspection, …):
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
token = client.post(["oauth", "token"], form: {
|
|
77
|
+
grant_type: "client_credentials",
|
|
78
|
+
scope: "read write"
|
|
79
|
+
})
|
|
80
|
+
token["access_token"] # a 2xx still returns parsed JSON
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The response side is identical to a JSON call — parsed JSON on a 2xx, a typed
|
|
84
|
+
`ApiError` on a non-2xx — so an OAuth failure is just a rescue-able error whose
|
|
85
|
+
`#body` carries the payload:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
begin
|
|
89
|
+
client.post(["oauth", "token"], form: { grant_type: "authorization_code", code: bad })
|
|
90
|
+
rescue HttpResource::ClientError => e
|
|
91
|
+
e.status # => 400
|
|
92
|
+
JSON.parse(e.body)["error"] # => "invalid_grant"
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Pass **either** a JSON `payload` **or** `form:`, never both (it raises
|
|
97
|
+
`ArgumentError`). Form keys/values are percent-encoded, so untrusted input can't
|
|
98
|
+
inject a header or an extra field.
|
|
99
|
+
|
|
68
100
|
### A process-wide default client
|
|
69
101
|
|
|
70
102
|
```ruby
|
|
@@ -127,6 +159,68 @@ than silently dropping a write.
|
|
|
127
159
|
missing keys to `nil`. Guarding `data && Contact.from(data)` means an empty 2xx
|
|
128
160
|
yields `nil`, not a ghost value object.
|
|
129
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
|
+
|
|
130
224
|
## Error hierarchy
|
|
131
225
|
|
|
132
226
|
Every failure is an `HttpResource::ApiError` carrying `#status` (an `Integer`, or
|
|
@@ -213,6 +307,30 @@ A **`String`** path is the trusted escape hatch and is sent **verbatim** — so
|
|
|
213
307
|
let the framework encode it. The guarantee is covered by a dedicated,
|
|
214
308
|
adversarial spec (`spec/escape_safety_spec.rb`).
|
|
215
309
|
|
|
310
|
+
## Changelog
|
|
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
|
+
|
|
320
|
+
### 0.2.0
|
|
321
|
+
|
|
322
|
+
- Add a `form:` keyword to `post`/`put`/`patch` for `application/x-www-form-urlencoded`
|
|
323
|
+
bodies (OAuth token/introspection and other form-encoded endpoints). Responses
|
|
324
|
+
stay resty: parsed JSON on a 2xx, a typed `ApiError` (with `#body`) on a non-2xx.
|
|
325
|
+
- Add a first-class `put` verb (JSON or `form:` body).
|
|
326
|
+
- Passing both a JSON `payload` and `form:` raises `ArgumentError`; the bodyless
|
|
327
|
+
verbs (`get`/`delete`) reject `form:`.
|
|
328
|
+
|
|
329
|
+
### 0.1.0
|
|
330
|
+
|
|
331
|
+
- Initial release: Net::HTTP transport, typed `ApiError` hierarchy, bang/non-bang
|
|
332
|
+
resources, pluggable auth, per-call timeouts, escape-safe URL building.
|
|
333
|
+
|
|
216
334
|
## Development
|
|
217
335
|
|
|
218
336
|
```sh
|
data/lib/http_resource/client.rb
CHANGED
|
@@ -8,13 +8,14 @@ require "openssl"
|
|
|
8
8
|
|
|
9
9
|
module HttpResource
|
|
10
10
|
# Net::HTTP transport for a single REST host. Resource-oriented: the verbs
|
|
11
|
-
# (get/post/patch/delete) are the primitives a Resource is built on, and
|
|
12
|
-
# an escape hatch for endpoints not yet modelled.
|
|
11
|
+
# (get/post/put/patch/delete) are the primitives a Resource is built on, and
|
|
12
|
+
# also an escape hatch for endpoints not yet modelled.
|
|
13
13
|
#
|
|
14
14
|
# client = HttpResource::Client.new(base_url: "https://api.example.org",
|
|
15
15
|
# auth: HttpResource::Auth.bearer(token))
|
|
16
|
-
# client.get(["api", "contacts", id])
|
|
17
|
-
# client.post(["api", "actions"], { ... })
|
|
16
|
+
# client.get(["api", "contacts", id]) # GET, id escaped as one segment
|
|
17
|
+
# client.post(["api", "actions"], { ... }) # POST a JSON body
|
|
18
|
+
# client.post(["oauth", "token"], form: { ... }) # POST a form body (OAuth, RFC 6749)
|
|
18
19
|
#
|
|
19
20
|
# Reads return parsed JSON (a Hash/Array, or nil on an empty body). Every call
|
|
20
21
|
# raises an HttpResource::ApiError subclass on a non-2xx response or a transport
|
|
@@ -31,45 +32,77 @@ module HttpResource
|
|
|
31
32
|
|
|
32
33
|
attr_reader :base_url
|
|
33
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
|
+
|
|
34
46
|
def initialize(base_url:, auth: nil, username: nil, password: nil,
|
|
35
|
-
open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT
|
|
47
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
|
|
48
|
+
simulation: nil)
|
|
36
49
|
raise ConfigurationError, "base_url is required" if blank?(base_url)
|
|
37
50
|
|
|
38
51
|
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
39
52
|
@auth = auth || default_auth(username, password)
|
|
40
53
|
@open_timeout = open_timeout
|
|
41
54
|
@read_timeout = read_timeout
|
|
55
|
+
@simulation = setup_simulation(simulation)
|
|
42
56
|
end
|
|
43
57
|
|
|
44
58
|
# Low-level REST verbs. `path` may be a String ("/api/foo") sent verbatim, or
|
|
45
59
|
# an Array of segments (["api", "contacts", email]) each individually escaped.
|
|
46
60
|
# Each verb accepts open_timeout:/read_timeout: to override the client's
|
|
47
61
|
# budget for that one call (e.g. a short read_timeout on a synchronous read).
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
#
|
|
63
|
+
# The body-bearing verbs (post/put/patch) send EITHER a JSON body — the
|
|
64
|
+
# positional `payload` — or a form body — `form: {...}`, encoded as
|
|
65
|
+
# application/x-www-form-urlencoded (for the form-encoded endpoints OAuth
|
|
66
|
+
# consumers hit: RFC 6749 token, RFC 7662 introspection, …). Passing both is
|
|
67
|
+
# a caller bug and raises ArgumentError. The response side is identical either
|
|
68
|
+
# way: parsed JSON on a 2xx, a typed ApiError (with #status + #body) on a
|
|
69
|
+
# non-2xx — so a "400 invalid_grant" is a rescue-able ClientError whose #body
|
|
70
|
+
# carries the error payload.
|
|
71
|
+
def get(path, params: nil, open_timeout: nil, read_timeout: nil)
|
|
72
|
+
request(:get, path, params:, open_timeout:, read_timeout:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def post(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
|
|
76
|
+
request(:post, path, body: payload, form:, open_timeout:, read_timeout:)
|
|
50
77
|
end
|
|
51
78
|
|
|
52
|
-
def
|
|
53
|
-
request(:
|
|
79
|
+
def put(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
|
|
80
|
+
request(:put, path, body: payload, form:, open_timeout:, read_timeout:)
|
|
54
81
|
end
|
|
55
82
|
|
|
56
|
-
def patch(path, payload = nil,
|
|
57
|
-
request(:patch, path, body: payload,
|
|
83
|
+
def patch(path, payload = nil, form: nil, open_timeout: nil, read_timeout: nil)
|
|
84
|
+
request(:patch, path, body: payload, form:, open_timeout:, read_timeout:)
|
|
58
85
|
end
|
|
59
86
|
|
|
60
|
-
def delete(path,
|
|
61
|
-
request(:delete, path,
|
|
87
|
+
def delete(path, open_timeout: nil, read_timeout: nil)
|
|
88
|
+
request(:delete, path, open_timeout:, read_timeout:)
|
|
62
89
|
end
|
|
63
90
|
|
|
64
91
|
private
|
|
65
92
|
|
|
66
|
-
def request(method, path, body: nil, params: nil, open_timeout: nil, read_timeout: nil)
|
|
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
|
+
|
|
67
99
|
# Build the URI + request OUTSIDE the network rescue: a URI::InvalidURIError
|
|
68
|
-
# (bad path)
|
|
69
|
-
# amount)
|
|
70
|
-
#
|
|
100
|
+
# (bad path), JSON::GeneratorError (un-serializable payload, e.g. a NaN
|
|
101
|
+
# amount) or an ArgumentError (both a JSON and a form body) is a
|
|
102
|
+
# deterministic caller bug, and must NOT be masked as a retryable
|
|
103
|
+
# TransportError — that would have a worker retry it forever.
|
|
71
104
|
uri = build_uri(path, params)
|
|
72
|
-
req = build_request(method, uri, body)
|
|
105
|
+
req = build_request(method, uri, body, form)
|
|
73
106
|
connection = http(uri, open_timeout:, read_timeout:)
|
|
74
107
|
begin
|
|
75
108
|
handle(connection.request(req))
|
|
@@ -116,19 +149,32 @@ module HttpResource
|
|
|
116
149
|
ERB::Util.url_encode(str)
|
|
117
150
|
end
|
|
118
151
|
|
|
119
|
-
def build_request(method, uri, body)
|
|
152
|
+
def build_request(method, uri, body, form = nil)
|
|
120
153
|
klass = {
|
|
121
|
-
get: Net::HTTP::Get, post: Net::HTTP::Post,
|
|
154
|
+
get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put,
|
|
122
155
|
patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
|
|
123
156
|
}.fetch(method)
|
|
124
157
|
request = klass.new(uri)
|
|
125
158
|
@auth&.apply(request)
|
|
126
159
|
request["Accept"] = "application/json"
|
|
127
|
-
|
|
160
|
+
apply_body(request, body, form)
|
|
161
|
+
request
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A request carries EITHER a JSON body (`payload`) or a form body (`form:`),
|
|
165
|
+
# never both — passing both is a caller bug. Form values are percent-encoded
|
|
166
|
+
# by URI.encode_www_form (the same encoder used for query params), so an
|
|
167
|
+
# untrusted key/value can't inject a header, a second field, or CRLF.
|
|
168
|
+
def apply_body(request, body, form)
|
|
169
|
+
raise ArgumentError, "pass either a JSON payload or form:, not both" if body && form
|
|
170
|
+
|
|
171
|
+
if form
|
|
172
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
173
|
+
request.body = URI.encode_www_form(form)
|
|
174
|
+
elsif body
|
|
128
175
|
request["Content-Type"] = "application/json"
|
|
129
176
|
request.body = JSON.generate(body)
|
|
130
177
|
end
|
|
131
|
-
request
|
|
132
178
|
end
|
|
133
179
|
|
|
134
180
|
def http(uri, open_timeout: nil, read_timeout: nil)
|
|
@@ -154,6 +200,27 @@ module HttpResource
|
|
|
154
200
|
body
|
|
155
201
|
end
|
|
156
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
|
+
|
|
157
224
|
def default_auth(username, password)
|
|
158
225
|
return nil if blank?(username) && blank?(password)
|
|
159
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
|