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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpResource
4
+ VERSION = "0.1.0"
5
+ 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: []