HttpResource
A tiny, zero-dependency Ruby framework for building typed REST-resource
clients on top of Net::HTTP.
You bring a base_url and an auth strategy; HttpResource gives you a transport
with a typed error hierarchy, Rails-style bang/non-bang resources,
pluggable auth, per-call timeouts, and escape-safe URL building so
untrusted path segments can never escape the protocol.
It is the generic core extracted from Skiftet's mejla_api_client: a small set
of proven patterns you would otherwise hand-roll (and get subtly wrong) in every
service-to-service client.
Why
Most hand-rolled HTTP clients get three things wrong:
- They mask deterministic bugs as retryable failures. A bad URL or an
un-serializable payload gets caught by a broad
rescueand turned into a "transport error", so a background worker retries it forever. HttpResource builds the request outside the network rescue, so those propagate. - They flatten every failure into one exception. A 404, a 422 validation rejection, an auth failure and a 5xx all need different handling. HttpResource maps each to a distinct, rescue-by-parent error class.
- They interpolate untrusted ids straight into URLs. HttpResource encodes each array path segment as a single RFC-3986 path component — see Escape safety.
Install
# Gemfile
gem "http_resource"
require "http_resource"
Requires Ruby >= 3.2. No runtime dependencies.
Usage
Build a client
client = HttpResource::Client.new(
base_url: "https://api.example.org",
auth: HttpResource::Auth.bearer(ENV.fetch("API_TOKEN")),
open_timeout: 5, # optional, default 5
read_timeout: 15 # optional, default 15
)
client.get(["api", "contacts", id]) # GET, id escaped as ONE path segment
client.post(["api", "actions"], { foo: 1 }) # POST a JSON body
client.patch(["api", "contacts", email], { name: "Anna" })
client.delete(["api", "contacts", id])
A String path is sent verbatim (client.get("/api/ping")); an Array path
has each segment percent-encoded (see Escape safety).
Reads return parsed JSON (a Hash/Array, or nil on an empty body). Every
call raises an HttpResource::ApiError subclass on a non-2xx response or a
transport failure.
A process-wide default client
HttpResource.configure do |c|
c.base_url = ENV.fetch("API_URL")
c.auth = HttpResource::Auth.basic(ENV.fetch("API_USER"), ENV.fetch("API_PASS"))
end
HttpResource.client.get(["api", "ping"])
HttpResource.build_client(base_url: ..., auth: ...) builds an independent
client when you talk to more than one host.
Pluggable auth
An auth strategy is any object responding to #apply(request). Three are shipped:
HttpResource::Auth.basic("user", "pass") # Authorization: Basic <base64>
HttpResource::Auth.bearer("token") # Authorization: Bearer token
HttpResource::Auth.header("X-Api-Key", k) # X-Api-Key: k
Passing username:/password: (and no auth:) defaults to Basic. Bring your
own strategy for anything else (HMAC signing, refreshing tokens, …).
Resources: the bang/non-bang pattern
Subclass HttpResource::Resource to map an endpoint to typed verbs. Pair a
non-bang method (returns nil on an expected 404 miss) with a bang method
(raises on any failure):
Contact = Data.define(:email, :name) do
extend HttpResource::ValueObject # tolerant .from(payload)
end
class Contacts < HttpResource::Resource
def find(id) = soft { find!(id) } # nil on 404, raises on anything else
def find!(id)
data = @client.get(["api", "contacts", id])
data && Contact.from(data) # empty 2xx -> nil, never a ghost object
end
end
contacts = Contacts.new(client)
contacts.find("missing") # => nil (404 swallowed)
contacts.find!("missing") # => raises HttpResource::NotFoundError
soft { ... } swallows only an Expected failure (a 404) to nil.
Everything else — including a 422 validation rejection on a write — raises even
from the non-bang form, so a sync job surfaces and retries the failure rather
than silently dropping a write.
ValueObject#from returns nil for a nil payload, unwraps a top-level
{ "data" => {...} } envelope, tolerates string or symbol keys, and defaults
missing keys to nil. Guarding data && Contact.from(data) means an empty 2xx
yields nil, not a ghost value object.
Error hierarchy
Every failure is an HttpResource::ApiError carrying #status (an Integer, or
nil for transport failures) and #body. ApiError.for_status maps the HTTP
status to the most specific class, so you can rescue broadly or narrowly:
| Class | Status | client_error? |
server_error? |
Expected (→ nil) |
Meaning |
|---|---|---|---|---|---|
ApiError |
any / other | by status | by status | no | base for all of the below |
ClientError |
400–499 | yes | no | no | caller's request won't succeed on retry — drop |
NotFoundError |
404 | yes | no | yes | resource missing; the only swallow-to-nil case |
ValidationError |
422 | yes | no | no | request rejected; a write must surface, not drop |
AuthError |
401, 403 | yes | no | no | bad/missing credentials — usually a config bug |
RedirectError |
300–399 | no | no | no | unfollowed redirect — usually a wrong base_url |
ServerError |
500–599 | no | yes | no | server failed a valid request — retryable |
TransportError |
nil | no | no | no | network failure before/while talking — retryable |
TimeoutError |
nil | no | no | no | connect/read exceeded the budget (a TransportError) |
ConnectionError |
nil | no | no | no | refused/reset/DNS/TLS (a TransportError) |
Because the tree nests, a worker can branch on intent:
begin
client.post(["api", "actions"], payload)
rescue HttpResource::ClientError # 4xx — drop, don't retry
drop!
rescue HttpResource::ServerError, # 5xx + transport — retry
HttpResource::TransportError
retry_later!
end
ConfigurationError (a sibling of ApiError under Error) is raised eagerly
for a blank base_url — never on the network path.
Timeouts
The client carries an open_timeout (default 5s) and read_timeout (default
15s). Override either for a single call — e.g. a short read on a synchronous
page render that must not stall:
client.get(["api", "contacts", id], read_timeout: 2)
A connect or read that exceeds the budget raises TimeoutError (status nil).
Escape safety
Path segments passed in an
Arraycarry untrusted input (ids, emails, tokens). HttpResource builds URLs so that input can never escape the protocol.
In build_uri, every Array segment is encoded with ERB::Util.url_encode
(RFC-3986 path-component encoding) before being joined with /. That encodes
/, ?, #, :, @, ;, CR/LF and every other reserved character — and a
space becomes %20, not + (which is why CGI.escape is not used: it
mis-encodes space and is for form bodies, not path components). Query params go
through URI.encode_www_form.
Two inputs cannot be safely encoded and are rejected with an
ArgumentError instead: a blank/nil segment (which would collapse into
//) and a bare . or .. dot-segment. No percent-encoding survives a
strict normaliser (%2E decodes back to . per RFC 3986 §6.2.2.2, then
remove_dot_segments traverses), and no legitimate id is a dot-segment — so a
./.. id is an error, never a traversal.
The result: an adversarial segment fed to client.get(["api", "contacts", seg])
always lands as one percent-encoded path component on the configured
host (or is rejected). None of the following can break out:
| Adversarial segment | Cannot do |
|---|---|
../../etc/passwd |
introduce extra path segments / traverse (the / are encoded) |
bare . / .. |
climb the path — rejected (no encoding survives normalisation) |
a/b?c#d |
add a path segment, query, or fragment |
https://evil.com/x |
change scheme or host |
x\r\nHost: evil.com |
inject CRLF / smuggle a header |
%2e%2e%2f |
sneak a pre-encoded ../ through |
a b, ;semi, @host, unicode |
alter structure or authority |
A String path is the trusted escape hatch and is sent verbatim — so
never interpolate untrusted input into a String path; pass an Array and
let the framework encode it. The guarantee is covered by a dedicated,
adversarial spec (spec/escape_safety_spec.rb).
Development
bundle install
bundle exec rspec
bundle exec rubocop
License
MIT © Skiftet.