Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 67 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/srb-x2q.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions lib/statifier/basichttp.rb
Original file line number Diff line number Diff line change
@@ -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"
153 changes: 153 additions & 0 deletions lib/statifier/basichttp/net_http_transport.rb
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions lib/statifier/basichttp/request.rb
Original file line number Diff line number Diff line change
@@ -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<Array(String, String)>] 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<Array(String, String)>] 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
Loading
Loading