diff --git a/lib/async/http/protocol/configurable.rb b/lib/async/http/protocol/configurable.rb new file mode 100644 index 0000000..b8c7178 --- /dev/null +++ b/lib/async/http/protocol/configurable.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +module Async + module HTTP + module Protocol + class Configured + def initialize(protocol, **options) + @protocol = protocol + @options = options + end + + # @attribute [Protocol] The underlying protocol. + attr :protocol + + # @attribute [Hash] The options to pass to the protocol. + attr :options + + def client(peer, **options) + options = @options.merge(options) + @protocol.client(peer, **options) + end + + def server(peer, **options) + options = @options.merge(options) + @protocol.server(peer, **options) + end + + def names + @protocol.names + end + end + + module Configurable + def new(**options) + Configured.new(self, **options) + end + end + end + end +end diff --git a/lib/async/http/protocol/defaulton.rb b/lib/async/http/protocol/defaulton.rb new file mode 100644 index 0000000..d9a915b --- /dev/null +++ b/lib/async/http/protocol/defaulton.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +module Async + module HTTP + module Protocol + # This module provides a default instance of the protocol, which can be used to create clients and servers. The name is a play on "Default" + "Singleton". + module Defaulton + def self.extended(base) + base.instance_variable_set(:@default, base.new) + end + + attr_accessor :default + + # Create a client for an outbound connection, using the default instance. + def client(peer, **options) + default.client(peer, **options) + end + + # Create a server for an inbound connection, using the default instance. + def server(peer, **options) + default.server(peer, **options) + end + + # @returns [Array] The names of the supported protocol, used for Application Layer Protocol Negotiation (ALPN), using the default instance. + def names + default.names + end + end + + private_constant :Defaulton + end + end +end diff --git a/lib/async/http/protocol/http.rb b/lib/async/http/protocol/http.rb index b70bb83..3864222 100644 --- a/lib/async/http/protocol/http.rb +++ b/lib/async/http/protocol/http.rb @@ -4,19 +4,33 @@ # Copyright, 2024, by Thomas Morgan. # Copyright, 2024, by Samuel Williams. +require_relative "defaulton" + require_relative "http1" require_relative "http2" module Async module HTTP module Protocol - # HTTP is an http:// server that auto-selects HTTP/1.1 or HTTP/2 by detecting the HTTP/2 - # connection preface. - module HTTP + # HTTP is an http:// server that auto-selects HTTP/1.1 or HTTP/2 by detecting the HTTP/2 connection preface. + class HTTP HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" HTTP2_PREFACE_SIZE = HTTP2_PREFACE.bytesize - def self.protocol_for(stream) + # Create a new HTTP protocol instance. + # + # @parameter http1 [HTTP1] The HTTP/1 protocol instance. + # @parameter http2 [HTTP2] The HTTP/2 protocol instance. + def initialize(http1: HTTP1, http2: HTTP2) + @http1 = http1 + @http2 = http2 + end + + # Determine if the inbound connection is HTTP/1 or HTTP/2. + # + # @parameter stream [IO::Stream] The stream to detect the protocol for. + # @returns [Class] The protocol class to use. + def protocol_for(stream) # Detect HTTP/2 connection preface # https://www.rfc-editor.org/rfc/rfc9113.html#section-3.4 preface = stream.peek do |read_buffer| @@ -29,27 +43,35 @@ def self.protocol_for(stream) end if preface == HTTP2_PREFACE - HTTP2 + @http2 else - HTTP1 + @http1 end end - # Only inbound connections can detect HTTP1 vs HTTP2 for http://. - # Outbound connections default to HTTP1. - def self.client(peer, **options) - HTTP1.client(peer, **options) + # Create a client for an outbound connection. Defaults to HTTP/1 for plaintext connections. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the protocol, keyed by protocol class. + def client(peer, **options) + options = options[@http1] || {} + + return @http1.client(peer, **options) end - def self.server(peer, **options) - stream = ::IO::Stream(peer) + # Create a server for an inbound connection. Able to detect HTTP1 and HTTP2. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the protocol, keyed by protocol class. + def server(peer, **options) + stream = IO::Stream(peer) + protocol = protocol_for(stream) + options = options[protocol] || {} - return protocol_for(stream).server(stream, **options) + return protocol.server(stream, **options) end - def self.names - ["h2", "http/1.1", "http/1.0"] - end + extend Defaulton end end end diff --git a/lib/async/http/protocol/http1.rb b/lib/async/http/protocol/http1.rb index 2d7b23a..3edc844 100644 --- a/lib/async/http/protocol/http1.rb +++ b/lib/async/http/protocol/http1.rb @@ -4,6 +4,8 @@ # Copyright, 2017-2024, by Samuel Williams. # Copyright, 2024, by Thomas Morgan. +require_relative "configurable" + require_relative "http1/client" require_relative "http1/server" @@ -13,28 +15,41 @@ module Async module HTTP module Protocol module HTTP1 + extend Configurable + VERSION = "HTTP/1.1" + # @returns [Boolean] Whether the protocol supports bidirectional communication. def self.bidirectional? true end + # @returns [Boolean] Whether the protocol supports trailers. def self.trailer? true end - def self.client(peer) + # Create a client for an outbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the client instance. + def self.client(peer, **options) stream = ::IO::Stream(peer) - return HTTP1::Client.new(stream, VERSION) + return HTTP1::Client.new(stream, VERSION, **options) end - def self.server(peer) + # Create a server for an inbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the server instance. + def self.server(peer, **options) stream = ::IO::Stream(peer) - return HTTP1::Server.new(stream, VERSION) + return HTTP1::Server.new(stream, VERSION, **options) end + # @returns [Array] The names of the supported protocol. def self.names ["http/1.1", "http/1.0"] end diff --git a/lib/async/http/protocol/http1/connection.rb b/lib/async/http/protocol/http1/connection.rb index 1ceed5d..7ba94c5 100755 --- a/lib/async/http/protocol/http1/connection.rb +++ b/lib/async/http/protocol/http1/connection.rb @@ -14,9 +14,10 @@ module HTTP module Protocol module HTTP1 class Connection < ::Protocol::HTTP1::Connection - def initialize(stream, version) - super(stream) + def initialize(stream, version, **options) + super(stream, **options) + # On the client side, we need to send the HTTP version with the initial request. On the server side, there are some scenarios (bad request) where we don't know the request version. In those cases, we use this value, which is either hard coded based on the protocol being used, OR could be negotiated during the connection setup (e.g. ALPN). @version = version end diff --git a/lib/async/http/protocol/http10.rb b/lib/async/http/protocol/http10.rb index e37308b..6920b77 100755 --- a/lib/async/http/protocol/http10.rb +++ b/lib/async/http/protocol/http10.rb @@ -10,28 +10,41 @@ module Async module HTTP module Protocol module HTTP10 + extend Configurable + VERSION = "HTTP/1.0" + # @returns [Boolean] Whether the protocol supports bidirectional communication. def self.bidirectional? false end + # @returns [Boolean] Whether the protocol supports trailers. def self.trailer? false end - def self.client(peer) + # Create a client for an outbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the client instance. + def self.client(peer, **options) stream = ::IO::Stream(peer) - return HTTP1::Client.new(stream, VERSION) + return HTTP1::Client.new(stream, VERSION, **options) end - def self.server(peer) + # Create a server for an inbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the server instance. + def self.server(peer, **options) stream = ::IO::Stream(peer) - return HTTP1::Server.new(stream, VERSION) + return HTTP1::Server.new(stream, VERSION, **options) end + # @returns [Array] The names of the supported protocol. def self.names ["http/1.0"] end diff --git a/lib/async/http/protocol/http11.rb b/lib/async/http/protocol/http11.rb index b29f246..814f7e3 100644 --- a/lib/async/http/protocol/http11.rb +++ b/lib/async/http/protocol/http11.rb @@ -11,28 +11,41 @@ module Async module HTTP module Protocol module HTTP11 + extend Configurable + VERSION = "HTTP/1.1" + # @returns [Boolean] Whether the protocol supports bidirectional communication. def self.bidirectional? true end + # @returns [Boolean] Whether the protocol supports trailers. def self.trailer? true end - def self.client(peer) + # Create a client for an outbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the client instance. + def self.client(peer, **options) stream = ::IO::Stream(peer) - return HTTP1::Client.new(stream, VERSION) + return HTTP1::Client.new(stream, VERSION, **options) end - def self.server(peer) + # Create a server for an inbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the server instance. + def self.server(peer, **options) stream = ::IO::Stream(peer) - return HTTP1::Server.new(stream, VERSION) + return HTTP1::Server.new(stream, VERSION, **options) end + # @returns [Array] The names of the supported protocol. def self.names ["http/1.1"] end diff --git a/lib/async/http/protocol/http2.rb b/lib/async/http/protocol/http2.rb index e7a5420..e9a8a84 100644 --- a/lib/async/http/protocol/http2.rb +++ b/lib/async/http/protocol/http2.rb @@ -4,6 +4,8 @@ # Copyright, 2018-2024, by Samuel Williams. # Copyright, 2024, by Thomas Morgan. +require_relative "configurable" + require_relative "http2/client" require_relative "http2/server" @@ -13,16 +15,21 @@ module Async module HTTP module Protocol module HTTP2 + extend Configurable + VERSION = "HTTP/2" + # @returns [Boolean] Whether the protocol supports bidirectional communication. def self.bidirectional? true end + # @returns [Boolean] Whether the protocol supports trailers. def self.trailer? true end + # The default settings for the client. CLIENT_SETTINGS = { ::Protocol::HTTP2::Settings::ENABLE_PUSH => 0, ::Protocol::HTTP2::Settings::MAXIMUM_FRAME_SIZE => 0x100000, @@ -30,6 +37,7 @@ def self.trailer? ::Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES => 1, } + # The default settings for the server. SERVER_SETTINGS = { # We choose a lower maximum concurrent streams to avoid overloading a single connection/thread. ::Protocol::HTTP2::Settings::MAXIMUM_CONCURRENT_STREAMS => 128, @@ -39,7 +47,11 @@ def self.trailer? ::Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES => 1, } - def self.client(peer, settings = CLIENT_SETTINGS) + # Create a client for an outbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the client instance. + def self.client(peer, settings: CLIENT_SETTINGS) stream = ::IO::Stream(peer) client = Client.new(stream) @@ -49,7 +61,11 @@ def self.client(peer, settings = CLIENT_SETTINGS) return client end - def self.server(peer, settings = SERVER_SETTINGS) + # Create a server for an inbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the server instance. + def self.server(peer, settings: SERVER_SETTINGS) stream = ::IO::Stream(peer) server = Server.new(stream) @@ -59,6 +75,7 @@ def self.server(peer, settings = SERVER_SETTINGS) return server end + # @returns [Array] The names of the supported protocol. def self.names ["h2"] end diff --git a/lib/async/http/protocol/http2/connection.rb b/lib/async/http/protocol/http2/connection.rb index 7757703..e2c2876 100644 --- a/lib/async/http/protocol/http2/connection.rb +++ b/lib/async/http/protocol/http2/connection.rb @@ -26,7 +26,7 @@ module HTTP2 TRAILER = "trailer".freeze module Connection - def initialize(*) + def initialize(...) super @reader = nil diff --git a/lib/async/http/protocol/https.rb b/lib/async/http/protocol/https.rb index a79784a..1fa4535 100644 --- a/lib/async/http/protocol/https.rb +++ b/lib/async/http/protocol/https.rb @@ -4,16 +4,18 @@ # Copyright, 2018-2024, by Samuel Williams. # Copyright, 2019, by Brian Morearty. +require_relative "defaulton" + require_relative "http10" require_relative "http11" - require_relative "http2" module Async module HTTP module Protocol # A server that supports both HTTP1.0 and HTTP1.1 semantics by detecting the version of the request. - module HTTPS + class HTTPS + # The protocol classes for each supported protocol. HANDLERS = { "h2" => HTTP2, "http/1.1" => HTTP11, @@ -21,7 +23,23 @@ module HTTPS nil => HTTP11, } - def self.protocol_for(peer) + def initialize(handlers = HANDLERS, **options) + @handlers = handlers + @options = options + end + + def add(name, protocol, **options) + @handlers[name] = protocol + @options[protocol] = options + end + + # Determine the protocol of the peer and return the appropriate protocol class. + # + # Use TLS Application Layer Protocol Negotiation (ALPN) to determine the protocol. + # + # @parameter peer [IO] The peer to communicate with. + # @returns [Class] The protocol class to use. + def protocol_for(peer) # alpn_protocol is only available if openssl v1.0.2+ name = peer.alpn_protocol @@ -34,18 +52,34 @@ def self.protocol_for(peer) end end - def self.client(peer) - protocol_for(peer).client(peer) + # Create a client for an outbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the client instance. + def client(peer, **options) + protocol = protocol_for(peer) + options = options[protocol] || {} + + protocol.client(peer, **options) end - def self.server(peer) - protocol_for(peer).server(peer) + # Create a server for an inbound connection. + # + # @parameter peer [IO] The peer to communicate with. + # @parameter options [Hash] Options to pass to the server instance. + def server(peer, **options) + protocol = protocol_for(peer) + options = options[protocol] || {} + + protocol.server(peer, **options) end - # Supported Application Layer Protocol Negotiation names: - def self.names - HANDLERS.keys.compact + # @returns [Array] The names of the supported protocol, used for Application Layer Protocol Negotiation (ALPN). + def names + @handlers.keys.compact end + + extend Defaulton end end end diff --git a/releases.md b/releases.md index 59bd24c..3dd42a1 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,66 @@ # Releases +## Unreleased + +### Support custom protocols with options + +{ruby Async::HTTP::Protocol} contains classes for specific protocols, e.g. {ruby Async::HTTP::Protocol::HTTP1} and {ruby Async::HTTP::Protocol::HTTP2}. It also contains classes for aggregating protocols, e.g. {ruby Async::HTTP::Protocol::HTTP} and {ruby Async::HTTP::Protocol::HTTPS}. They serve as factories for creating client and server instances. + +These classes are now configurable with various options, which are passed as keyword arguments to the relevant connection classes. For example, to configure an HTTP/1.1 protocol without keep-alive: + +```ruby +protocol = Async::HTTP::Protocol::HTTP1.new(persistent: false, maximum_line_length: 32) +endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292", protocol: protocol) +server = Async::HTTP::Server.for(endpoint) do |request| + Protocol::HTTP::Response[200, {}, ["Hello, world"]] +end.run +``` + +Making a request to the server will now close the connection after the response is received: + +``` +> curl -v http://localhost:9292 +* Host localhost:9292 was resolved. +* IPv6: ::1 +* IPv4: 127.0.0.1 +* Trying [::1]:9292... +* Connected to localhost (::1) port 9292 +* using HTTP/1.x +> GET / HTTP/1.1 +> Host: localhost:9292 +> User-Agent: curl/8.12.1 +> Accept: */* +> +* Request completely sent off +< HTTP/1.1 200 OK +< connection: close +< content-length: 12 +< +* shutting down connection #0 +Hello, world +``` + +In addition, any line longer than 32 bytes will be rejected: + +``` +curl -v http://localhost:9292/012345678901234567890123456789012 +* Host localhost:9292 was resolved. +* IPv6: ::1 +* IPv4: 127.0.0.1 +* Trying [::1]:9292... +* Connected to localhost (::1) port 9292 +* using HTTP/1.x +> GET /012345678901234567890123456789012 HTTP/1.1 +> Host: localhost:9292 +> User-Agent: curl/8.12.1 +> Accept: */* +> +* Request completely sent off +* Empty reply from server +* shutting down connection #0 +curl: (52) Empty reply from server +``` + ## v0.87.0 ### Unify HTTP/1 and HTTP/2 `CONNECT` semantics diff --git a/test/async/http/protocol/http.rb b/test/async/http/protocol/http.rb index eaaa947..7cb37cb 100755 --- a/test/async/http/protocol/http.rb +++ b/test/async/http/protocol/http.rb @@ -8,6 +8,38 @@ require "async/http/a_protocol" describe Async::HTTP::Protocol::HTTP do + let(:protocol) {subject.default} + + with ".default" do + it "has a default instance" do + expect(protocol).to be_a Async::HTTP::Protocol::HTTP + end + end + + with "#protocol_for" do + let(:buffer) {StringIO.new} + + it "it can detect http/1.1" do + buffer.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + buffer.rewind + + stream = IO::Stream(buffer) + + expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP1 + end + + it "it can detect http/2" do + # This special preface is used to indicate that the client would like to use HTTP/2. + # https://www.rfc-editor.org/rfc/rfc7540.html#section-3.5 + buffer.write("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") + buffer.rewind + + stream = IO::Stream(buffer) + + expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP2 + end + end + with "server" do include Sus::Fixtures::Async::HTTP::ServerContext let(:protocol) {subject} diff --git a/test/async/http/protocol/http1.rb b/test/async/http/protocol/http1.rb new file mode 100644 index 0000000..7bfa945 --- /dev/null +++ b/test/async/http/protocol/http1.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Thomas Morgan. +# Copyright, 2024, by Samuel Williams. + +require "async/http/protocol/http" +require "async/http/a_protocol" + +describe Async::HTTP::Protocol::HTTP1 do + with ".new" do + it "can configure the protocol" do + protocol = subject.new( + persistent: false, + maximum_line_length: 4096, + ) + + expect(protocol.options).to have_keys( + persistent: be == false, + maximum_line_length: be == 4096, + ) + end + end +end diff --git a/test/async/http/protocol/https.rb b/test/async/http/protocol/https.rb new file mode 100644 index 0000000..0624748 --- /dev/null +++ b/test/async/http/protocol/https.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "async/http/protocol/https" +require "async/http/a_protocol" + +describe Async::HTTP::Protocol::HTTPS do + let(:protocol) {subject.default} + + with ".default" do + it "has a default instance" do + expect(protocol).to be_a Async::HTTP::Protocol::HTTPS + end + + it "supports http/1.0" do + expect(protocol.names).to be(:include?, "http/1.0") + end + + it "supports http/1.1" do + expect(protocol.names).to be(:include?, "http/1.1") + end + + it "supports h2" do + expect(protocol.names).to be(:include?, "h2") + end + end + + with "#protocol_for" do + let(:buffer) {StringIO.new} + + it "can detect http/1.0" do + stream = IO::Stream(buffer) + expect(stream).to receive(:alpn_protocol).and_return("http/1.0") + + expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP10 + end + + it "it can detect http/1.1" do + stream = IO::Stream(buffer) + expect(stream).to receive(:alpn_protocol).and_return("http/1.1") + + expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP11 + end + + it "it can detect http/2" do + stream = IO::Stream(buffer) + expect(stream).to receive(:alpn_protocol).and_return("h2") + + expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP2 + end + end +end