From 73d26857bc195e205d2f954d5e5936be5406fe71 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 30 Aug 2024 20:24:44 +1200 Subject: [PATCH] Support for interim responses. --- guides/design-overview/readme.md | 18 ++++++++++++++++ lib/protocol/http/request.rb | 26 +++++++++++++++++++++--- readme.md | 1 + releases.md | 23 +++++++++++++++++++++ test/protocol/http/request.rb | 35 ++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 3 deletions(-) diff --git a/guides/design-overview/readme.md b/guides/design-overview/readme.md index 52ece61..e4be3a1 100644 --- a/guides/design-overview/readme.md +++ b/guides/design-overview/readme.md @@ -189,3 +189,21 @@ response.read -> "dlroW olleH" ~~~ The value of this uni-directional flow is that it is natural for the stream to be taken out of the scope imposed by the nested `call(request)` model. However, the user must explicitly close the stream, since it's no longer scoped to the client and/or server. + +## Interim Response Handling + +Interim responses are responses that are sent before the final response. They are used for things like `103 Early Hints` and `100 Continue`. These responses are sent before the final response, and are used to signal to the client that the server is still processing the request. + +```ruby +body = Body::Writable.new + +interim_response_callback = proc do |status, headers| + if status == 100 + # Continue sending the request body. + body.write("Hello World") + body.close + end +end + +response = client.post("/upload", {'expect' => '100-continue'}, body, interim_response: interim_response_callback) +``` diff --git a/lib/protocol/http/request.rb b/lib/protocol/http/request.rb index 544b7f0..3bcf120 100644 --- a/lib/protocol/http/request.rb +++ b/lib/protocol/http/request.rb @@ -25,7 +25,7 @@ module HTTP class Request prepend Body::Reader - def initialize(scheme = nil, authority = nil, method = nil, path = nil, version = nil, headers = Headers.new, body = nil, protocol = nil) + def initialize(scheme = nil, authority = nil, method = nil, path = nil, version = nil, headers = Headers.new, body = nil, protocol = nil, interim_response = nil) @scheme = scheme @authority = authority @method = method @@ -34,6 +34,7 @@ def initialize(scheme = nil, authority = nil, method = nil, path = nil, version @headers = headers @body = body @protocol = protocol + @interim_response = interim_response end # @attribute [String] the request scheme, usually `"http"` or `"https"`. @@ -60,11 +61,30 @@ def initialize(scheme = nil, authority = nil, method = nil, path = nil, version # @attribute [String | Array(String) | Nil] the request protocol, usually empty, but occasionally `"websocket"` or `"webtransport"`. In HTTP/1, it is used to request a connection upgrade, and in HTTP/2 it is used to indicate a specfic protocol for the stream. attr_accessor :protocol + # @attribute [Proc] a callback which is called when an interim response is received. + attr_accessor :interim_response + # Send the request to the given connection. def call(connection) connection.call(self) end + # Send an interim response back to the origin of this request, if possible. + def send_interim_response(status, headers) + @interim_response&.call(status, headers) + end + + def on_interim_response(&block) + if interim_response = @interim_response + @interim_response = ->(status, headers) do + block.call(status, headers) + interim_response.call(status, headers) + end + else + @interim_response = block + end + end + # Whether this is a HEAD request: no body is expected in the response. def head? @method == Methods::HEAD @@ -81,11 +101,11 @@ def connect? # @parameter path [String] The path, e.g. `"/index.html"`, `"/search?q=hello"`, etc. # @parameter headers [Hash] The headers, e.g. `{"accept" => "text/html"}`, etc. # @parameter body [String | Array(String) | Body::Readable] The body, e.g. `"Hello, World!"`, etc. See {Body::Buffered.wrap} for more information about . - def self.[](method, path, _headers = nil, _body = nil, scheme: nil, authority: nil, headers: _headers, body: _body, protocol: nil) + def self.[](method, path, _headers = nil, _body = nil, scheme: nil, authority: nil, headers: _headers, body: _body, protocol: nil, interim_response: nil) body = Body::Buffered.wrap(body) headers = Headers[headers] - self.new(scheme, authority, method, path, nil, headers, body, protocol) + self.new(scheme, authority, method, path, nil, headers, body, protocol, interim_response) end # Whether the request can be replayed without side-effects. diff --git a/readme.md b/readme.md index 2a1526b..5d40f99 100644 --- a/readme.md +++ b/readme.md @@ -25,6 +25,7 @@ Please see the [project releases](https://socketry.github.io/protocol-http/relea ### Unreleased - [`Request[]` and `Response[]` Keyword Arguments](https://socketry.github.io/protocol-http/releases/index#request[]-and-response[]-keyword-arguments) + - [Interim Response Handling](https://socketry.github.io/protocol-http/releases/index#interim-response-handling) ## See Also diff --git a/releases.md b/releases.md index 64df916..652f758 100644 --- a/releases.md +++ b/releases.md @@ -13,5 +13,28 @@ client.get("/", headers: {"accept" => "text/html"}, authority: "example.com") # Response keyword arguments: def call(request) return Response[200, headers: {"content-Type" => "text/html"}, body: "Hello, World!"] +``` + +### Interim Response Handling + +The `Request` class now exposes a `#interim_response` attribute which can be used to handle interim responses both on the client side and server side. + +On the client side, you can pass a callback using the `interim_response` keyword argument which will be invoked whenever an interim response is received: + +```ruby +client = ... +response = client.get("/index", interim_response: proc{|status, headers| ...}) +``` + +On the server side, you can send an interim response using the `#send_interim_response` method: + +```ruby +def call(request) + if request.headers["expect"] == "100-continue" + # Send an interim response: + request.send_interim_response(100) + end + + # ... end ``` diff --git a/test/protocol/http/request.rb b/test/protocol/http/request.rb index f193f41..2977fa2 100644 --- a/test/protocol/http/request.rb +++ b/test/protocol/http/request.rb @@ -121,4 +121,39 @@ request.call(connection) end end + + with "interim response" do + let(:request) {subject.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)} + + it "should call block" do + request.on_interim_response do |status, headers| + expect(status).to be == 100 + expect(headers).to be == {} + end + + request.send_interim_response(100, {}) + end + + it "calls multiple blocks" do + sequence = [] + + request.on_interim_response do |status, headers| + sequence << 1 + + expect(status).to be == 100 + expect(headers).to be == {} + end + + request.on_interim_response do |status, headers| + sequence << 2 + + expect(status).to be == 100 + expect(headers).to be == {} + end + + request.send_interim_response(100, {}) + + expect(sequence).to be == [2, 1] + end + end end