# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2019-2024, by Samuel Williams.
require_relative "body/buffered"
require_relative "body/reader"
require_relative "headers"
require_relative "methods"
module Protocol
module HTTP
# Represents an HTTP request which can be used both server and client-side.
#
# ~~~ ruby
# require 'protocol/http'
#
# # Long form:
# Protocol::HTTP::Request.new("http", "example.com", "GET", "/index.html", "HTTP/1.1", Protocol::HTTP::Headers[["accept", "text/html"]])
#
# # Short form:
# Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}]
# ~~~
class Request
prepend Body::Reader
# Initialize the request.
#
# @parameter scheme [String | Nil] The request scheme, usually `"http"` or `"https"`.
# @parameter authority [String | Nil] The request authority, usually a hostname and port number, e.g. `"example.com:80"`.
# @parameter method [String | Nil] The request method, usually one of `"GET"`, `"HEAD"`, `"POST"`, `"PUT"`, `"DELETE"`, `"CONNECT"` or `"OPTIONS"`, etc.
# @parameter path [String | Nil] The request path, usually a path and query string, e.g. `"/index.html"`, `"/search?q=hello"`, etc.
# @parameter version [String | Nil] The request version, usually `"http/1.0"`, `"http/1.1"`, `"h2"`, or `"h3"`.
# @parameter headers [Headers] The request headers, usually containing metadata associated with the request such as the `"user-agent"`, `"accept"` (content type), `"accept-language"`, etc.
# @parameter body [Body::Readable] The request body.
# @parameter protocol [String | Array(String) | Nil] The request protocol, usually empty, but occasionally `"websocket"` or `"webtransport"`.
# @parameter interim_response [Proc] A callback which is called when an interim response is received.
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
@path = path
@version = version
@headers = headers
@body = body
@protocol = protocol
@interim_response = interim_response
end
# @attribute [String] the request scheme, usually `"http"` or `"https"`.
attr_accessor :scheme
# @attribute [String] the request authority, usually a hostname and port number, e.g. `"example.com:80"`.
attr_accessor :authority
# @attribute [String] the request method, usually one of `"GET"`, `"HEAD"`, `"POST"`, `"PUT"`, `"DELETE"`, `"CONNECT"` or `"OPTIONS"`, etc.
attr_accessor :method
# @attribute [String] the request path, usually a path and query string, e.g. `"/index.html"`, `"/search?q=hello"`, however it can be any [valid request target](https://www.rfc-editor.org/rfc/rfc9110#target.resource).
attr_accessor :path
# @attribute [String] the request version, usually `"http/1.0"`, `"http/1.1"`, `"h2"`, or `"h3"`.
attr_accessor :version
# @attribute [Headers] the request headers, usually containing metadata associated with the request such as the `"user-agent"`, `"accept"` (content type), `"accept-language"`, etc.
attr_accessor :headers
# @attribute [Body::Readable] the request body. It should only be read once (it may not be idempotent).
attr_accessor :body
# @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
# A request that is generated by a server, may choose to include the peer (address) associated with the request. It should be implemented by a sub-class.
#
# @returns [Peer | Nil] The peer (address) associated with the request.
def peer
nil
end
# 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
# Register a callback to be called when an interim response is received.
#
# @yields {|status, headers| ...} The callback to be called when an interim response is received.
# @parameter status [Integer] The HTTP status code, e.g. `100`, `101`, etc.
# @parameter headers [Hash] The headers, e.g. `{"link" => "</style.css>; rel=stylesheet"}`, etc.
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
end
# Whether this is a CONNECT request: typically used to establish a tunnel.
def connect?
@method == Methods::CONNECT
end
# A short-cut method which exposes the main request variables that you'd typically care about.
#
# @parameter method [String] The HTTP method, e.g. `"GET"`, `"POST"`, etc.
# @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, interim_response: nil)
body = Body::Buffered.wrap(body)
headers = Headers[headers]
self.new(scheme, authority, method, path, nil, headers, body, protocol, interim_response)
end
# Whether the request can be replayed without side-effects.
def idempotent?
@method != Methods::POST && (@body.nil? || @body.empty?)
end
# Convert the request to a hash, suitable for serialization.
#
# @returns [Hash] The request as a hash.
def as_json(...)
{
scheme: @scheme,
authority: @authority,
method: @method,
path: @path,
version: @version,
headers: @headers&.as_json,
body: @body&.as_json,
protocol: @protocol
}
end
# Convert the request to JSON.
#
# @returns [String] The request as JSON.
def to_json(...)
as_json.to_json(...)
end
# Summarize the request as a string.
#
# @returns [String] The request as a string.
def to_s
"#{@scheme}://#{@authority}: #{@method} #{@path} #{@version}"
end
end
end
end