diff --git a/CLAUDE.md b/CLAUDE.md index 31af75f..f67a3b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,6 +233,12 @@ as `Statifier::Effect::Child` values. chart accepts with the event descriptors the chart listens for and answers a `Statifier::AcceptsCheck` directly: the declared names no descriptor matches and the descriptors no declared name matches. +The HTTP transport, `Statifier::BasicHTTP` under `lib/statifier/basichttp/` +(loaded only by `require "statifier/basichttp"`, never by the core), is the +seam the Basic HTTP processor makes requests through: a callable taking a +frozen `Request` and answering a frozen `Response` or raising +`TransportError`, with `NetHTTPTransport` on the standard library's +Net::HTTP as the default. The conformance runner drives every case through the driver; the registry holds the scion, w3c and statifier cases the runner passes (the claims `scion`, `statifier`, `w3c-mandatory` and `w3c-optional`), and diff --git a/README.md b/README.md index 34a6112..35ec9ca 100644 --- a/README.md +++ b/README.md @@ -320,12 +320,75 @@ it into a fresh compile and drives the remaining steps, requiring the imported execution to agree with the unbroken one at every later step (`test/conformance/position_property.rb`). +## The HTTP transport + +The HTTP side of the gem is opt-in: `require "statifier/basichttp"` loads it, +and `require "statifier"` alone loads none of it and no HTTP library. What it +holds today is the HTTP transport, the seam between the Basic HTTP Event I/O +Processor of SCXML appendix C.2 and whatever makes a request. + +A transport is anything that responds to `call`: a lambda, a method object or +an object. It takes one request and makes it, and either answers the response +that came back or raises the transport error: + +- **The request**, `Statifier::BasicHTTP::Request`, a frozen value with + `http_method` (an HTTP token such as `"POST"`), `url` (an absolute URL), + `headers` (name and value pairs, in order, a name allowed more than once) + and `body` (a String, already encoded, empty for none). Building one copies + the strings it is given and refuses a method or header name that is not an + HTTP token and a header value holding a carriage return, a line feed or a + NUL. +- **The response**, `Statifier::BasicHTTP::Response`, a frozen value with + `status` (an Integer from 100 to 599), `headers` (name and value pairs) and + `body` (a String, empty for none). Every status is a response: a status + outside 2xx is answered, not raised. +- **The error**, `Statifier::BasicHTTP::TransportError`, the one error a + transport raises when no response came back: a URL it cannot request, a + connection refused or failed, a time bound passed, an exchange broken off. + The error that caused it, when there was one, is its `cause`. + +A transport makes one attempt; retrying is not its job. + +The default transport, `Statifier::BasicHTTP::NetHTTPTransport`, uses +Net::HTTP from the standard library (the `net-http` default gem), so it adds +no runtime dependency. Each call opens a connection of its own, verifies an +`https` peer as Net::HTTP does by default, and bounds each of opening the +connection, writing the request and reading the response by `timeout:` +seconds, five when absent. A host that needs other Net::HTTP settings (a +proxy, a certificate file) passes `connect:`, a callable that answers the +connection for a URL; `NetHTTPTransport.connection(uri, timeout:)` is the +default one, for a host's own to start from. + +```ruby +require "statifier/basichttp" + +request = Statifier::BasicHTTP::Request.new( + http_method: "POST", + url: "http://localhost:8080/s1", + headers: [["content-type", "text/plain"]], + body: "e.go" +) + +# A host's own transport, here one that answers every request in memory +echo = ->(req) { Statifier::BasicHTTP::Response.new(status: 200, body: req.body) } +echo.call(request).body # => "e.go" + +# The default transport +transport = Statifier::BasicHTTP::NetHTTPTransport.new(timeout: 2) +begin + transport.call(request).status +rescue Statifier::BasicHTTP::TransportError => e + e.message # why no response came back +end +``` + ## Compatibility - **Ruby 3.3 and later.** `required_ruby_version` in the gemspec is `>= 3.3`, and CI runs the test suite on Ruby 3.3, 3.4 and 4.0. - **One runtime dependency**, `predicator` (`>= 2.0`, `< 3`). The XML front - end is hand-written, so no XML gem is needed. + end is hand-written, so no XML gem is needed, and the default HTTP + transport uses Net::HTTP, a default gem. - **No shared mutable state.** The machine and the state are immutable values, there is no global configuration, and nothing is kept between calls but the state a call answers. Processors and the clock are passed with each @@ -368,7 +431,9 @@ needs and whether it passes or why it fails. - Every public method and class carries a YARD doc comment in `lib/`: the `Statifier` module's eight calls and `Statifier::Result`, the driver's values (`Statifier::Driver::Moved` and `DoneStatus`), `Statifier::Effect`, - `Statifier::Event` and `Statifier::Position`. + `Statifier::Event` and `Statifier::Position`, and the HTTP transport's + request, response, error and default transport under + `Statifier::BasicHTTP`. - [The changelog](CHANGELOG.md): what changed in each version. - [The conformance apparatus](conformance/README.md): what is vendored, from where, and how the check, the runner and the ratchet work. diff --git a/changelog.d/srb-x2q.md b/changelog.d/srb-x2q.md new file mode 100644 index 0000000..faffadd --- /dev/null +++ b/changelog.d/srb-x2q.md @@ -0,0 +1,5 @@ +### Added + +* `require "statifier/basichttp"` loads the HTTP transport, the seam a Basic HTTP processor makes its requests through; `require "statifier"` alone loads none of it and no HTTP library. +* A transport is any callable that takes a `Statifier::BasicHTTP::Request` (`http_method`, `url`, `headers`, `body`) and answers a `Statifier::BasicHTTP::Response` (`status`, `headers`, `body`), whatever its status, or raises `Statifier::BasicHTTP::TransportError` when no response came back; both values are frozen, and building one copies what it is given. +* `Statifier::BasicHTTP::NetHTTPTransport` is the default transport, on the standard library's Net::HTTP: one attempt per call, each step bounded by `timeout:` seconds (five when absent), and `connect:` for a host's own Net::HTTP connection. diff --git a/lib/statifier/basichttp.rb b/lib/statifier/basichttp.rb new file mode 100644 index 0000000..3e40cdd --- /dev/null +++ b/lib/statifier/basichttp.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../statifier" + +module Statifier + # The Basic HTTP Event I/O Processor's side of the package (SCXML + # appendix C.2), loaded only by `require "statifier/basichttp"`: + # `require "statifier"` alone loads none of it, and so loads no HTTP + # library. + # + # What it holds today is the HTTP transport, the seam between a processor + # and whatever makes a request. A transport is anything that responds to + # `call` (a lambda, a method object, an object): it takes one {Request} + # (the method, the URL, the headers and the body) and makes it, and it + # answers the {Response} that came back (the status, the headers and the + # body), whatever its status, or raises a {TransportError} when no response + # came back. {NetHTTPTransport} is the default transport, on the standard + # library's Net::HTTP, and a host may pass any other callable of the same + # contract instead. + module BasicHTTP + # A transport answered no response: the URL could not be requested, the + # connection failed or was refused, a time bound passed, or the exchange + # broke off. This is the one error a transport raises for a request it + # could not make; the error that caused it, when there was one, is its + # `cause`. + class TransportError < StandardError; end + end +end + +require_relative "basichttp/request" +require_relative "basichttp/response" +require_relative "basichttp/net_http_transport" diff --git a/lib/statifier/basichttp/net_http_transport.rb b/lib/statifier/basichttp/net_http_transport.rb new file mode 100644 index 0000000..e73dcff --- /dev/null +++ b/lib/statifier/basichttp/net_http_transport.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" + +module Statifier + module BasicHTTP + # The default transport, on the standard library's Net::HTTP (the + # `net-http` default gem), so it adds no runtime dependency. + # + # Each {#call} makes one request once, on a connection of its own that + # it opens and closes, and answers the {Response}, whatever its status; + # retrying is not a transport's job. It raises a {TransportError} when no + # response came back: a URL that is not an absolute `http` or `https` URL + # with a host (no connection is then opened); any error raised while it + # connects, sends or reads, a host's `connect` included, which is then + # the error's `cause`; or a status line outside 100 to 599. + # + # Net::HTTP adds the headers it always adds (`host`, and the + # `accept-encoding`, `accept` and `user-agent` defaults) unless the + # request names them, and a response body it decodes from `gzip` or + # `deflate` is answered decoded. Two headers of one name in a request are + # sent as one header, their values joined by a comma. + # + # @example + # transport = Statifier::BasicHTTP::NetHTTPTransport.new(timeout: 2) + # request = Statifier::BasicHTTP::Request.new(http_method: "POST", url: "http://localhost:8080/s1", body: "e.go") + # begin + # transport.call(request).status # => 200 + # rescue Statifier::BasicHTTP::TransportError => e + # e.message # why no response came back + # end + class NetHTTPTransport + # How long, in seconds, each of opening the connection, writing the + # request and reading the response may take when the host names no + # bound: the reference's five seconds. + DEFAULT_TIMEOUT = 5 + + # The connection the default transport opens for `uri`: a Net::HTTP + # object, not yet started, for its host and port, using TLS for an + # `https` URL (with Net::HTTP's default, the peer verified against the + # default certificate store), each of its open, TLS handshake, write + # and read bounds `timeout` seconds. A host's own `connect:` callable + # may call this and change the answer before it returns it (a proxy, a + # certificate file). + # + # @param uri [URI::HTTP] the URL requested + # @param timeout [Numeric] the bound on each step, in seconds + # @return [Net::HTTP] the connection, not started + def self.connection(uri, timeout: DEFAULT_TIMEOUT) + Net::HTTP.new(uri.host, uri.port).tap do |http| + http.use_ssl = uri.scheme == "https" + http.open_timeout = timeout + http.ssl_timeout = timeout + http.write_timeout = timeout + http.read_timeout = timeout + end + end + + # @param timeout [Numeric] the bound, in seconds, on each of opening + # the connection, writing the request and reading the response; + # {DEFAULT_TIMEOUT} when absent + # @param connect [#call, nil] answers the connection for a URL, called + # with the request's `URI::HTTP`; the connection answers `start` + # with a block, yielding something that answers `request` with a + # `Net::HTTPGenericRequest` and returns its `Net::HTTPResponse`, as + # a Net::HTTP object does. {connection} with `timeout` when nil; a + # host's own sets its own bounds, so the two are not given together + # @raise [ArgumentError] for a timeout that is not a positive finite + # number, a `connect` that does not answer `call`, or both given + def initialize(timeout: nil, connect: nil) + raise ArgumentError, "give timeout or connect, not both" if timeout && connect + raise ArgumentError, "expected connect to answer call" unless connect.nil? || connect.respond_to?(:call) + + @timeout = timeout.nil? ? DEFAULT_TIMEOUT : checked_timeout(timeout) + @connect = connect + freeze + end + + # The connection a {#call} for `uri` opens, not yet started: what the + # host's `connect` answers for it, or {connection} with this + # transport's timeout when the host gave none. + # + # @param uri [URI::HTTP] the URL requested + # @return [Net::HTTP, Object] the connection + def connect(uri) = @connect ? @connect.call(uri) : self.class.connection(uri, timeout: @timeout) + + # Makes `request` once and answers what came back. + # + # @param request [Request] the request + # @return [Response] the response, whatever its status + # @raise [TransportError] when no response came back + # @raise [TypeError] for a request that is not a {Request} + def call(request) + raise TypeError, "expected a #{Request}, got #{request.class}" unless request.is_a?(Request) + + make(request) + end + + private + + # The request made, every error but a {TransportError} raised as one + # whose `cause` it is. + def make(request) + uri = target(request.url) + response(exchange(uri, net_request(request, uri))) + rescue TransportError + raise + rescue StandardError => e + raise TransportError, "#{e.class}: #{e.message}" + end + + def checked_timeout(timeout) + return timeout if timeout.is_a?(Numeric) && timeout.real? && timeout.positive? && timeout.to_f.finite? + + raise ArgumentError, "expected timeout to be a positive finite number of seconds, got #{timeout.inspect}" + end + + # The request's URL as an absolute `http` or `https` URI with a host. + def target(url) + uri = URI.parse(url) + return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? + + raise TransportError, "not an absolute http or https URL: #{url}" + rescue URI::InvalidURIError => e + raise TransportError, "not a URL: #{e.message}" + end + + # `request` as Net::HTTP's request for `uri`: the first header of a + # name replaces Net::HTTP's default of that name, and a later one is + # added to it. + def net_request(request, uri) + net = Net::HTTPGenericRequest.new(request.http_method, true, request.http_method != "HEAD", uri.request_uri) + named = {} + request.headers.each do |name, value| + named.key?(name.downcase) ? net.add_field(name, value) : net[name] = value + named[name.downcase] = true + end + net.body = request.body + net + end + + def exchange(uri, net_request) + connect(uri).start { |http| http.request(net_request) } + end + + def response(net_response) + headers = net_response.to_hash.flat_map { |name, values| values.map { [name, _1] } } + Response.new(status: Integer(net_response.code, 10), headers:, body: net_response.body.to_s) + end + end + end +end diff --git a/lib/statifier/basichttp/request.rb b/lib/statifier/basichttp/request.rb new file mode 100644 index 0000000..5e56938 --- /dev/null +++ b/lib/statifier/basichttp/request.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Statifier + module BasicHTTP + # One request a transport makes, a frozen value, deeply: the strings it + # is built from are copied, never frozen in place. A transport sends it + # as it is handed and changes nothing in it. + # + # @example + # request = Statifier::BasicHTTP::Request.new( + # http_method: "POST", + # url: "http://localhost:8080/s1", + # headers: [["content-type", "text/plain"]], + # body: "e.go" + # ) + # + # @!attribute [r] http_method + # @return [String] the HTTP method, an HTTP token such as `"POST"` + # @!attribute [r] url + # @return [String] the absolute URL requested + # @!attribute [r] headers + # @return [Array] name and value pairs, in + # order; a name may appear more than once. Each name is an HTTP + # token, and no value holds a carriage return, a line feed or a NUL + # @!attribute [r] body + # @return [String] the body, already encoded; empty for none + Request = ::Data.define(:http_method, :url, :headers, :body) do + # @param http_method [String] the HTTP method + # @param url [String] the absolute URL + # @param headers [Array] name and value pairs; + # none when absent + # @param body [String] the body; empty when absent + # @raise [TypeError] for a member of the wrong kind + # @raise [ArgumentError] for a method or a header name that is not an + # HTTP token, or a header value holding a carriage return, a line + # feed or a NUL + def initialize(http_method:, url:, headers: [], body: "") + super( + http_method: Values.token(http_method, "http_method"), + url: Values.string(url, "url"), + headers: Values.headers(headers), + body: Values.string(body, "body") + ) + end + end + + # The checks and copies {Request} and {Response} make of what they are + # built from. + # + # @api private + module Values + module_function + + # An HTTP token (RFC 9110, section 5.6.2). + TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/ + + # `value`, a String, as a frozen copy. + def string(value, field) + raise TypeError, "expected #{field} to be a String, got #{value.class}" unless value.is_a?(String) + + value.frozen? ? value : value.dup.freeze + end + + # `value`, an HTTP token, as a frozen copy. + def token(value, field) + copy = string(value, field) + raise ArgumentError, "expected #{field} to be an HTTP token, got #{copy.inspect}" unless TOKEN.match?(copy) + + copy + end + + # `value`, a list of name and value pairs, as a frozen copy. + def headers(value) + raise TypeError, "expected headers to be an Array of pairs, got #{value.class}" unless value.is_a?(Array) + + value.map { header(_1) }.freeze + end + + # One name and value pair, as a frozen copy. + def header(pair) + unless pair.is_a?(Array) && pair.size == 2 + raise TypeError, "expected each header to be a [name, value] pair, got #{pair.inspect}" + end + + name = token(pair[0], "a header name") + value = string(pair[1], "a header value") + raise ArgumentError, "the value of header #{name} holds CR, LF or NUL" if value.match?(/[\r\n\0]/) + + [name, value].freeze + end + end + end +end diff --git a/lib/statifier/basichttp/response.rb b/lib/statifier/basichttp/response.rb new file mode 100644 index 0000000..963f072 --- /dev/null +++ b/lib/statifier/basichttp/response.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Statifier + module BasicHTTP + # The response a transport answers, whatever its status: a status + # outside 2xx is a response, not a {TransportError}. A frozen value, + # deeply: the strings it is built from are copied, never frozen in place. + # + # @!attribute [r] status + # @return [Integer] the HTTP status code, from 100 to 599 + # @!attribute [r] headers + # @return [Array] name and value pairs, in the + # order the transport read them; a name may appear more than once. + # {NetHTTPTransport} answers every name in lower case + # @!attribute [r] body + # @return [String] the body as the transport read it; empty for none + Response = ::Data.define(:status, :headers, :body) do + # @param status [Integer] the HTTP status code + # @param headers [Array] name and value pairs; + # none when absent + # @param body [String] the body; empty when absent + # @raise [TypeError] for a member of the wrong kind + # @raise [ArgumentError] for a status outside 100 to 599, or a header + # as {Request} refuses one + def initialize(status:, headers: [], body: "") + raise TypeError, "expected status to be an Integer, got #{status.class}" unless status.is_a?(Integer) + raise ArgumentError, "expected status to be from 100 to 599, got #{status}" unless (100..599).cover?(status) + + super(status:, headers: Values.headers(headers), body: Values.string(body, "body")) + end + end + end +end diff --git a/test/basichttp/basichttp_helper.rb b/test/basichttp/basichttp_helper.rb new file mode 100644 index 0000000..5cc1572 --- /dev/null +++ b/test/basichttp/basichttp_helper.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "statifier/basichttp" +require "stringio" + +# The local test double the default transport is driven against: a +# connection that opens no socket. It takes the request Net::HTTP built and +# writes it, through Net::HTTP's own serializer, into a string, and it +# answers a response Net::HTTP's own parser reads from canned bytes. +module BasicHTTPHelper + B = Statifier::BasicHTTP + + # The members of the request the tests make, unless one names another. + REQUEST = { http_method: "POST", url: "http://localhost:8080/s1" }.freeze + + # A connection in place of a Net::HTTP object, holding what it was asked. + class Connection + attr_reader :started, :wire, :net_request + + # raw is the response's bytes; error, when given, is raised by request. + def initialize(raw = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", error: nil) + @raw = raw + @error = error + @started = 0 + end + + def start + @started += 1 + yield self + end + + def request(net_request) + raise @error if @error + + @net_request = net_request + out = StringIO.new + net_request.exec(Net::BufferedIO.new(out), "1.1", net_request.path) + @wire = out.string + parse(@raw) + end + + private + + def parse(raw) + socket = Net::BufferedIO.new(StringIO.new(raw)) + response = Net::HTTPResponse.read_new(socket) + response.reading_body(socket, @net_request.response_body_permitted?) { nil } + response + end + end + + # A transport over connection, recording every URI it was asked to + # connect to in uris. + def transport_over(connection, uris = []) + B::NetHTTPTransport.new(connect: lambda { |uri| + uris << uri + connection + }) + end + + def request(**members) + B::Request.new(**REQUEST, **members) + end +end diff --git a/test/basichttp/load_test.rb b/test/basichttp/load_test.rb new file mode 100644 index 0000000..dd95895 --- /dev/null +++ b/test/basichttp/load_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" +require "rbconfig" + +# The HTTP side of the package is opt-in: `require "statifier"` loads no +# HTTP library and none of `Statifier::BasicHTTP`, and only +# `require "statifier/basichttp"` loads them. Each is asked of a fresh Ruby +# process, since this suite's own process loads both. +class BasicHTTPLoadTest < Minitest::Test + LIB = File.expand_path("../../lib", __dir__) + + PROBE = <<~RUBY + require ARGV.fetch(0) + puts [ + $LOADED_FEATURES.any? { |path| path.end_with?("/net/http.rb") }, + !defined?(Net::HTTP).nil?, + Statifier.const_defined?(:BasicHTTP, false) + ].inspect + RUBY + + # What a fresh process that requires `feature` has loaded: net/http, + # Net::HTTP and Statifier::BasicHTTP, in that order. + def loaded_after(feature) + out, status = Open3.capture2e(RbConfig.ruby, "-I", LIB, "-e", PROBE, feature) + assert_predicate status, :success?, out + out.lines.last.strip + end + + # sabotage: add `require_relative "statifier/basichttp"` to the end of lib/statifier.rb; the core then loads net/http. + def test_the_core_loads_no_http_library + assert_equal "[false, false, false]", loaded_after("statifier") + end + + # sabotage: drop `require "net/http"` from net_http_transport.rb; Net::HTTP is then not loaded. + def test_the_opt_in_require_loads_the_transport + assert_equal "[true, true, true]", loaded_after("statifier/basichttp") + end +end diff --git a/test/basichttp/net_http_transport_test.rb b/test/basichttp/net_http_transport_test.rb new file mode 100644 index 0000000..cbb100c --- /dev/null +++ b/test/basichttp/net_http_transport_test.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "basichttp_helper" + +# Statifier::BasicHTTP::NetHTTPTransport, the default transport, driven +# against a local test double below Net::HTTP (BasicHTTPHelper::Connection): +# Net::HTTP builds, writes and reads each message, and no socket is opened. +class NetHTTPTransportTest < Minitest::Test + include BasicHTTPHelper + + CREATED = "HTTP/1.1 201 Created\r\nContent-Type: text/plain\r\nSet-Cookie: a=1\r\n" \ + "Set-Cookie: b=2\r\nContent-Length: 2\r\n\r\nok" + + # sabotage: drop `net.body = request.body` in net_request; the wire lacks the body. + def test_the_request_goes_on_the_wire_as_it_was_built + connection = Connection.new + headers = [%w[content-type text/plain], %w[scxml-send-key k1]] + transport_over(connection).call(request(url: "http://localhost:8080/s1?q=1", headers:, body: "e.go")) + head, body = connection.wire.split("\r\n\r\n", 2) + lines = head.split("\r\n") + + assert_equal "POST /s1?q=1 HTTP/1.1", lines.first + assert_equal ["Content-Type: text/plain", "Scxml-Send-Key: k1", "Content-Length: 4"], + lines.grep(/\A(Content-Type|Scxml-Send-Key|Content-Length):/) + assert_equal "e.go", body + end + + # sabotage: connect to a fixed URI in exchange; the URI recorded is not the request's. + def test_one_connection_is_opened_for_the_request_url + connection = Connection.new + uris = [] + transport_over(connection, uris).call(request(url: "http://localhost:8080/s1")) + + assert_equal([["localhost", 8080, "http"]], uris.map { [_1.host, _1.port, _1.scheme] }) + assert_equal 1, connection.started + end + + # sabotage: always add_field in net_request; the wire carries Net::HTTP's default user-agent too. + def test_a_header_replaces_the_default_of_its_name_and_a_repeat_adds_to_it + connection = Connection.new + transport_over(connection).call(request(headers: [%w[user-agent t1], %w[x-a 1], %w[X-A 2]])) + lines = connection.wire.split("\r\n\r\n", 2).first.split("\r\n") + + assert_equal ["User-Agent: t1"], lines.grep(/\AUser-Agent:/) + assert_equal ["X-A: 1, 2"], lines.grep(/\AX-A:/) + end + + # sabotage: drop the flat_map over each name's values in response (keep the first); set-cookie loses b=2. + def test_the_response_is_answered_with_its_status_headers_and_body + answered = transport_over(Connection.new(CREATED)).call(request) + + assert_equal 201, answered.status + assert_equal [%w[content-type text/plain], %w[set-cookie a=1], %w[set-cookie b=2], %w[content-length 2]], + answered.headers + assert_equal "ok", answered.body + assert Ractor.shareable?(answered), "the response is frozen deeply" + end + + # sabotage: clamp the status to 200..299 in response; the 503 is answered as 299. + def test_a_status_outside_2xx_is_a_response_not_an_error + answered = transport_over(Connection.new("HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n")) + .call(request) + + assert_equal [503, ""], [answered.status, answered.body] + end + + # sabotage: answer a nil body as "none" in response; the body is not empty. + def test_a_response_with_no_body_is_answered_with_an_empty_one + answered = transport_over(Connection.new("HTTP/1.1 204 No Content\r\n\r\n")).call(request) + + assert_equal [204, ""], [answered.status, answered.body] + end + + # sabotage: drop the `rescue StandardError` clause in make; the connection's own error escapes. + def test_an_error_while_connecting_or_exchanging_is_a_transport_error_with_its_cause + [Errno::ECONNREFUSED.new("connect(2)"), Net::OpenTimeout.new("execution expired"), EOFError.new("end of file")] + .each do |error| + raised = assert_raises(B::TransportError) { transport_over(Connection.new(error: error)).call(request) } + + assert_same error, raised.cause + assert_includes raised.message, error.class.name + end + end + + # sabotage: drop the 100..599 check in Response#initialize; the 600 status is answered. + def test_a_status_line_outside_100_to_599_is_a_transport_error + assert_raises(B::TransportError) do + transport_over(Connection.new("HTTP/1.1 600 Odd\r\nContent-Length: 0\r\n\r\n")).call(request) + end + end + + # sabotage: return uri unchecked in target; ftp and hostless URLs reach the connection. + def test_a_url_that_is_not_an_absolute_http_url_opens_no_connection + uris = [] + ["ftp://localhost/s1", "http://", "/s1", "http://local host/", "localhost:8080"].each do |url| + assert_raises(B::TransportError, url) { transport_over(Connection.new, uris).call(request(url:)) } + end + + assert_empty uris + end + + # sabotage: drop the Request check in call; a NoMethodError becomes a TransportError instead. + def test_a_request_that_is_not_a_request_value_is_a_type_error + assert_raises(TypeError) { transport_over(Connection.new).call({ http_method: "POST" }) } + end + + # sabotage: permit a response body for every method in net_request; the HEAD response's body is read. + def test_a_head_request_reads_no_body + answered = transport_over(Connection.new("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")) + .call(request(http_method: "HEAD")) + + assert_equal [200, ""], [answered.status, answered.body] + end + + # sabotage: drop `use_ssl =` in connection; the https connection is not TLS. + def test_the_default_connection_is_an_unstarted_net_http_with_its_bounds + https = B::NetHTTPTransport.connection(URI("https://localhost:8443/s1"), timeout: 2) + + assert_instance_of Net::HTTP, https + assert_equal ["localhost", 8443, true, false], [https.address, https.port, https.use_ssl?, https.started?] + assert_equal [2, 2, 2, 2], [https.open_timeout, https.ssl_timeout, https.write_timeout, https.read_timeout] + end + + # sabotage: set use_ssl = true unconditionally in connection; the http connection is TLS. + def test_the_default_connection_for_http_is_plain_with_the_default_bound + http = B::NetHTTPTransport.connection(URI("http://localhost/s1")) + + assert_equal [80, false, 5], [http.port, http.use_ssl?, http.read_timeout] + end + + # sabotage: drop `timeout: @timeout` from connect; the connection takes the default 5. + def test_the_default_connect_uses_the_timeout_given + connection = B::NetHTTPTransport.new(timeout: 0.5).connect(URI("http://localhost/")) + + assert_equal [0.5, false], [connection.read_timeout, connection.started?] + assert_equal 5, B::NetHTTPTransport.new.connect(URI("http://localhost/")).read_timeout + end + + # sabotage: ignore @connect in connect; the host's connection is not the one answered. + def test_a_host_connect_answers_the_connection + connection = Connection.new + + assert_same connection, transport_over(connection).connect(URI("http://localhost/")) + end + + # sabotage: drop the both-given check in initialize; no ArgumentError is raised. + def test_the_options_are_checked + assert_raises(ArgumentError) { B::NetHTTPTransport.new(timeout: 1, connect: ->(_uri) {}) } + assert_raises(ArgumentError) { B::NetHTTPTransport.new(connect: :not_callable) } + [0, -1, Float::INFINITY, Float::NAN, "5", Complex(1, 1)].each do |timeout| + assert_raises(ArgumentError, timeout.inspect) { B::NetHTTPTransport.new(timeout:) } + end + assert_predicate B::NetHTTPTransport.new, :frozen? + end +end diff --git a/test/basichttp/values_test.rb b/test/basichttp/values_test.rb new file mode 100644 index 0000000..5a79902 --- /dev/null +++ b/test/basichttp/values_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "basichttp_helper" + +# Statifier::BasicHTTP::Request and Response: the values a transport takes +# and answers, frozen deeply, built from copies of what a caller passes. +class BasicHTTPValuesTest < Minitest::Test + include BasicHTTPHelper + + # sabotage: return value unfrozen in Values.string; the shareable assertion fails. + def test_a_request_is_a_deeply_frozen_value + built = request(url: +"http://localhost:8080/s1", headers: [[+"x-a", +"1"]], body: +"e.go") + + assert_equal ["POST", "http://localhost:8080/s1", [%w[x-a 1]], "e.go"], + [built.http_method, built.url, built.headers, built.body] + assert Ractor.shareable?(built), "a request is frozen deeply" + end + + # sabotage: freeze value in place in Values.string; the host's strings come back frozen. + def test_a_request_copies_what_it_is_built_from_and_freezes_none_of_it + url = +"http://localhost:8080/s1" + pair = [+"x-a", +"1"] + body = +"e.go" + request(url:, headers: [pair], body:) + + assert_equal [false, false, false, false], [url, body, pair, pair[0]].map(&:frozen?) + end + + # sabotage: default headers: to [["x-a", "1"]] in Request#initialize; the headers are not empty. + def test_a_request_defaults_to_no_headers_and_an_empty_body + built = B::Request.new(http_method: "GET", url: "http://localhost/") + + assert_equal [[], ""], [built.headers, built.body] + end + + # sabotage: drop the TOKEN check in Values.token; no ArgumentError is raised. + def test_a_request_refuses_a_method_or_header_name_that_is_not_a_token + assert_raises(ArgumentError) { request(http_method: "PO ST") } + assert_raises(ArgumentError) { request(http_method: "") } + assert_raises(ArgumentError) { request(headers: [["x a", "1"]]) } + end + + # sabotage: drop the CR, LF and NUL check in Values.header; no ArgumentError is raised. + def test_a_request_refuses_a_header_value_that_could_split_the_message + ["1\r\nx-b: 2", "1\n", "1\0"].each do |value| + assert_raises(ArgumentError, value.inspect) { request(headers: [["x-a", value]]) } + end + end + + # sabotage: test respond_to?(:map) for is_a?(Array) in Values.headers; the Hash is taken and no TypeError is raised. + def test_a_request_refuses_members_of_the_wrong_kind + assert_raises(TypeError) { request(url: :url) } + assert_raises(TypeError) { request(body: nil) } + assert_raises(TypeError) { request(headers: { "x-a" => "1" }) } + assert_raises(TypeError) { request(headers: [["x-a"]]) } + assert_raises(TypeError) { request(headers: [["x-a", 1]]) } + end + + # sabotage: drop the range check in Response#initialize; no ArgumentError is raised. + def test_a_response_holds_a_status_from_one_hundred_to_five_hundred_ninety_nine + assert_equal 599, B::Response.new(status: 599).status + assert_raises(ArgumentError) { B::Response.new(status: 99) } + assert_raises(ArgumentError) { B::Response.new(status: 600) } + assert_raises(TypeError) { B::Response.new(status: "200") } + end + + # sabotage: pass headers through uncopied in Response#initialize; the shareable assertion fails. + def test_a_response_is_a_deeply_frozen_copy_of_what_it_is_built_from + pair = [+"content-type", +"text/plain"] + body = +"ok" + built = B::Response.new(status: 200, headers: [pair], body:) + + assert_equal [200, [%w[content-type text/plain]], "ok"], [built.status, built.headers, built.body] + assert Ractor.shareable?(built), "a response is frozen deeply" + assert_equal [false, false], [pair, body].map(&:frozen?) + end + + # sabotage: default body: to "x" in Response#initialize; the body is not empty. + def test_a_response_defaults_to_no_headers_and_an_empty_body + built = B::Response.new(status: 204) + + assert_equal [[], ""], [built.headers, built.body] + end + + # A transport is any callable of the contract: here a lambda in place of + # the default one. + def test_a_lambda_is_a_transport + transport = ->(req) { B::Response.new(status: 202, body: req.body) } + + answered = transport.call(request(body: "e.go")) + + assert_equal [202, "e.go"], [answered.status, answered.body] + end +end