http_resource 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/lib/http_resource/auth/basic.rb +18 -0
- data/lib/http_resource/auth/bearer.rb +16 -0
- data/lib/http_resource/auth/header.rb +17 -0
- data/lib/http_resource/auth.rb +28 -0
- data/lib/http_resource/client.rb +167 -0
- data/lib/http_resource/configuration.rb +22 -0
- data/lib/http_resource/errors.rb +92 -0
- data/lib/http_resource/resource.rb +35 -0
- data/lib/http_resource/value_object.rb +47 -0
- data/lib/http_resource/version.rb +5 -0
- data/lib/http_resource.rb +60 -0
- metadata +62 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ae3a2c83649ab50e44f3b85b70e223903788c31b8c1d3e738d87ae4154f1e716
|
|
4
|
+
data.tar.gz: 4807d9724ace0ad3b1cddedaa74c693454c4f1d0b29591a7f655228ea8a09a71
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5b6bffc9f723e5ad0e7c2c15917dd7a18b9064719c5b1c128f3aebe6f1d601390db7b83ed1c7abffe1e047e2293d3ee7f9524c85c0501d3e08ea2d67f05f7e72
|
|
7
|
+
data.tar.gz: bed8b7f03efd23d9f52a543bc07e6b0cd16b1429ce5df9f6373f29ab51fbdc100684720e47fb16fcc4c10d7489db70177a471a0cca67b97ae9f2c1e1147a5e78
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Skiftet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# HttpResource
|
|
2
|
+
|
|
3
|
+
A tiny, **zero-dependency** Ruby framework for building typed REST-resource
|
|
4
|
+
clients on top of `Net::HTTP`.
|
|
5
|
+
|
|
6
|
+
You bring a `base_url` and an auth strategy; HttpResource gives you a transport
|
|
7
|
+
with a **typed error hierarchy**, Rails-style **bang/non-bang resources**,
|
|
8
|
+
**pluggable auth**, **per-call timeouts**, and **escape-safe URL building** so
|
|
9
|
+
untrusted path segments can never escape the protocol.
|
|
10
|
+
|
|
11
|
+
It is the generic core extracted from Skiftet's `mejla_api_client`: a small set
|
|
12
|
+
of proven patterns you would otherwise hand-roll (and get subtly wrong) in every
|
|
13
|
+
service-to-service client.
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
Most hand-rolled HTTP clients get three things wrong:
|
|
18
|
+
|
|
19
|
+
1. **They mask deterministic bugs as retryable failures.** A bad URL or an
|
|
20
|
+
un-serializable payload gets caught by a broad `rescue` and turned into a
|
|
21
|
+
"transport error", so a background worker retries it forever. HttpResource
|
|
22
|
+
builds the request *outside* the network rescue, so those propagate.
|
|
23
|
+
2. **They flatten every failure into one exception.** A 404, a 422 validation
|
|
24
|
+
rejection, an auth failure and a 5xx all need different handling. HttpResource
|
|
25
|
+
maps each to a distinct, rescue-by-parent error class.
|
|
26
|
+
3. **They interpolate untrusted ids straight into URLs.** HttpResource encodes
|
|
27
|
+
each array path segment as a single RFC-3986 path component — see
|
|
28
|
+
[Escape safety](#escape-safety).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Gemfile
|
|
34
|
+
gem "http_resource"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "http_resource"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Ruby >= 3.2. No runtime dependencies.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Build a client
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
client = HttpResource::Client.new(
|
|
49
|
+
base_url: "https://api.example.org",
|
|
50
|
+
auth: HttpResource::Auth.bearer(ENV.fetch("API_TOKEN")),
|
|
51
|
+
open_timeout: 5, # optional, default 5
|
|
52
|
+
read_timeout: 15 # optional, default 15
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
client.get(["api", "contacts", id]) # GET, id escaped as ONE path segment
|
|
56
|
+
client.post(["api", "actions"], { foo: 1 }) # POST a JSON body
|
|
57
|
+
client.patch(["api", "contacts", email], { name: "Anna" })
|
|
58
|
+
client.delete(["api", "contacts", id])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A `String` path is sent verbatim (`client.get("/api/ping")`); an `Array` path
|
|
62
|
+
has each segment percent-encoded (see [Escape safety](#escape-safety)).
|
|
63
|
+
|
|
64
|
+
Reads return parsed JSON (a `Hash`/`Array`, or `nil` on an empty body). Every
|
|
65
|
+
call raises an `HttpResource::ApiError` subclass on a non-2xx response or a
|
|
66
|
+
transport failure.
|
|
67
|
+
|
|
68
|
+
### A process-wide default client
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
HttpResource.configure do |c|
|
|
72
|
+
c.base_url = ENV.fetch("API_URL")
|
|
73
|
+
c.auth = HttpResource::Auth.basic(ENV.fetch("API_USER"), ENV.fetch("API_PASS"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
HttpResource.client.get(["api", "ping"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`HttpResource.build_client(base_url: ..., auth: ...)` builds an independent
|
|
80
|
+
client when you talk to more than one host.
|
|
81
|
+
|
|
82
|
+
### Pluggable auth
|
|
83
|
+
|
|
84
|
+
An auth strategy is any object responding to `#apply(request)`. Three are shipped:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
HttpResource::Auth.basic("user", "pass") # Authorization: Basic <base64>
|
|
88
|
+
HttpResource::Auth.bearer("token") # Authorization: Bearer token
|
|
89
|
+
HttpResource::Auth.header("X-Api-Key", k) # X-Api-Key: k
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Passing `username:`/`password:` (and no `auth:`) defaults to Basic. Bring your
|
|
93
|
+
own strategy for anything else (HMAC signing, refreshing tokens, …).
|
|
94
|
+
|
|
95
|
+
### Resources: the bang/non-bang pattern
|
|
96
|
+
|
|
97
|
+
Subclass `HttpResource::Resource` to map an endpoint to typed verbs. Pair a
|
|
98
|
+
non-bang method (returns `nil` on an expected 404 miss) with a bang method
|
|
99
|
+
(raises on any failure):
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
Contact = Data.define(:email, :name) do
|
|
103
|
+
extend HttpResource::ValueObject # tolerant .from(payload)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class Contacts < HttpResource::Resource
|
|
107
|
+
def find(id) = soft { find!(id) } # nil on 404, raises on anything else
|
|
108
|
+
|
|
109
|
+
def find!(id)
|
|
110
|
+
data = @client.get(["api", "contacts", id])
|
|
111
|
+
data && Contact.from(data) # empty 2xx -> nil, never a ghost object
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
contacts = Contacts.new(client)
|
|
116
|
+
contacts.find("missing") # => nil (404 swallowed)
|
|
117
|
+
contacts.find!("missing") # => raises HttpResource::NotFoundError
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`soft { ... }` swallows **only** an `Expected` failure (a 404) to `nil`.
|
|
121
|
+
Everything else — including a 422 validation rejection on a write — raises even
|
|
122
|
+
from the non-bang form, so a sync job surfaces and retries the failure rather
|
|
123
|
+
than silently dropping a write.
|
|
124
|
+
|
|
125
|
+
`ValueObject#from` returns `nil` for a `nil` payload, unwraps a top-level
|
|
126
|
+
`{ "data" => {...} }` envelope, tolerates string or symbol keys, and defaults
|
|
127
|
+
missing keys to `nil`. Guarding `data && Contact.from(data)` means an empty 2xx
|
|
128
|
+
yields `nil`, not a ghost value object.
|
|
129
|
+
|
|
130
|
+
## Error hierarchy
|
|
131
|
+
|
|
132
|
+
Every failure is an `HttpResource::ApiError` carrying `#status` (an `Integer`, or
|
|
133
|
+
`nil` for transport failures) and `#body`. `ApiError.for_status` maps the HTTP
|
|
134
|
+
status to the most specific class, so you can rescue broadly or narrowly:
|
|
135
|
+
|
|
136
|
+
| Class | Status | `client_error?` | `server_error?` | `Expected` (→ nil) | Meaning |
|
|
137
|
+
|---|---|---|---|---|---|
|
|
138
|
+
| `ApiError` | any / other | by status | by status | no | base for all of the below |
|
|
139
|
+
| `ClientError` | 400–499 | yes | no | no | caller's request won't succeed on retry — drop |
|
|
140
|
+
| `NotFoundError` | 404 | yes | no | **yes** | resource missing; the only swallow-to-nil case |
|
|
141
|
+
| `ValidationError` | 422 | yes | no | no | request rejected; a write must surface, not drop |
|
|
142
|
+
| `AuthError` | 401, 403 | yes | no | no | bad/missing credentials — usually a config bug |
|
|
143
|
+
| `RedirectError` | 300–399 | no | no | no | unfollowed redirect — usually a wrong base_url |
|
|
144
|
+
| `ServerError` | 500–599 | no | yes | no | server failed a valid request — retryable |
|
|
145
|
+
| `TransportError` | nil | no | no | no | network failure before/while talking — retryable |
|
|
146
|
+
| `TimeoutError` | nil | no | no | no | connect/read exceeded the budget (a TransportError) |
|
|
147
|
+
| `ConnectionError` | nil | no | no | no | refused/reset/DNS/TLS (a TransportError) |
|
|
148
|
+
|
|
149
|
+
Because the tree nests, a worker can branch on intent:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
begin
|
|
153
|
+
client.post(["api", "actions"], payload)
|
|
154
|
+
rescue HttpResource::ClientError # 4xx — drop, don't retry
|
|
155
|
+
drop!
|
|
156
|
+
rescue HttpResource::ServerError, # 5xx + transport — retry
|
|
157
|
+
HttpResource::TransportError
|
|
158
|
+
retry_later!
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`ConfigurationError` (a sibling of `ApiError` under `Error`) is raised eagerly
|
|
163
|
+
for a blank `base_url` — never on the network path.
|
|
164
|
+
|
|
165
|
+
## Timeouts
|
|
166
|
+
|
|
167
|
+
The client carries an `open_timeout` (default 5s) and `read_timeout` (default
|
|
168
|
+
15s). Override either for a single call — e.g. a short read on a synchronous
|
|
169
|
+
page render that must not stall:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
client.get(["api", "contacts", id], read_timeout: 2)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A connect or read that exceeds the budget raises `TimeoutError` (status `nil`).
|
|
176
|
+
|
|
177
|
+
## Escape safety
|
|
178
|
+
|
|
179
|
+
> **Path segments passed in an `Array` carry untrusted input** (ids, emails,
|
|
180
|
+
> tokens). HttpResource builds URLs so that input can **never** escape the
|
|
181
|
+
> protocol.
|
|
182
|
+
|
|
183
|
+
In `build_uri`, every `Array` segment is encoded with **`ERB::Util.url_encode`**
|
|
184
|
+
(RFC-3986 path-component encoding) before being joined with `/`. That encodes
|
|
185
|
+
`/`, `?`, `#`, `:`, `@`, `;`, CR/LF and every other reserved character — and a
|
|
186
|
+
space becomes `%20`, not `+` (which is why `CGI.escape` is *not* used: it
|
|
187
|
+
mis-encodes space and is for form bodies, not path components). Query params go
|
|
188
|
+
through `URI.encode_www_form`.
|
|
189
|
+
|
|
190
|
+
Two inputs **cannot** be safely encoded and are **rejected** with an
|
|
191
|
+
`ArgumentError` instead: a **blank/`nil`** segment (which would collapse into
|
|
192
|
+
`//`) and a bare **`.`** or **`..`** dot-segment. No percent-encoding survives a
|
|
193
|
+
strict normaliser (`%2E` decodes back to `.` per RFC 3986 §6.2.2.2, then
|
|
194
|
+
`remove_dot_segments` traverses), and no legitimate id is a dot-segment — so a
|
|
195
|
+
`.`/`..` id is an error, never a traversal.
|
|
196
|
+
|
|
197
|
+
The result: an adversarial segment fed to `client.get(["api", "contacts", seg])`
|
|
198
|
+
always lands as **one** percent-encoded path component on the **configured**
|
|
199
|
+
host (or is rejected). None of the following can break out:
|
|
200
|
+
|
|
201
|
+
| Adversarial segment | Cannot do |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `../../etc/passwd` | introduce extra path segments / traverse (the `/` are encoded) |
|
|
204
|
+
| bare `.` / `..` | climb the path — **rejected** (no encoding survives normalisation) |
|
|
205
|
+
| `a/b?c#d` | add a path segment, query, or fragment |
|
|
206
|
+
| `https://evil.com/x` | change scheme or host |
|
|
207
|
+
| `x\r\nHost: evil.com` | inject CRLF / smuggle a header |
|
|
208
|
+
| `%2e%2e%2f` | sneak a pre-encoded `../` through |
|
|
209
|
+
| `a b`, `;semi`, `@host`, unicode | alter structure or authority |
|
|
210
|
+
|
|
211
|
+
A **`String`** path is the trusted escape hatch and is sent **verbatim** — so
|
|
212
|
+
**never interpolate untrusted input into a `String` path**; pass an `Array` and
|
|
213
|
+
let the framework encode it. The guarantee is covered by a dedicated,
|
|
214
|
+
adversarial spec (`spec/escape_safety_spec.rb`).
|
|
215
|
+
|
|
216
|
+
## Development
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
bundle install
|
|
220
|
+
bundle exec rspec
|
|
221
|
+
bundle exec rubocop
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
[MIT](LICENSE) © Skiftet.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpResource
|
|
4
|
+
module Auth
|
|
5
|
+
# HTTP Basic auth: sets the standard `Authorization: Basic <base64>` header
|
|
6
|
+
# via Net::HTTP's own `#basic_auth`.
|
|
7
|
+
class Basic
|
|
8
|
+
def initialize(username, password)
|
|
9
|
+
@username = username.to_s
|
|
10
|
+
@password = password.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply(request)
|
|
14
|
+
request.basic_auth(@username, @password)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpResource
|
|
4
|
+
module Auth
|
|
5
|
+
# Bearer-token auth: sets `Authorization: Bearer <token>`.
|
|
6
|
+
class Bearer
|
|
7
|
+
def initialize(token)
|
|
8
|
+
@token = token.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def apply(request)
|
|
12
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpResource
|
|
4
|
+
module Auth
|
|
5
|
+
# Arbitrary-header auth: sets a custom header (e.g. `X-Api-Key: <value>`).
|
|
6
|
+
class Header
|
|
7
|
+
def initialize(name, value)
|
|
8
|
+
@name = name.to_s
|
|
9
|
+
@value = value.to_s
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def apply(request)
|
|
13
|
+
request[@name] = @value
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "http_resource/auth/basic"
|
|
4
|
+
require "http_resource/auth/bearer"
|
|
5
|
+
require "http_resource/auth/header"
|
|
6
|
+
|
|
7
|
+
module HttpResource
|
|
8
|
+
# Pluggable auth strategies. A strategy is any object responding to
|
|
9
|
+
# `#apply(request)` that mutates a Net::HTTP request to carry credentials.
|
|
10
|
+
# Three are shipped (Basic, Bearer, Header); bring your own for anything else.
|
|
11
|
+
module Auth
|
|
12
|
+
# Convenience builders so callers can write `Auth.basic("u", "p")`.
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def basic(username, password)
|
|
17
|
+
Basic.new(username, password)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def bearer(token)
|
|
21
|
+
Bearer.new(token)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def header(name, value)
|
|
25
|
+
Header.new(name, value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "erb"
|
|
6
|
+
require "json"
|
|
7
|
+
require "openssl"
|
|
8
|
+
|
|
9
|
+
module HttpResource
|
|
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 also
|
|
12
|
+
# an escape hatch for endpoints not yet modelled.
|
|
13
|
+
#
|
|
14
|
+
# client = HttpResource::Client.new(base_url: "https://api.example.org",
|
|
15
|
+
# auth: HttpResource::Auth.bearer(token))
|
|
16
|
+
# client.get(["api", "contacts", id]) # GET, id escaped as one segment
|
|
17
|
+
# client.post(["api", "actions"], { ... }) # POST a JSON body
|
|
18
|
+
#
|
|
19
|
+
# Reads return parsed JSON (a Hash/Array, or nil on an empty body). Every call
|
|
20
|
+
# raises an HttpResource::ApiError subclass on a non-2xx response or a transport
|
|
21
|
+
# failure.
|
|
22
|
+
#
|
|
23
|
+
# SECURITY — path segments are UNTRUSTED. When `path` is an Array, each segment
|
|
24
|
+
# is percent-encoded as a single RFC-3986 path component, so an id/email/token
|
|
25
|
+
# can never escape into a second segment, the host, a query or a header. A
|
|
26
|
+
# String `path` is sent VERBATIM (trusted) — NEVER interpolate untrusted input
|
|
27
|
+
# into a String path; pass an Array.
|
|
28
|
+
class Client
|
|
29
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
30
|
+
DEFAULT_READ_TIMEOUT = 15
|
|
31
|
+
|
|
32
|
+
attr_reader :base_url
|
|
33
|
+
|
|
34
|
+
def initialize(base_url:, auth: nil, username: nil, password: nil,
|
|
35
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
36
|
+
raise ConfigurationError, "base_url is required" if blank?(base_url)
|
|
37
|
+
|
|
38
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
39
|
+
@auth = auth || default_auth(username, password)
|
|
40
|
+
@open_timeout = open_timeout
|
|
41
|
+
@read_timeout = read_timeout
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Low-level REST verbs. `path` may be a String ("/api/foo") sent verbatim, or
|
|
45
|
+
# an Array of segments (["api", "contacts", email]) each individually escaped.
|
|
46
|
+
# Each verb accepts open_timeout:/read_timeout: to override the client's
|
|
47
|
+
# budget for that one call (e.g. a short read_timeout on a synchronous read).
|
|
48
|
+
def get(path, params: nil, **timeouts)
|
|
49
|
+
request(:get, path, params:, **timeouts)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def post(path, payload = nil, **timeouts)
|
|
53
|
+
request(:post, path, body: payload, **timeouts)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def patch(path, payload = nil, **timeouts)
|
|
57
|
+
request(:patch, path, body: payload, **timeouts)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete(path, **timeouts)
|
|
61
|
+
request(:delete, path, **timeouts)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def request(method, path, body: nil, params: nil, open_timeout: nil, read_timeout: nil)
|
|
67
|
+
# Build the URI + request OUTSIDE the network rescue: a URI::InvalidURIError
|
|
68
|
+
# (bad path) or JSON::GeneratorError (un-serializable payload, e.g. a NaN
|
|
69
|
+
# amount) is a deterministic caller bug, and must NOT be masked as a
|
|
70
|
+
# retryable TransportError — that would have a worker retry it forever.
|
|
71
|
+
uri = build_uri(path, params)
|
|
72
|
+
req = build_request(method, uri, body)
|
|
73
|
+
connection = http(uri, open_timeout:, read_timeout:)
|
|
74
|
+
begin
|
|
75
|
+
handle(connection.request(req))
|
|
76
|
+
rescue ApiError
|
|
77
|
+
raise
|
|
78
|
+
rescue Timeout::Error, Errno::ETIMEDOUT => e # Net::Open/ReadTimeout subclass Timeout::Error
|
|
79
|
+
raise TimeoutError, "Request timed out: #{e.class}: #{e.message}"
|
|
80
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
81
|
+
raise ConnectionError, "Connection failed: #{e.class}: #{e.message}"
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise TransportError, "Request failed: #{e.class}: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# An Array path has each segment percent-encoded as ONE path component
|
|
88
|
+
# (RFC 3986). ERB::Util.url_encode encodes "/", "?", "#", ":", "@", ";",
|
|
89
|
+
# CR/LF, space (as %20, not "+") and every reserved char — so an untrusted
|
|
90
|
+
# segment cannot introduce a new path segment, change the host, or inject a
|
|
91
|
+
# query/fragment/CRLF. A String path is trusted and sent verbatim.
|
|
92
|
+
def build_uri(path, params)
|
|
93
|
+
joined =
|
|
94
|
+
if path.is_a?(Array)
|
|
95
|
+
path.map { encode_segment(_1) }.join("/")
|
|
96
|
+
else
|
|
97
|
+
path.to_s.sub(%r{\A/+}, "")
|
|
98
|
+
end
|
|
99
|
+
uri = URI.parse("#{@base_url}/#{joined}")
|
|
100
|
+
uri.query = URI.encode_www_form(params) if params && !params.empty?
|
|
101
|
+
uri
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Percent-encode one untrusted path segment as a single RFC-3986 path
|
|
105
|
+
# component. Two inputs can't be safely encoded and must be REJECTED:
|
|
106
|
+
# - a BLANK segment would collapse into "//".
|
|
107
|
+
# - "." / ".." are dot-segments a server/proxy resolves to climb the path,
|
|
108
|
+
# and NO percent-encoding survives strict normalisation (%2E decodes back
|
|
109
|
+
# to "." per RFC 3986 §6.2.2.2, THEN remove_dot_segments traverses). No
|
|
110
|
+
# legitimate id is a dot-segment, so reject rather than (uselessly) encode.
|
|
111
|
+
def encode_segment(segment)
|
|
112
|
+
str = segment.to_s
|
|
113
|
+
raise ArgumentError, "path segment may not be blank" if str.empty?
|
|
114
|
+
raise ArgumentError, "path segment may not be a '.' or '..' dot-segment" if [".", ".."].include?(str)
|
|
115
|
+
|
|
116
|
+
ERB::Util.url_encode(str)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_request(method, uri, body)
|
|
120
|
+
klass = {
|
|
121
|
+
get: Net::HTTP::Get, post: Net::HTTP::Post,
|
|
122
|
+
patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
|
|
123
|
+
}.fetch(method)
|
|
124
|
+
request = klass.new(uri)
|
|
125
|
+
@auth&.apply(request)
|
|
126
|
+
request["Accept"] = "application/json"
|
|
127
|
+
if body
|
|
128
|
+
request["Content-Type"] = "application/json"
|
|
129
|
+
request.body = JSON.generate(body)
|
|
130
|
+
end
|
|
131
|
+
request
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def http(uri, open_timeout: nil, read_timeout: nil)
|
|
135
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
136
|
+
http.use_ssl = uri.scheme == "https"
|
|
137
|
+
http.open_timeout = open_timeout || @open_timeout
|
|
138
|
+
http.read_timeout = read_timeout || @read_timeout
|
|
139
|
+
http
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def handle(response)
|
|
143
|
+
status = response.code.to_i
|
|
144
|
+
body = response.body.to_s
|
|
145
|
+
parsed = body.empty? ? nil : parse_json(body)
|
|
146
|
+
return parsed if status.between?(200, 299)
|
|
147
|
+
|
|
148
|
+
raise ApiError.for_status("HTTP request returned #{status}", status:, body:)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_json(body)
|
|
152
|
+
JSON.parse(body)
|
|
153
|
+
rescue JSON::JSONError
|
|
154
|
+
body
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def default_auth(username, password)
|
|
158
|
+
return nil if blank?(username) && blank?(password)
|
|
159
|
+
|
|
160
|
+
Auth::Basic.new(username, password)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def blank?(value)
|
|
164
|
+
value.nil? || value.to_s.strip.empty?
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpResource
|
|
4
|
+
# Holds the settings for building a default client (HttpResource.client).
|
|
5
|
+
# Framework-generic: no app-specific env names. A host sets these explicitly in
|
|
6
|
+
# an initializer, mapping ITS own env vars onto them.
|
|
7
|
+
#
|
|
8
|
+
# HttpResource.configure do |c|
|
|
9
|
+
# c.base_url = ENV.fetch("API_URL")
|
|
10
|
+
# c.auth = HttpResource::Auth.bearer(ENV.fetch("API_TOKEN"))
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
attr_accessor :base_url, :auth, :open_timeout, :read_timeout
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@base_url = nil
|
|
17
|
+
@auth = nil
|
|
18
|
+
@open_timeout = Client::DEFAULT_OPEN_TIMEOUT
|
|
19
|
+
@read_timeout = Client::DEFAULT_READ_TIMEOUT
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpResource
|
|
4
|
+
# Base for every error the framework raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Missing/blank base_url, or other misconfiguration of a client.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Marker for the ONE failure the non-bang resource methods (find, destroy)
|
|
11
|
+
# treat as an EXPECTED outcome and swallow to nil: a 404 not-found. Everything
|
|
12
|
+
# else — INCLUDING a 422 validation rejection on a write (which must surface,
|
|
13
|
+
# not silently drop the write) — is UNEXPECTED and raises even from the
|
|
14
|
+
# non-bang form. The bang form (find!, create!) raises on any failure.
|
|
15
|
+
module Expected; end
|
|
16
|
+
|
|
17
|
+
# Raised on a non-2xx response or a transport failure. Carries the HTTP status
|
|
18
|
+
# (an Integer, or nil for transport failures) + the raw body, so a background
|
|
19
|
+
# worker can branch its retry: drop on a 4xx (client_error?), retry on a 5xx
|
|
20
|
+
# (server_error?) or a transport failure (TransportError, status nil).
|
|
21
|
+
class ApiError < Error
|
|
22
|
+
attr_reader :status, :body
|
|
23
|
+
|
|
24
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
25
|
+
@status = status
|
|
26
|
+
@body = body
|
|
27
|
+
super(message || "HTTP error (status=#{status.inspect})")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def client_error?
|
|
31
|
+
status.is_a?(Integer) && status.between?(400, 499)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def server_error?
|
|
35
|
+
status.is_a?(Integer) && status.between?(500, 599)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def not_found?
|
|
39
|
+
status == 404
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Map an HTTP status to the most specific ApiError subclass.
|
|
43
|
+
def self.for_status(message, status:, body:)
|
|
44
|
+
klass =
|
|
45
|
+
case status
|
|
46
|
+
when 404 then NotFoundError
|
|
47
|
+
when 422 then ValidationError
|
|
48
|
+
when 401, 403 then AuthError
|
|
49
|
+
when 400..499 then ClientError
|
|
50
|
+
when 300..399 then RedirectError
|
|
51
|
+
when 500..599 then ServerError
|
|
52
|
+
else self
|
|
53
|
+
end
|
|
54
|
+
klass.new(message, status:, body:)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 4xx — the caller's request won't succeed on retry; a background worker should
|
|
59
|
+
# drop, not retry. Parent of the specific 4xx below.
|
|
60
|
+
class ClientError < ApiError; end
|
|
61
|
+
|
|
62
|
+
# 404 — the resource does not exist. Expected: the non-bang form swallows it to nil.
|
|
63
|
+
class NotFoundError < ClientError
|
|
64
|
+
include Expected
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 422 — the request was rejected as invalid (the body holds the details). NOT
|
|
68
|
+
# Expected: a non-bang write raises this instead of silently dropping the
|
|
69
|
+
# write, so a sync job surfaces and retries the failure rather than losing data.
|
|
70
|
+
class ValidationError < ClientError; end
|
|
71
|
+
|
|
72
|
+
# 401/403 — bad or missing credentials. Almost always a config problem; raises
|
|
73
|
+
# even from the non-bang form.
|
|
74
|
+
class AuthError < ClientError; end
|
|
75
|
+
|
|
76
|
+
# 3xx — the server returned a redirect (Net::HTTP does not follow them). Almost
|
|
77
|
+
# always a misconfigured base_url (e.g. http:// hitting an https redirect); not
|
|
78
|
+
# retryable. Neither client_error? nor server_error?.
|
|
79
|
+
class RedirectError < ApiError; end
|
|
80
|
+
|
|
81
|
+
# 5xx — the server failed to handle a valid request. Retryable.
|
|
82
|
+
class ServerError < ApiError; end
|
|
83
|
+
|
|
84
|
+
# Network-level failure before/while talking to the server; status is nil. Retryable.
|
|
85
|
+
class TransportError < ApiError; end
|
|
86
|
+
|
|
87
|
+
# The connection or read exceeded the timeout budget.
|
|
88
|
+
class TimeoutError < TransportError; end
|
|
89
|
+
|
|
90
|
+
# Could not establish/keep the connection (refused, reset, DNS, TLS).
|
|
91
|
+
class ConnectionError < TransportError; end
|
|
92
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpResource
|
|
4
|
+
# Base for the REST resource proxies hung off a Client. A subclass maps one
|
|
5
|
+
# endpoint to its verbs in a bang/non-bang pair (Rails-style):
|
|
6
|
+
#
|
|
7
|
+
# find(id) — returns the value object, or nil on an EXPECTED miss (404
|
|
8
|
+
# not-found). Raises on the UNEXPECTED (validation, auth, 5xx,
|
|
9
|
+
# timeout, connection).
|
|
10
|
+
# find!(id) — raises an HttpResource::ApiError on ANY failure.
|
|
11
|
+
#
|
|
12
|
+
# Sketch:
|
|
13
|
+
#
|
|
14
|
+
# class Contacts < HttpResource::Resource
|
|
15
|
+
# def find(id) = soft { find!(id) }
|
|
16
|
+
# def find!(id)
|
|
17
|
+
# data = @client.get(["api", "contacts", id])
|
|
18
|
+
# data && Contact.from(data)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
class Resource
|
|
22
|
+
def initialize(client)
|
|
23
|
+
@client = client
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Run the bang form, swallowing only EXPECTED failures (404) to nil.
|
|
29
|
+
def soft
|
|
30
|
+
yield
|
|
31
|
+
rescue Expected
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpResource
|
|
4
|
+
# Mixin for response value objects, designed to pair with Ruby's Data.define.
|
|
5
|
+
# It gives a class a tolerant `.from(payload)` that:
|
|
6
|
+
# - returns nil for a nil payload (so an empty 2xx -> nil, never a ghost),
|
|
7
|
+
# - unwraps a top-level { "data" => {...} } envelope if present,
|
|
8
|
+
# - normalises string OR symbol keys,
|
|
9
|
+
# then hands the inner hash to `build` (or the Data.define member names, when
|
|
10
|
+
# no `build` is defined) so missing keys arrive as nil instead of raising.
|
|
11
|
+
#
|
|
12
|
+
# Contact = Data.define(:email, :name) do
|
|
13
|
+
# extend HttpResource::ValueObject
|
|
14
|
+
# end
|
|
15
|
+
# Contact.from("data" => { "email" => "a@b.se" }) # => #<data Contact email="a@b.se", name=nil>
|
|
16
|
+
# Contact.from(nil) # => nil
|
|
17
|
+
#
|
|
18
|
+
# Resource methods should still guard `data && Contact.from(data)` so the
|
|
19
|
+
# caller never receives a ghost object from an empty body.
|
|
20
|
+
module ValueObject
|
|
21
|
+
def from(payload)
|
|
22
|
+
return nil if payload.nil?
|
|
23
|
+
|
|
24
|
+
data = unwrap(payload)
|
|
25
|
+
respond_to?(:build) ? build(data) : new(**slice_members(data))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def unwrap(payload)
|
|
31
|
+
hash = stringify(payload)
|
|
32
|
+
inner = hash["data"]
|
|
33
|
+
inner.is_a?(Hash) ? inner : hash
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stringify(payload)
|
|
37
|
+
return {} unless payload.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
payload.transform_keys(&:to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Pick exactly the Data members, defaulting missing ones to nil.
|
|
43
|
+
def slice_members(data)
|
|
44
|
+
members.to_h { |key| [key, data[key.to_s]] }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "http_resource/version"
|
|
4
|
+
require "http_resource/errors"
|
|
5
|
+
require "http_resource/auth"
|
|
6
|
+
require "http_resource/client"
|
|
7
|
+
require "http_resource/configuration"
|
|
8
|
+
require "http_resource/resource"
|
|
9
|
+
require "http_resource/value_object"
|
|
10
|
+
|
|
11
|
+
# A small, zero-dependency framework for building typed REST-resource clients on
|
|
12
|
+
# top of Net::HTTP. Bring a base_url + an auth strategy; get a Net::HTTP
|
|
13
|
+
# transport with a typed error hierarchy, bang/non-bang resources, per-call
|
|
14
|
+
# timeouts and escape-safe URL building.
|
|
15
|
+
#
|
|
16
|
+
# Build a client directly:
|
|
17
|
+
#
|
|
18
|
+
# client = HttpResource::Client.new(base_url: "https://api.example.org",
|
|
19
|
+
# auth: HttpResource::Auth.bearer(token))
|
|
20
|
+
#
|
|
21
|
+
# …or configure a process-wide default:
|
|
22
|
+
#
|
|
23
|
+
# HttpResource.configure do |c|
|
|
24
|
+
# c.base_url = ENV.fetch("API_URL")
|
|
25
|
+
# c.auth = HttpResource::Auth.basic(ENV.fetch("API_USER"), ENV.fetch("API_PASS"))
|
|
26
|
+
# end
|
|
27
|
+
# HttpResource.client.get(["api", "ping"])
|
|
28
|
+
module HttpResource
|
|
29
|
+
class << self
|
|
30
|
+
# Configure the process-wide default client, then (re)build it.
|
|
31
|
+
def configure
|
|
32
|
+
yield configuration
|
|
33
|
+
@client = build_client
|
|
34
|
+
configuration
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configuration
|
|
38
|
+
@configuration ||= Configuration.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The memoized default client, built from `configuration`.
|
|
42
|
+
def client
|
|
43
|
+
@client ||= build_client
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build a fresh, independent client. Defaults to the configured base_url/auth,
|
|
47
|
+
# but every option can be overridden per call — handy for talking to more
|
|
48
|
+
# than one host without a global default.
|
|
49
|
+
def build_client(base_url: configuration.base_url, auth: configuration.auth,
|
|
50
|
+
open_timeout: configuration.open_timeout, read_timeout: configuration.read_timeout)
|
|
51
|
+
Client.new(base_url:, auth:, open_timeout:, read_timeout:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Drop the memoized config + client (mainly for tests / reconfiguration).
|
|
55
|
+
def reset!
|
|
56
|
+
@configuration = nil
|
|
57
|
+
@client = nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: http_resource
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Skiftet
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: HttpResource gives you a Net::HTTP transport with a typed error hierarchy
|
|
13
|
+
(NotFoundError, ValidationError, AuthError, ServerError, TimeoutError…), Rails-style
|
|
14
|
+
bang/non-bang resources, pluggable auth strategies (Basic/Bearer/Header), per-call
|
|
15
|
+
timeouts, and escape-safe URL building so untrusted path segments can never escape
|
|
16
|
+
the protocol. Bring a base_url and an auth strategy; build clients and resources
|
|
17
|
+
on top. Zero runtime dependencies (stdlib only).
|
|
18
|
+
email:
|
|
19
|
+
- joel.e.svensson@skiftet.org
|
|
20
|
+
executables: []
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- LICENSE
|
|
25
|
+
- README.md
|
|
26
|
+
- lib/http_resource.rb
|
|
27
|
+
- lib/http_resource/auth.rb
|
|
28
|
+
- lib/http_resource/auth/basic.rb
|
|
29
|
+
- lib/http_resource/auth/bearer.rb
|
|
30
|
+
- lib/http_resource/auth/header.rb
|
|
31
|
+
- lib/http_resource/client.rb
|
|
32
|
+
- lib/http_resource/configuration.rb
|
|
33
|
+
- lib/http_resource/errors.rb
|
|
34
|
+
- lib/http_resource/resource.rb
|
|
35
|
+
- lib/http_resource/value_object.rb
|
|
36
|
+
- lib/http_resource/version.rb
|
|
37
|
+
homepage: https://github.com/Skiftet/http_resource
|
|
38
|
+
licenses:
|
|
39
|
+
- MIT
|
|
40
|
+
metadata:
|
|
41
|
+
homepage_uri: https://github.com/Skiftet/http_resource
|
|
42
|
+
source_code_uri: https://github.com/Skiftet/http_resource
|
|
43
|
+
changelog_uri: https://github.com/Skiftet/http_resource/blob/main/README.md
|
|
44
|
+
rubygems_mfa_required: 'true'
|
|
45
|
+
rdoc_options: []
|
|
46
|
+
require_paths:
|
|
47
|
+
- lib
|
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.2'
|
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '0'
|
|
58
|
+
requirements: []
|
|
59
|
+
rubygems_version: 3.6.9
|
|
60
|
+
specification_version: 4
|
|
61
|
+
summary: A tiny, zero-dependency framework for typed REST-resource clients on Net::HTTP.
|
|
62
|
+
test_files: []
|