hermetic 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.
@@ -0,0 +1,96 @@
1
+ require "json"
2
+
3
+ module Hermetic
4
+ module Backends
5
+ module Hosted
6
+ # E2B (e2b.dev) — a vendor microVM per run. trust :vendor, off-host by
7
+ # construction: a guest escape lands in E2B's cloud, not next to the
8
+ # app's secrets.
9
+ #
10
+ # BEST-EFFORT WIRE SHAPE (spec §6.4): BASE_URL, the endpoint path, and
11
+ # the request/response bodies below are a design-time approximation of
12
+ # E2B's create-and-run API. Pin them against the live API via the
13
+ # :hosted smoke layer (spec §5) before first real use.
14
+ class E2B < Base
15
+ BASE_URL = "https://api.e2b.dev"
16
+
17
+ def initialize(api_key:, template: "base", base_url: BASE_URL, transport: nil, **opts)
18
+ raise ConfigError, "api_key is required for Hosted::E2B" if api_key.to_s.strip.empty?
19
+
20
+ super(trust: :vendor, **opts)
21
+ @api_key = api_key
22
+ @template = template
23
+ @base_url = base_url.to_s.chomp("/")
24
+ @transport = transport || Transport::Http.new
25
+ end
26
+
27
+ def backend_name = :e2b
28
+
29
+ # Pure: one run as an HTTP request spec — unit-testable with zero
30
+ # infra. Box credentials are already resolved into clean_env (:env
31
+ # mode), so the model-visible command never carries a raw token.
32
+ def create_and_run_spec(request)
33
+ {
34
+ method: :post,
35
+ url: "#{@base_url}/v1/sandboxes/exec",
36
+ headers: {
37
+ "X-API-Key" => @api_key,
38
+ "Content-Type" => "application/json",
39
+ "Idempotency-Key" => request.idempotency_key
40
+ },
41
+ body: JSON.generate(
42
+ template: @template,
43
+ command: request.exec_string,
44
+ stdin: request.stdin,
45
+ files: request.files,
46
+ env: request.clean_env,
47
+ network: { egress: request.network.egress },
48
+ timeout: request.timeout,
49
+ limits: request.limits.to_h
50
+ )
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ # In-doubt discipline (spec §7 risk 2, v1: no automatic retry):
57
+ # wire failure / 5xx -> the run may have executed vendor-side, raise
58
+ # in_doubt; 4xx -> the vendor refused before starting, plain failure.
59
+ # A guest failure is never an exception — it's a non-zero Result.
60
+ def execute(request)
61
+ response = begin
62
+ @transport.call(create_and_run_spec(request))
63
+ rescue TransportError => e
64
+ raise TransportError.new(
65
+ "E2B unreachable or response lost (in doubt, idempotency_key=#{request.idempotency_key}): #{e.message}",
66
+ in_doubt: true
67
+ )
68
+ end
69
+
70
+ unless response.success?
71
+ raise TransportError.new(
72
+ "E2B returned HTTP #{response.status} (idempotency_key=#{request.idempotency_key}): " \
73
+ "#{response.body.to_s[0, 200]}",
74
+ in_doubt: response.status >= 500
75
+ )
76
+ end
77
+
78
+ to_tuple(response.json)
79
+ rescue JSON::ParserError
80
+ raise TransportError.new(
81
+ "E2B returned unparseable JSON (in doubt, idempotency_key=#{request.idempotency_key})",
82
+ in_doubt: true
83
+ )
84
+ end
85
+
86
+ # Vendor payload -> the execute tuple Base assembles into a Result.
87
+ # Expected shape (best-effort, pinned with the endpoint):
88
+ # { "stdout": "", "stderr": "", "exitCode": 0, "timedOut": false }
89
+ def to_tuple(payload)
90
+ [ payload["stdout"].to_s, payload["stderr"].to_s,
91
+ payload["exitCode"].to_i, !!payload["timedOut"] ]
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,27 @@
1
+ require "hermetic/backends/hosted/e2b"
2
+
3
+ module Hermetic
4
+ module Backends
5
+ # Factory for vendor-cloud sandboxes — off-host by construction (spec §3):
6
+ # the guest lives in the vendor's cloud and this process holds only an
7
+ # API key. E2B ships in v1; Daytona/Modal are named but deferred so the
8
+ # constructor fails with the roadmap, not a NameError.
9
+ module Hosted
10
+ PROVIDERS = { e2b: :E2B }.freeze
11
+ DEFERRED = %i[daytona modal].freeze
12
+
13
+ def self.build(provider, **opts)
14
+ key = provider.to_s.downcase.to_sym
15
+ if DEFERRED.include?(key)
16
+ raise ConfigError, "hosted provider #{key.inspect} is deferred (v1.1) — :e2b is the v1 provider"
17
+ end
18
+
19
+ const = PROVIDERS.fetch(key) do
20
+ raise ConfigError,
21
+ "unknown hosted provider #{provider.inspect} (known: #{PROVIDERS.keys.inspect}, deferred: #{DEFERRED.inspect})"
22
+ end
23
+ const_get(const).new(**opts)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ module Hermetic
2
+ module Backends
3
+ # Disabled: code execution is off. Fails loud on run — a silent no-op
4
+ # would look like a sandbox while providing none.
5
+ class Null < Base
6
+ def initialize
7
+ super(trust: :none)
8
+ end
9
+
10
+ def enabled? = false
11
+
12
+ def run(*, **)
13
+ raise DisabledError,
14
+ "code execution is disabled (Null backend) — construct a real one: " \
15
+ "Hermetic.hosted / .gvisor / .firecracker / .docker"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ module Hermetic
2
+ # Named secrets injected into the guest at exec time — the model-visible
3
+ # command never contains a raw token. Values are callables resolved lazily
4
+ # per run, so short-lived tokens are minted fresh and never cached on the
5
+ # box. :env mode ships in v1; :proxy (guest gets no token at all) is v1.1.
6
+ class Credentials
7
+ MODES = %i[env proxy].freeze
8
+
9
+ attr_reader :mode
10
+
11
+ def self.build(value, mode: :env)
12
+ case value
13
+ when nil then new({}, mode: mode)
14
+ when Credentials then value
15
+ when Hash then new(value, mode: mode)
16
+ else raise ConfigError, "credentials must be a Hash of NAME => value-or-callable, got #{value.inspect}"
17
+ end
18
+ end
19
+
20
+ def initialize(map = {}, mode: :env)
21
+ raise ConfigError, "unknown credential_mode #{mode.inspect} (known: #{MODES.inspect})" unless MODES.include?(mode)
22
+ raise ConfigError, "credential_mode :proxy is deferred (v1.1) — use :env" if mode == :proxy
23
+
24
+ @map = map.to_h.transform_keys(&:to_s).freeze
25
+ @mode = mode
26
+ end
27
+
28
+ def names = @map.keys
29
+
30
+ def empty? = @map.empty?
31
+
32
+ # Fresh values for one run. Callables are invoked now, never memoized.
33
+ def resolve
34
+ @map.transform_values { |v| (v.respond_to?(:call) ? v.call : v).to_s }
35
+ end
36
+
37
+ # Values (and the closures that mint them) never appear in logs.
38
+ def inspect = "#<Hermetic::Credentials mode=#{@mode} names=#{names.inspect} [redacted]>"
39
+ alias to_s inspect
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ module Hermetic
2
+ class Error < StandardError; end
3
+
4
+ # A disabled (Null) sandbox was asked to run — fail loud, never no-op.
5
+ class DisabledError < Error; end
6
+
7
+ # Bad construction or a malformed run request: path traversal in files:,
8
+ # a non-integer timeout, an unknown credential mode, etc.
9
+ class ConfigError < Error; end
10
+
11
+ # The wire failed (socket error, missing binary) — distinct from the guest
12
+ # failing, which is a non-zero Result, not an exception. `in_doubt` is true
13
+ # when the run may have executed before the wire failed — the remote/hosted
14
+ # in-doubt window (spec §7 risk 2). Never blindly retry an in-doubt run:
15
+ # replay the same idempotency key against a deduping executor, or escalate.
16
+ class TransportError < Error
17
+ attr_reader :in_doubt
18
+
19
+ def initialize(message = nil, in_doubt: false)
20
+ super(message)
21
+ @in_doubt = in_doubt
22
+ end
23
+
24
+ def in_doubt? = !!@in_doubt
25
+ end
26
+
27
+ # Host-side wall-clock enforcement failed in a way that can't be expressed
28
+ # as the normal exit-124 Result (e.g. the reaper couldn't kill the guest).
29
+ class TimeoutError < Error; end
30
+
31
+ # Workspace materialization failed — files: couldn't be placed in the
32
+ # guest's workdir.
33
+ class WorkspaceError < Error; end
34
+ end
@@ -0,0 +1,44 @@
1
+ module Hermetic
2
+ # Resource ceilings for a run. Set on the box at construction, overridable
3
+ # per run via `limits:` (merged over the box defaults).
4
+ class Limits
5
+ DEFAULTS = { memory: "512m", cpus: "1", pids: 256 }.freeze
6
+
7
+ attr_reader :memory, :cpus, :pids
8
+
9
+ def self.build(value)
10
+ case value
11
+ when nil then new
12
+ when Limits then value
13
+ when Hash then new.merge(value)
14
+ else raise ConfigError, "limits must be a Hash or Hermetic::Limits, got #{value.inspect}"
15
+ end
16
+ end
17
+
18
+ def initialize(memory: DEFAULTS[:memory], cpus: DEFAULTS[:cpus], pids: DEFAULTS[:pids])
19
+ @memory = memory.to_s
20
+ @cpus = cpus.to_s
21
+ @pids = Integer(pids)
22
+ raise ConfigError, "pids must be positive, got #{pids.inspect}" unless @pids.positive?
23
+ rescue ArgumentError, TypeError
24
+ raise ConfigError, "pids must be an integer, got #{pids.inspect}"
25
+ end
26
+
27
+ def merge(overrides)
28
+ return self if overrides.nil?
29
+
30
+ overrides = overrides.to_h.transform_keys(&:to_sym)
31
+ unknown = overrides.keys - DEFAULTS.keys
32
+ raise ConfigError, "unknown limit(s) #{unknown.inspect} — known: #{DEFAULTS.keys.inspect}" unless unknown.empty?
33
+
34
+ self.class.new(**to_h.merge(overrides))
35
+ end
36
+
37
+ def to_h = { memory: memory, cpus: cpus, pids: pids }
38
+
39
+ def ==(other) = other.is_a?(Limits) && other.to_h == to_h
40
+ alias eql? ==
41
+
42
+ def hash = to_h.hash
43
+ end
44
+ end
@@ -0,0 +1,52 @@
1
+ module Hermetic
2
+ # Guest networking is default-deny (:none). Opting in means naming an
3
+ # egress allowlist — "host:port" entries — never "open". No inbound ever.
4
+ class NetworkPolicy
5
+ EGRESS_ENTRY = /\A[A-Za-z0-9*._-]+:\d{1,5}\z/
6
+
7
+ attr_reader :egress
8
+
9
+ def self.from(value)
10
+ case value
11
+ when NetworkPolicy then value
12
+ when :none, nil then NONE
13
+ else raise ConfigError, "network must be :none or a Hermetic::NetworkPolicy, got #{value.inspect}"
14
+ end
15
+ end
16
+
17
+ def initialize(egress: [])
18
+ @egress = Array(egress).map(&:to_s).freeze
19
+ @egress.each do |entry|
20
+ raise ConfigError, %(egress entries are "host:port", got #{entry.inspect}) unless entry.match?(EGRESS_ENTRY)
21
+ end
22
+ end
23
+
24
+ def none? = egress.empty?
25
+
26
+ def open? = !none?
27
+
28
+ # The docker/gvisor `--network` value. Default-deny stays "none"; an
29
+ # egress allowlist needs a bridge — enforcing the allowlist itself is the
30
+ # backend's job, this flag only stops it being silently unreachable.
31
+ def docker_flag = none? ? "none" : "bridge"
32
+
33
+ # The firecracker "network-interfaces" machine config. Default-deny
34
+ # attaches no NIC at all — the guest has no device to speak through.
35
+ # An egress allowlist attaches one tap iface; enforcing the allowlist on
36
+ # that tap (nftables) is the orchestration layer's job — same split of
37
+ # responsibility as docker_flag.
38
+ def firecracker_ifaces(iface_id: "eth0", host_dev_name: "tap0", guest_mac: "06:00:AC:10:00:02")
39
+ return [] if none?
40
+
41
+ [ { "iface_id" => iface_id, "host_dev_name" => host_dev_name, "guest_mac" => guest_mac } ]
42
+ end
43
+
44
+ def to_s = none? ? "none" : "egress:#{egress.join(',')}"
45
+
46
+ def inspect = "#<Hermetic::NetworkPolicy #{self}>"
47
+
48
+ def ==(other) = other.is_a?(NetworkPolicy) && other.egress == egress
49
+
50
+ NONE = new.freeze
51
+ end
52
+ end
@@ -0,0 +1,96 @@
1
+ require "json"
2
+
3
+ module Hermetic
4
+ # Off-host executor CLIENT — the self-host trust boundary (spec §3). Wraps a
5
+ # local backend *kind* (:gvisor/:firecracker/:docker) and ships each run as
6
+ # JSON to a dedicated sandbox host that holds no database creds, no
7
+ # RAILS_MASTER_KEY, no ledger. This process keeps only the URL + token.
8
+ # EXECUTOR.md is the server side of this contract.
9
+ class Remote < Backends::Base
10
+ BACKENDS = %i[docker gvisor firecracker].freeze
11
+
12
+ attr_reader :executor, :backend
13
+
14
+ def initialize(executor:, token:, backend: :gvisor, backend_options: {}, transport: nil, **opts)
15
+ raise ConfigError, "executor URL is required" if executor.to_s.strip.empty?
16
+ raise ConfigError, "token is required — the executor only accepts authenticated runs" if token.to_s.strip.empty?
17
+ unless BACKENDS.include?(backend)
18
+ raise ConfigError, "unknown executor backend #{backend.inspect} (known: #{BACKENDS.inspect})"
19
+ end
20
+
21
+ super(trust: :remote, **opts)
22
+ @executor = executor.to_s.chomp("/")
23
+ @token = token
24
+ @backend = backend
25
+ @backend_options = backend_options.to_h
26
+ @transport = transport || Transport::Http.new
27
+ end
28
+
29
+ # Result#backend reports the isolation tech the executor runs; trust
30
+ # :remote carries the boundary — the two axes stay unconflated (spec §3).
31
+ def backend_name = @backend
32
+
33
+ # Pure: one run as an HTTP request spec. Idempotency-Key is deterministic
34
+ # for the same logical run — the executor-side dedup handle that makes a
35
+ # retry of an in-doubt run safe (spec §7 risk 2, EXECUTOR.md).
36
+ def run_spec(request)
37
+ {
38
+ method: :post,
39
+ url: "#{@executor}/run",
40
+ headers: {
41
+ "Authorization" => "Bearer #{@token}",
42
+ "Content-Type" => "application/json",
43
+ "Idempotency-Key" => request.idempotency_key
44
+ },
45
+ body: JSON.generate(
46
+ v: 1,
47
+ backend: @backend,
48
+ backend_options: @backend_options,
49
+ argv: request.exec_argv,
50
+ stdin: request.stdin,
51
+ files: request.files,
52
+ env: request.clean_env,
53
+ network: { egress: request.network.egress },
54
+ timeout: request.timeout,
55
+ limits: request.limits.to_h
56
+ )
57
+ }
58
+ end
59
+
60
+ private
61
+
62
+ # In-doubt discipline (v1: no automatic retry):
63
+ # wire failure -> the POST may have executed guest-side: in_doubt
64
+ # HTTP 4xx -> the executor refused before starting the run: plain failure
65
+ # HTTP 5xx -> the executor broke mid-flight: in_doubt
66
+ # A caller that retries replays the SAME logical run; the stable
67
+ # idempotency key lets a deduping executor collapse it.
68
+ def execute(request)
69
+ response = begin
70
+ @transport.call(run_spec(request))
71
+ rescue TransportError => e
72
+ raise TransportError.new(
73
+ "executor unreachable or response lost (in doubt, idempotency_key=#{request.idempotency_key}): #{e.message}",
74
+ in_doubt: true
75
+ )
76
+ end
77
+
78
+ unless response.success?
79
+ raise TransportError.new(
80
+ "executor returned HTTP #{response.status} (idempotency_key=#{request.idempotency_key}): " \
81
+ "#{response.body.to_s[0, 200]}",
82
+ in_doubt: response.status >= 500
83
+ )
84
+ end
85
+
86
+ payload = response.json
87
+ [ payload["stdout"].to_s, payload["stderr"].to_s,
88
+ payload["exit_status"].to_i, !!payload["timed_out"] ]
89
+ rescue JSON::ParserError
90
+ raise TransportError.new(
91
+ "executor returned unparseable JSON (in doubt, idempotency_key=#{request.idempotency_key})",
92
+ in_doubt: true
93
+ )
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,127 @@
1
+ require "digest"
2
+ require "json"
3
+ require "shellwords"
4
+
5
+ module Hermetic
6
+ # The normalized form of one `run` call. Backends never see raw kwargs —
7
+ # they see a Request whose invariants already hold: shell-vs-argv resolved,
8
+ # workdir-relative files only, clean default-deny env, integer host-enforced
9
+ # timeout, limits merged over box defaults.
10
+ class Request
11
+ SHELL = [ "/bin/sh", "-c" ].freeze
12
+
13
+ attr_reader :files, :stdin, :network, :timeout, :limits, :env_allowlist
14
+
15
+ def self.build(command, **kwargs) = new(command, **kwargs)
16
+
17
+ def initialize(command, files: {}, stdin: nil, env: {}, network: :none,
18
+ timeout: 30, limits: nil, default_limits: Limits.new,
19
+ credentials: nil, env_allowlist: [], host_env: ENV)
20
+ @command = normalize_command(command)
21
+ @files = normalize_files(files)
22
+ @stdin = stdin&.to_s
23
+ @explicit_env = normalize_env(env)
24
+ @network = NetworkPolicy.from(network)
25
+ @timeout = normalize_timeout(timeout)
26
+ @limits = default_limits.merge(limits)
27
+ @credentials = Credentials.build(credentials)
28
+ @env_allowlist = Array(env_allowlist).map(&:to_s).freeze
29
+ @host_env = host_env
30
+ end
31
+
32
+ def shell? = @command.is_a?(String)
33
+
34
+ # Full argv to exec. A String command runs under /bin/sh -c (matches
35
+ # Silas); an Array execs directly — no shell, no injection surface.
36
+ def exec_argv = shell? ? SHELL + [ @command ] : @command
37
+
38
+ # The command as a single shell string, for hosted APIs that take one.
39
+ def exec_string = shell? ? @command : Shellwords.join(@command)
40
+
41
+ # The guest environment: host env is default-deny. Precedence (low→high):
42
+ # allowlisted host vars < resolved credentials < explicit env:.
43
+ # Credentials resolve lazily, per call — never cached on the request.
44
+ def clean_env
45
+ allowlisted = @env_allowlist.each_with_object({}) do |name, acc|
46
+ acc[name] = @host_env[name].to_s if @host_env[name]
47
+ end
48
+ allowlisted.merge(@credentials.resolve).merge(@explicit_env)
49
+ end
50
+
51
+ def credential_names = @credentials.names
52
+
53
+ # Deterministic across retries of the same logical run — the executor-side
54
+ # dedup key for the remote in-doubt window (spec §7 risk 2). Credential and
55
+ # host-env VALUES are excluded: short-lived tokens must not change the key
56
+ # (and must never be digested at all).
57
+ def idempotency_key
58
+ @idempotency_key ||= Digest::SHA256.hexdigest(JSON.generate(canonical))[0, 32]
59
+ end
60
+
61
+ # Values are redacted; keys/command are the model-visible surface anyway.
62
+ def inspect
63
+ "#<Hermetic::Request cmd=#{exec_argv.inspect} files=#{files.keys.inspect} " \
64
+ "env=#{(@explicit_env.keys + credential_names).sort.inspect} " \
65
+ "network=#{@network} timeout=#{@timeout} [values redacted]>"
66
+ end
67
+ alias to_s inspect
68
+
69
+ private
70
+
71
+ def canonical
72
+ {
73
+ v: 1,
74
+ shell: shell?,
75
+ argv: exec_argv,
76
+ stdin: @stdin,
77
+ files: @files.sort.to_h,
78
+ env: @explicit_env.sort.to_h,
79
+ env_allowlist: @env_allowlist.sort,
80
+ credential_names: credential_names.sort,
81
+ egress: @network.egress,
82
+ timeout: @timeout,
83
+ limits: @limits.to_h
84
+ }
85
+ end
86
+
87
+ def normalize_command(command)
88
+ case command
89
+ when String
90
+ raise ConfigError, "command must not be empty" if command.strip.empty?
91
+
92
+ command
93
+ when Array
94
+ raise ConfigError, "argv must not be empty" if command.empty?
95
+ raise ConfigError, "argv must be an Array of Strings, got #{command.inspect}" unless command.all?(String)
96
+
97
+ command.dup.freeze
98
+ else
99
+ raise ConfigError, "command must be a String (/bin/sh -c) or an Array argv, got #{command.inspect}"
100
+ end
101
+ end
102
+
103
+ def normalize_files(files)
104
+ files.to_h.each_with_object({}) do |(path, contents), acc|
105
+ path = path.to_s
106
+ if path.empty? || path.start_with?("/") || path.split("/").include?("..")
107
+ raise ConfigError, "unsafe file path #{path.inspect} — workdir-relative only, no traversal"
108
+ end
109
+
110
+ acc[path] = contents.to_s
111
+ end.freeze
112
+ end
113
+
114
+ def normalize_env(env)
115
+ env.to_h.map { |k, v| [ k.to_s, v.to_s ] }.to_h.freeze
116
+ end
117
+
118
+ def normalize_timeout(timeout)
119
+ seconds = Integer(timeout)
120
+ raise ConfigError, "timeout must be positive, got #{timeout.inspect}" unless seconds.positive?
121
+
122
+ seconds
123
+ rescue ArgumentError, TypeError
124
+ raise ConfigError, "timeout must be an integer number of seconds, got #{timeout.inspect}"
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,15 @@
1
+ module Hermetic
2
+ # Superset of Silas::Sandbox::Result — the first three members are the ones
3
+ # Silas's run_code tool reads, so a Hermetic box drops into
4
+ # `config.sandbox = <object>` with zero Silas changes. trust/backend make
5
+ # the isolation posture legible on every result (see spec §3).
6
+ Result = Struct.new(
7
+ :stdout, :stderr, :exit_status,
8
+ :timed_out, :duration_ms, :backend, :trust,
9
+ keyword_init: true
10
+ ) do
11
+ def success? = exit_status.to_i.zero? && !timed_out
12
+
13
+ def timed_out? = !!timed_out
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ require "hermetic"
2
+
3
+ module Hermetic
4
+ # OPTIONAL Silas shim — `require "hermetic/silas"` from an app that runs
5
+ # Silas (spec §4). A sandbox run is a non-replayable external effect, so it
6
+ # must never happen inside a ledger transaction: a rollback after the guest
7
+ # ran would replay it on retry. Silas's own Docker adapter carries this
8
+ # guard; prepending here gives every Hermetic backend the same fail-loud
9
+ # posture. Without Silas loaded the check no-ops, so requiring this file is
10
+ # always safe.
11
+ module SilasLedgerGuard
12
+ def run(...)
13
+ if defined?(::Silas::Ledger) && ::Silas::Ledger.in_transaction?
14
+ raise Error, "sandbox exec inside a ledger transaction — sandbox-backed tools " \
15
+ "must be at_most_once!, never transactional!"
16
+ end
17
+
18
+ super
19
+ end
20
+ end
21
+
22
+ Backends::Base.prepend(SilasLedgerGuard)
23
+ end
@@ -0,0 +1,52 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Hermetic
6
+ module Transport
7
+ # Sends a backend-built request spec ({ method:, url:, headers:, body: })
8
+ # over net/http. The connection factory is injectable so unit tests never
9
+ # open a socket — fakes return canned responses or raise socket errors.
10
+ class Http
11
+ REQUESTS = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.freeze
12
+
13
+ Response = Struct.new(:status, :body, keyword_init: true) do
14
+ def json = JSON.parse(body)
15
+
16
+ def success? = (200..299).cover?(status)
17
+ end
18
+
19
+ def initialize(open_timeout: 5, read_timeout: 65, connector: nil)
20
+ @open_timeout = open_timeout
21
+ @read_timeout = read_timeout
22
+ @connector = connector || method(:default_connector)
23
+ end
24
+
25
+ def call(spec)
26
+ method = spec.fetch(:method) { raise ConfigError, "http spec needs :method" }.to_s.upcase
27
+ uri = URI(spec.fetch(:url) { raise ConfigError, "http spec needs :url" })
28
+ klass = REQUESTS.fetch(method) { raise ConfigError, "unsupported http method #{method.inspect}" }
29
+
30
+ request = klass.new(uri)
31
+ (spec[:headers] || {}).each { |name, value| request[name.to_s] = value.to_s }
32
+ request.body = spec[:body] if spec[:body]
33
+
34
+ raw = @connector.call(uri).request(request)
35
+ Response.new(status: raw.code.to_i, body: raw.body.to_s)
36
+ rescue SocketError, SystemCallError, IOError, EOFError,
37
+ Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
38
+ raise TransportError, "#{method} #{uri}: #{e.class}: #{e.message}"
39
+ end
40
+
41
+ private
42
+
43
+ def default_connector(uri)
44
+ http = Net::HTTP.new(uri.host, uri.port)
45
+ http.use_ssl = uri.scheme == "https"
46
+ http.open_timeout = @open_timeout
47
+ http.read_timeout = @read_timeout
48
+ http
49
+ end
50
+ end
51
+ end
52
+ end