├── test └── protocol │ └── http │ ├── body │ ├── file_spec.txt │ ├── reader_spec.txt │ ├── inflate.rb │ ├── head.rb │ ├── reader.rb │ ├── deflate.rb │ ├── readable.rb │ ├── digestable.rb │ ├── file.rb │ ├── rewindable.rb │ ├── wrapper.rb │ └── completable.rb │ ├── http.rb │ ├── peer.rb │ ├── header │ ├── etag.rb │ ├── authorization.rb │ ├── multiple.rb │ ├── etags.rb │ ├── cookie.rb │ ├── date.rb │ ├── vary.rb │ ├── accept_encoding.rb │ ├── accept_charset.rb │ ├── trailer.rb │ ├── connection.rb │ ├── accept_language.rb │ ├── transfer_encoding.rb │ ├── priority.rb │ ├── cache_control.rb │ └── accept.rb │ ├── headers │ └── merged.rb │ ├── quoted_string.rb │ ├── middleware │ └── builder.rb │ ├── methods.rb │ ├── middleware.rb │ ├── content_encoding.rb │ └── cookie.rb ├── .mailmap ├── .gitignore ├── .editorconfig ├── config ├── sus.rb └── external.yaml ├── guides ├── links.yaml ├── headers │ └── readme.md └── streaming │ └── readme.md ├── lib └── protocol │ ├── http │ ├── version.rb │ ├── body.rb │ ├── error.rb │ ├── header │ │ ├── trailer.rb │ │ ├── accept_charset.rb │ │ ├── accept_encoding.rb │ │ ├── cookie.rb │ │ ├── accept_language.rb │ │ ├── vary.rb │ │ ├── date.rb │ │ ├── etag.rb │ │ ├── authorization.rb │ │ ├── multiple.rb │ │ ├── digest.rb │ │ ├── priority.rb │ │ ├── etags.rb │ │ ├── connection.rb │ │ ├── server_timing.rb │ │ ├── transfer_encoding.rb │ │ └── split.rb │ ├── peer.rb │ ├── quoted_string.rb │ ├── body │ │ ├── inflate.rb │ │ ├── head.rb │ │ ├── completable.rb │ │ ├── digestable.rb │ │ ├── wrapper.rb │ │ ├── reader.rb │ │ ├── rewindable.rb │ │ ├── file.rb │ │ └── deflate.rb │ ├── middleware │ │ └── builder.rb │ ├── content_encoding.rb │ ├── accept_encoding.rb │ ├── cookie.rb │ ├── middleware.rb │ └── methods.rb │ └── http.rb ├── examples └── streaming │ ├── gems.rb │ ├── unidirectional.rb │ ├── simple.rb │ ├── unidirectional2.rb │ ├── gems.locked │ ├── bidirectional2.rb │ └── bidirectional.rb ├── bake.rb ├── .github ├── workflows │ ├── rubocop.yaml │ ├── documentation-coverage.yaml │ ├── test-external.yaml │ ├── test.yaml │ ├── documentation.yaml │ └── test-coverage.yaml └── copilot-instructions.md ├── benchmark ├── array.rb └── string.rb ├── fixtures └── protocol │ └── http │ └── body │ ├── a_readable_body.rb │ └── a_writable_body.rb ├── gems.rb ├── protocol-http.gemspec ├── .rubocop.yml ├── context ├── index.yaml ├── headers.md ├── streaming.md └── url-parsing.md ├── license.md └── release.cert /test/protocol/http/body/file_spec.txt: -------------------------------------------------------------------------------- 1 | Hello World -------------------------------------------------------------------------------- /test/protocol/http/body/reader_spec.txt: -------------------------------------------------------------------------------- 1 | thequickbrownfox -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Dan Olson 2 | Thomas Morgan 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /agents.md 2 | /.context 3 | /.bundle 4 | /pkg 5 | /gems.locked 6 | /.covered.db 7 | /external 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 1 3 | message-body: 4 | order: 2 5 | headers: 6 | order: 3 7 | middleware: 8 | order: 4 9 | streaming: 10 | order: 7 11 | design-overview: 12 | order: 10 13 | -------------------------------------------------------------------------------- /lib/protocol/http/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | VERSION = "0.56.1" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /examples/streaming/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "async" 9 | gem "async-http", path: "../../../async-http" 10 | gem "protocol-http", path: "../../" 11 | 12 | gem "debug" 13 | -------------------------------------------------------------------------------- /test/protocol/http/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require "protocol/http" 7 | 8 | describe Protocol::HTTP do 9 | it "has a version number" do 10 | expect(Protocol::HTTP::VERSION).to be =~ /\d+\.\d+\.\d+/ 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/protocol/http/body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "body/readable" 7 | require_relative "body/writable" 8 | require_relative "body/wrapper" 9 | 10 | module Protocol 11 | module HTTP 12 | # @namespace 13 | module Body 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | # Update the project documentation with the new version number. 7 | # 8 | # @parameter version [String] The new version number. 9 | def after_gem_release_version_increment(version) 10 | context["releases:update"].call(version) 11 | context["utopia:project:update"].call 12 | end 13 | -------------------------------------------------------------------------------- /lib/protocol/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require_relative "http/version" 7 | 8 | require_relative "http/headers" 9 | require_relative "http/request" 10 | require_relative "http/response" 11 | require_relative "http/middleware" 12 | 13 | # @namespace 14 | module Protocol 15 | # @namespace 16 | module HTTP 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: ruby 17 | bundler-cache: true 18 | 19 | - name: Run RuboCop 20 | timeout-minutes: 10 21 | run: bundle exec rubocop 22 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | COVERAGE: PartialSummary 10 | 11 | jobs: 12 | validate: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Validate coverage 23 | timeout-minutes: 5 24 | run: bundle exec bake decode:index:coverage lib 25 | -------------------------------------------------------------------------------- /test/protocol/http/peer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | require "protocol/http/peer" 7 | require "socket" 8 | 9 | describe Protocol::HTTP::Peer do 10 | it "can be created from IO" do 11 | address = Addrinfo.tcp("192.168.1.1", 80) 12 | io = Socket.new(:AF_INET, :SOCK_STREAM) 13 | expect(io).to receive(:remote_address).and_return(address) 14 | 15 | peer = Protocol::HTTP::Peer.for(io) 16 | expect(peer).to have_attributes( 17 | address: be_equal(address), 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /benchmark/array.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "sus/fixtures/benchmark" 7 | 8 | describe "Array initialization" do 9 | include Sus::Fixtures::Benchmark 10 | 11 | let(:source_array) {["value1", "value2", "value3", "value4", "value5"]} 12 | 13 | measure "Array.new(array)" do |repeats| 14 | repeats.times do 15 | Array.new(source_array) 16 | end 17 | end 18 | 19 | measure "Array.new.concat(array)" do |repeats| 20 | repeats.times do 21 | array = Array.new 22 | array.concat(source_array) 23 | end 24 | end 25 | end 26 | 27 | -------------------------------------------------------------------------------- /fixtures/protocol/http/body/a_readable_body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | module Body 9 | AReadableBody = Sus::Shared("a readable body") do 10 | with "#read" do 11 | it "after closing, returns nil" do 12 | body.close 13 | 14 | expect(body.read).to be_nil 15 | end 16 | end 17 | 18 | with "empty?" do 19 | it "returns true after closing" do 20 | body.close 21 | 22 | expect(body).to be(:empty?) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | name: ${{matrix.ruby}} on ${{matrix.os}} 11 | runs-on: ${{matrix.os}}-latest 12 | 13 | strategy: 14 | matrix: 15 | os: 16 | - ubuntu 17 | - macos 18 | 19 | ruby: 20 | - "3.2" 21 | - "3.3" 22 | - "3.4" 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: ${{matrix.ruby}} 29 | bundler-cache: true 30 | 31 | - name: Run tests 32 | timeout-minutes: 10 33 | run: bundle exec bake test:external 34 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | # Specify your gem's dependencies in protocol-http.gemspec 9 | gemspec 10 | 11 | # gem "async-http", path: "../async-http" 12 | 13 | group :maintenance, optional: true do 14 | gem "bake-modernize" 15 | gem "bake-gem" 16 | gem "bake-releases" 17 | 18 | gem "agent-context" 19 | 20 | gem "utopia-project", "~> 0.18" 21 | end 22 | 23 | group :test do 24 | gem "covered" 25 | gem "sus" 26 | gem "decode" 27 | 28 | gem "rubocop" 29 | gem "rubocop-socketry" 30 | 31 | gem "sus-fixtures-async" 32 | gem "sus-fixtures-benchmark" 33 | 34 | gem "bake-test" 35 | gem "bake-test-external" 36 | end 37 | -------------------------------------------------------------------------------- /benchmark/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2025, by Samuel Williams. 5 | 6 | def generator 7 | 100000.times do |i| 8 | yield "foo #{i}" 9 | end 10 | end 11 | 12 | def consumer_without_clear 13 | buffer = String.new 14 | generator do |chunk| 15 | buffer << chunk 16 | end 17 | return nil 18 | end 19 | 20 | def consumer_with_clear 21 | buffer = String.new 22 | generator do |chunk| 23 | buffer << chunk 24 | chunk.clear 25 | end 26 | return nil 27 | end 28 | 29 | require "benchmark" 30 | 31 | Benchmark.bm do |x| 32 | x.report("consumer_with_clear") do 33 | consumer_with_clear 34 | GC.start 35 | 36 | end 37 | 38 | x.report("consumer_without_clear") do 39 | consumer_without_clear 40 | GC.start 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/protocol/http/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | # A generic, HTTP protocol error. 9 | class Error < StandardError 10 | end 11 | 12 | # Represents a bad request error (as opposed to a server error). 13 | # This is used to indicate that the request was malformed or invalid. 14 | module BadRequest 15 | end 16 | 17 | # Raised when a singleton (e.g. `content-length`) header is duplicated in a request or response. 18 | class DuplicateHeaderError < Error 19 | include BadRequest 20 | 21 | # @parameter key [String] The header key that was duplicated. 22 | def initialize(key) 23 | super("Duplicate singleton header key: #{key.inspect}") 24 | end 25 | 26 | # @attribute [String] key The header key that was duplicated. 27 | attr :key 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/protocol/http/header/trailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | 8 | module Protocol 9 | module HTTP 10 | module Header 11 | # Represents headers that can contain multiple distinct values separated by commas. 12 | # 13 | # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. 14 | class Trailer < Split 15 | # Whether this header is acceptable in HTTP trailers. 16 | # @returns [Boolean] `false`, as Trailer headers control trailer processing and must appear before the message body. 17 | def self.trailer? 18 | false 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/external.yaml: -------------------------------------------------------------------------------- 1 | protocol-http1: 2 | url: https://github.com/socketry/protocol-http1.git 3 | command: bundle exec sus 4 | protocol-http2: 5 | url: https://github.com/socketry/protocol-http2.git 6 | command: bundle exec sus 7 | protocol-rack: 8 | url: https://github.com/socketry/protocol-rack.git 9 | command: bundle exec sus 10 | async-http: 11 | url: https://github.com/socketry/async-http.git 12 | command: bundle exec sus 13 | async-http-cache: 14 | url: https://github.com/socketry/async-http-cache.git 15 | command: bundle exec sus 16 | protocol-websocket: 17 | url: https://github.com/socketry/protocol-websocket.git 18 | command: bundle exec sus 19 | async-websocket: 20 | url: https://github.com/socketry/async-websocket.git 21 | command: bundle exec sus 22 | falcon: 23 | url: https://github.com/socketry/falcon.git 24 | command: bundle exec sus 25 | async-rest: 26 | url: https://github.com/socketry/async-rest.git 27 | command: bundle exec sus 28 | -------------------------------------------------------------------------------- /test/protocol/http/header/etag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/etag" 7 | 8 | describe Protocol::HTTP::Header::ETag do 9 | let(:header) {subject.parse(description)} 10 | 11 | with 'W/"abcd"' do 12 | it "is weak" do 13 | expect(header).to be(:weak?) 14 | end 15 | end 16 | 17 | with '"abcd"' do 18 | it "is not weak" do 19 | expect(header).not.to be(:weak?) 20 | end 21 | end 22 | 23 | with "#<<" do 24 | let(:header) {subject.new} 25 | 26 | it "can replace values" do 27 | header << '"abcd"' 28 | expect(header).not.to be(:weak?) 29 | 30 | header << 'W/"abcd"' 31 | expect(header).to be(:weak?) 32 | end 33 | end 34 | 35 | with ".coerce" do 36 | it "coerces string to ETag" do 37 | result = subject.coerce('"xyz"') 38 | expect(result).to be_a(subject) 39 | expect(result).to be == '"xyz"' 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions 2 | 3 | ## Use Agent Context 4 | 5 | When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. 6 | 7 | If the file does not exist, you will need to install it, by running the following command: 8 | 9 | ```bash 10 | $ bundle install 11 | $ bundle exec bake agent:context:install 12 | ``` 13 | 14 | This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. 15 | 16 | ## Ignoring Files 17 | 18 | The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. 19 | 20 | While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. 21 | -------------------------------------------------------------------------------- /protocol-http.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/protocol/http/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "protocol-http" 7 | spec.version = Protocol::HTTP::VERSION 8 | 9 | spec.summary = "Provides abstractions to handle HTTP protocols." 10 | spec.authors = ["Samuel Williams", "Thomas Morgan", "Bruno Sutic", "Herrick Fang", "William T. Nelson", "Bryan Powell", "Dan Olson", "Earlopain", "Genki Takiuchi", "Marcelo Junior", "Olle Jonsson", "Yuta Iwama"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/protocol-http" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/protocol-http/", 20 | "source_code_uri" => "https://github.com/socketry/protocol-http.git", 21 | } 22 | 23 | spec.files = Dir.glob(["{context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.required_ruby_version = ">= 3.2" 26 | end 27 | -------------------------------------------------------------------------------- /test/protocol/http/headers/merged.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require "protocol/http/headers" 7 | 8 | describe Protocol::HTTP::Headers::Merged do 9 | let(:fields) do 10 | [ 11 | ["Content-Type", "text/html"], 12 | ["Set-Cookie", "hello=world"], 13 | ["Accept", "*/*"], 14 | ["content-length", 10], 15 | ] 16 | end 17 | 18 | let(:merged) {subject.new(fields)} 19 | let(:headers) {Protocol::HTTP::Headers.new(merged)} 20 | 21 | with "#each" do 22 | it "should yield keys as lower case" do 23 | merged.each do |key, value| 24 | expect(key).to be == key.downcase 25 | end 26 | end 27 | 28 | it "should yield values as strings" do 29 | merged.each do |key, value| 30 | expect(value).to be_a(String) 31 | end 32 | end 33 | end 34 | 35 | with "#<<" do 36 | it "can append fields" do 37 | merged << [["Accept", "image/jpeg"]] 38 | 39 | expect(headers["accept"]).to be == ["*/*", "image/jpeg"] 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | name: ${{matrix.ruby}} on ${{matrix.os}} 11 | runs-on: ${{matrix.os}}-latest 12 | continue-on-error: ${{matrix.experimental}} 13 | 14 | strategy: 15 | matrix: 16 | os: 17 | - ubuntu 18 | - macos 19 | 20 | ruby: 21 | - "3.2" 22 | - "3.3" 23 | - "3.4" 24 | 25 | experimental: [false] 26 | 27 | include: 28 | - os: ubuntu 29 | ruby: truffleruby 30 | experimental: true 31 | - os: ubuntu 32 | ruby: jruby 33 | experimental: true 34 | - os: ubuntu 35 | ruby: head 36 | experimental: true 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{matrix.ruby}} 43 | bundler-cache: true 44 | 45 | - name: Run tests 46 | timeout-minutes: 10 47 | run: bundle exec bake test 48 | -------------------------------------------------------------------------------- /fixtures/protocol/http/body/a_writable_body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | module Body 9 | AWritableBody = Sus::Shared("a readable body") do 10 | with "#read" do 11 | it "after closing the write end, returns all chunks" do 12 | body.write("Hello ") 13 | body.write("World!") 14 | body.close_write 15 | 16 | expect(body.read).to be == "Hello " 17 | expect(body.read).to be == "World!" 18 | expect(body.read).to be_nil 19 | end 20 | end 21 | 22 | with "empty?" do 23 | it "returns false before writing" do 24 | expect(body).not.to be(:empty?) 25 | end 26 | 27 | it "returns true after all chunks are consumed" do 28 | body.write("Hello") 29 | body.close_write 30 | 31 | expect(body).not.to be(:empty?) 32 | expect(body.read).to be == "Hello" 33 | expect(body.read).to be_nil 34 | 35 | expect(body).to be(:empty?) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/protocol/http/peer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | # Provide a well defined, cached representation of a peer (address). 9 | class Peer 10 | # Create a new peer object for the given IO object, using the remote address if available. 11 | # 12 | # @returns [Peer | Nil] The peer object, or nil if the remote address is not available. 13 | def self.for(io) 14 | if address = io.remote_address 15 | return new(address) 16 | end 17 | end 18 | 19 | # Initialize the peer with the given address. 20 | # 21 | # @parameter address [Addrinfo] The remote address of the peer. 22 | def initialize(address) 23 | @address = address 24 | 25 | if address.ip? 26 | @ip_address = @address.ip_address 27 | end 28 | end 29 | 30 | # @attribute [Addrinfo] The remote address of the peer. 31 | attr :address 32 | 33 | # @attribute [String] The IP address of the peer, if available. 34 | attr :ip_address 35 | 36 | alias remote_address address 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/protocol/http/header/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/authorization" 7 | require "protocol/http/headers" 8 | 9 | describe Protocol::HTTP::Header::Authorization do 10 | with "basic username/password" do 11 | let(:header) {subject.basic("samuel", "password")} 12 | 13 | it "should generate correct authorization header" do 14 | expect(header).to be == "Basic c2FtdWVsOnBhc3N3b3Jk" 15 | end 16 | 17 | with "#credentials" do 18 | it "can split credentials" do 19 | expect(header.credentials).to be == ["Basic", "c2FtdWVsOnBhc3N3b3Jk"] 20 | end 21 | end 22 | end 23 | 24 | with ".parse" do 25 | it "parses raw authorization value" do 26 | result = subject.parse("Bearer token123") 27 | expect(result).to be_a(subject) 28 | expect(result).to be == "Bearer token123" 29 | end 30 | end 31 | 32 | with ".coerce" do 33 | it "coerces string to Authorization" do 34 | result = subject.coerce("Bearer xyz") 35 | expect(result).to be_a(subject) 36 | expect(result).to be == "Bearer xyz" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/protocol/http/header/multiple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/multiple" 7 | 8 | describe Protocol::HTTP::Header::Multiple do 9 | let(:header) {subject.parse(description)} 10 | 11 | with "first-value" do 12 | it "can add several values" do 13 | header << "second-value" 14 | header << "third-value" 15 | 16 | expect(header).to be == ["first-value", "second-value", "third-value"] 17 | expect(header).to have_attributes( 18 | to_s: be == "first-value\nsecond-value\nthird-value" 19 | ) 20 | end 21 | end 22 | 23 | with ".trailer?" do 24 | it "is not allowed in trailers by default" do 25 | expect(subject).not.to be(:trailer?) 26 | end 27 | end 28 | 29 | with ".coerce" do 30 | it "coerces array to Multiple" do 31 | result = subject.coerce(["value1", "value2"]) 32 | expect(result).to be_a(subject) 33 | expect(result).to be == ["value1", "value2"] 34 | end 35 | 36 | it "coerces string to Multiple" do 37 | result = subject.coerce("single-value") 38 | expect(result).to be_a(subject) 39 | expect(result).to be == ["single-value"] 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/protocol/http/quoted_string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/quoted_string" 7 | 8 | describe Protocol::HTTP::QuotedString do 9 | with ".unquote" do 10 | it "ignores linear whitespace" do 11 | quoted_string = subject.unquote(%Q{"Hello\r\n World"}) 12 | 13 | expect(quoted_string).to be == "Hello World" 14 | end 15 | end 16 | 17 | with ".quote" do 18 | it "doesn't quote a string that has no special characters" do 19 | quoted_string = subject.quote("Hello") 20 | 21 | expect(quoted_string).to be == "Hello" 22 | end 23 | 24 | it "quotes a string with a space" do 25 | quoted_string = subject.quote("Hello World") 26 | 27 | expect(quoted_string).to be == %Q{"Hello World"} 28 | end 29 | 30 | it "quotes a string with a double quote" do 31 | quoted_string = subject.quote(%Q{Hello "World"}) 32 | 33 | expect(quoted_string).to be == %Q{"Hello \\"World\\""} 34 | end 35 | 36 | it "quotes a string with a backslash" do 37 | quoted_string = subject.quote(%Q{Hello \\World}) 38 | 39 | expect(quoted_string).to be == %Q{"Hello \\\\World"} 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/protocol/http/middleware/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "protocol/http/middleware" 7 | require "protocol/http/middleware/builder" 8 | 9 | describe Protocol::HTTP::Middleware::Builder do 10 | it "can make an app" do 11 | app = Protocol::HTTP::Middleware.build do 12 | run Protocol::HTTP::Middleware::HelloWorld 13 | end 14 | 15 | expect(app).to be_equal(Protocol::HTTP::Middleware::HelloWorld) 16 | end 17 | 18 | it "defaults to not found" do 19 | app = Protocol::HTTP::Middleware.build do 20 | end 21 | 22 | expect(app).to be_equal(Protocol::HTTP::Middleware::NotFound) 23 | end 24 | 25 | it "can instantiate middleware" do 26 | app = Protocol::HTTP::Middleware.build do 27 | use Protocol::HTTP::Middleware 28 | end 29 | 30 | expect(app).to be_a(Protocol::HTTP::Middleware) 31 | end 32 | 33 | it "provides the builder as an argument" do 34 | current_self = self 35 | 36 | app = Protocol::HTTP::Middleware.build do |builder| 37 | builder.use Protocol::HTTP::Middleware 38 | 39 | expect(self).to be_equal(current_self) 40 | end 41 | 42 | expect(app).to be_a(Protocol::HTTP::Middleware) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/protocol/http/body/inflate.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2023-2024, by Samuel Williams. 6 | 7 | require "protocol/http/body/buffered" 8 | require "protocol/http/body/deflate" 9 | require "protocol/http/body/inflate" 10 | 11 | require "securerandom" 12 | 13 | describe Protocol::HTTP::Body::Inflate do 14 | let(:sample) {"The quick brown fox jumps over the lazy dog."} 15 | let(:chunks) {[sample] * 1024} 16 | 17 | let(:body) {Protocol::HTTP::Body::Buffered.new(chunks)} 18 | let(:deflate_body) {Protocol::HTTP::Body::Deflate.for(body)} 19 | let(:compressed_chunks) {deflate_body.join.each_char.to_a} 20 | let(:compressed_body_chunks) {compressed_chunks} 21 | let(:compressed_body) {Protocol::HTTP::Body::Buffered.new(compressed_body_chunks)} 22 | let(:decompressed_body) {subject.for(compressed_body)} 23 | 24 | it "can decompress a body" do 25 | expect(decompressed_body.join).to be == chunks.join 26 | end 27 | 28 | with "incomplete input" do 29 | let(:compressed_body_chunks) {compressed_chunks.first(compressed_chunks.size/2)} 30 | 31 | it "raises error when input is incomplete" do 32 | expect{decompressed_body.join}.to raise_exception(Zlib::BufError) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/protocol/http/header/accept_charset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | require_relative "../quoted_string" 8 | require_relative "../error" 9 | 10 | module Protocol 11 | module HTTP 12 | module Header 13 | # The `accept-charset` header represents a list of character sets that the client can accept. 14 | class AcceptCharset < Split 15 | ParseError = Class.new(Error) 16 | 17 | # https://tools.ietf.org/html/rfc7231#section-5.3.3 18 | CHARSET = /\A(?#{TOKEN})(;q=(?#{QVALUE}))?\z/ 19 | 20 | Charset = Struct.new(:name, :q) do 21 | def quality_factor 22 | (q || 1.0).to_f 23 | end 24 | 25 | def <=> other 26 | other.quality_factor <=> self.quality_factor 27 | end 28 | end 29 | 30 | # Parse the `accept-charset` header value into a list of character sets. 31 | # 32 | # @returns [Array(Charset)] the list of character sets and their associated quality factors. 33 | def charsets 34 | self.map do |value| 35 | if match = value.match(CHARSET) 36 | Charset.new(match[:name], match[:q]) 37 | else 38 | raise ParseError.new("Could not parse character set: #{value.inspect}") 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | BUNDLE_WITH: maintenance 21 | 22 | jobs: 23 | generate: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ruby 32 | bundler-cache: true 33 | 34 | - name: Installing packages 35 | run: sudo apt-get install wget 36 | 37 | - name: Generate documentation 38 | timeout-minutes: 5 39 | run: bundle exec bake utopia:project:static --force no 40 | 41 | - name: Upload documentation artifact 42 | uses: actions/upload-pages-artifact@v3 43 | with: 44 | path: docs 45 | 46 | deploy: 47 | runs-on: ubuntu-latest 48 | 49 | environment: 50 | name: github-pages 51 | url: ${{steps.deployment.outputs.page_url}} 52 | 53 | needs: generate 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | COVERAGE: PartialSummary 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - ruby 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{matrix.ruby}} 30 | bundler-cache: true 31 | 32 | - name: Run tests 33 | timeout-minutes: 5 34 | run: bundle exec bake test 35 | 36 | - uses: actions/upload-artifact@v4 37 | with: 38 | include-hidden-files: true 39 | if-no-files-found: error 40 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 41 | path: .covered.db 42 | 43 | validate: 44 | needs: test 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: ruby/setup-ruby@v1 50 | with: 51 | ruby-version: ruby 52 | bundler-cache: true 53 | 54 | - uses: actions/download-artifact@v4 55 | 56 | - name: Validate coverage 57 | timeout-minutes: 5 58 | run: bundle exec bake covered:validate --paths */.covered.db \; 59 | -------------------------------------------------------------------------------- /lib/protocol/http/header/accept_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | require_relative "../quoted_string" 8 | require_relative "../error" 9 | 10 | module Protocol 11 | module HTTP 12 | module Header 13 | # The `accept-encoding` header represents a list of encodings that the client can accept. 14 | class AcceptEncoding < Split 15 | ParseError = Class.new(Error) 16 | 17 | # https://tools.ietf.org/html/rfc7231#section-5.3.1 18 | QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/ 19 | 20 | # https://tools.ietf.org/html/rfc7231#section-5.3.4 21 | ENCODING = /\A(?#{TOKEN})(;q=(?#{QVALUE}))?\z/ 22 | 23 | Encoding = Struct.new(:name, :q) do 24 | def quality_factor 25 | (q || 1.0).to_f 26 | end 27 | 28 | def <=> other 29 | other.quality_factor <=> self.quality_factor 30 | end 31 | end 32 | 33 | # Parse the `accept-encoding` header value into a list of encodings. 34 | # 35 | # @returns [Array(Charset)] the list of character sets and their associated quality factors. 36 | def encodings 37 | self.map do |value| 38 | if match = value.match(ENCODING) 39 | Encoding.new(match[:name], match[:q]) 40 | else 41 | raise ParseError.new("Could not parse encoding: #{value.inspect}") 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-socketry 3 | 4 | AllCops: 5 | DisabledByDefault: true 6 | 7 | Layout/ConsistentBlankLineIndentation: 8 | Enabled: true 9 | 10 | Layout/IndentationStyle: 11 | Enabled: true 12 | EnforcedStyle: tabs 13 | 14 | Layout/InitialIndentation: 15 | Enabled: true 16 | 17 | Layout/IndentationWidth: 18 | Enabled: true 19 | Width: 1 20 | 21 | Layout/IndentationConsistency: 22 | Enabled: true 23 | EnforcedStyle: normal 24 | 25 | Layout/BlockAlignment: 26 | Enabled: true 27 | 28 | Layout/EndAlignment: 29 | Enabled: true 30 | EnforcedStyleAlignWith: start_of_line 31 | 32 | Layout/BeginEndAlignment: 33 | Enabled: true 34 | EnforcedStyleAlignWith: start_of_line 35 | 36 | Layout/ElseAlignment: 37 | Enabled: true 38 | 39 | Layout/DefEndAlignment: 40 | Enabled: true 41 | 42 | Layout/CaseIndentation: 43 | Enabled: true 44 | 45 | Layout/CommentIndentation: 46 | Enabled: true 47 | 48 | Layout/EmptyLinesAroundClassBody: 49 | Enabled: true 50 | 51 | Layout/EmptyLinesAroundModuleBody: 52 | Enabled: true 53 | 54 | Layout/EmptyLineAfterMagicComment: 55 | Enabled: true 56 | 57 | Layout/SpaceInsideBlockBraces: 58 | Enabled: true 59 | EnforcedStyle: no_space 60 | SpaceBeforeBlockParameters: false 61 | 62 | Layout/SpaceAroundBlockParameters: 63 | Enabled: true 64 | EnforcedStyleInsidePipes: no_space 65 | 66 | Style/FrozenStringLiteralComment: 67 | Enabled: true 68 | 69 | Style/StringLiterals: 70 | Enabled: true 71 | EnforcedStyle: double_quotes 72 | -------------------------------------------------------------------------------- /context/index.yaml: -------------------------------------------------------------------------------- 1 | # Automatically generated context index for Utopia::Project guides. 2 | # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. 3 | --- 4 | description: Provides abstractions to handle HTTP protocols. 5 | metadata: 6 | documentation_uri: https://socketry.github.io/protocol-http/ 7 | source_code_uri: https://github.com/socketry/protocol-http.git 8 | files: 9 | - path: getting-started.md 10 | title: Getting Started 11 | description: This guide explains how to use `protocol-http` for building abstract 12 | HTTP interfaces. 13 | - path: message-body.md 14 | title: Message Body 15 | description: This guide explains how to work with HTTP request and response message 16 | bodies using `Protocol::HTTP::Body` classes. 17 | - path: headers.md 18 | title: Headers 19 | description: This guide explains how to work with HTTP headers using `protocol-http`. 20 | - path: middleware.md 21 | title: Middleware 22 | description: This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`. 23 | - path: streaming.md 24 | title: Streaming 25 | description: This guide gives an overview of how to implement streaming requests 26 | and responses. 27 | - path: design-overview.md 28 | title: Design Overview 29 | description: This guide explains the high level design of `protocol-http` in the 30 | context of wider design patterns that can be used to implement HTTP clients and 31 | servers. 32 | -------------------------------------------------------------------------------- /lib/protocol/http/quoted_string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | # According to https://tools.ietf.org/html/rfc7231#appendix-C 9 | TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i 10 | 11 | QUOTED_STRING = /"(?:.(?!(?. It should already match the QUOTED_STRING pattern above by the parser. 19 | def self.unquote(value, normalize_whitespace = true) 20 | value = value[1...-1] 21 | 22 | value.gsub!(/\\(.)/, '\1') 23 | 24 | if normalize_whitespace 25 | # LWS = [CRLF] 1*( SP | HT ) 26 | value.gsub!(/[\r\n]+\s+/, " ") 27 | end 28 | 29 | return value 30 | end 31 | 32 | QUOTES_REQUIRED = /[()<>@,;:\\"\/\[\]?={} \t]/ 33 | 34 | # Quote a string for HTTP header values if required. 35 | # 36 | # @raises [ArgumentError] if the value contains invalid characters like control characters or newlines. 37 | def self.quote(value, force = false) 38 | # Check if quoting is required: 39 | if value =~ QUOTES_REQUIRED or force 40 | "\"#{value.gsub(/["\\]/, '\\\\\0')}\"" 41 | else 42 | value 43 | end 44 | end 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /examples/streaming/unidirectional.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/client" 9 | require "async/http/server" 10 | require "async/http/endpoint" 11 | 12 | require "protocol/http/body/stream" 13 | require "protocol/http/body/writable" 14 | 15 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") 16 | 17 | Async do 18 | server = Async::HTTP::Server.for(endpoint) do |request| 19 | output = Protocol::HTTP::Body::Writable.new 20 | stream = Protocol::HTTP::Body::Stream.new(request.body, output) 21 | 22 | Async do 23 | # Simple echo server: 24 | while chunk = stream.readpartial(1024) 25 | stream.write(chunk) 26 | end 27 | rescue EOFError 28 | # Ignore EOF errors. 29 | ensure 30 | stream.close 31 | end 32 | 33 | Protocol::HTTP::Response[200, {}, output] 34 | end 35 | 36 | server_task = Async{server.run} 37 | 38 | client = Async::HTTP::Client.new(endpoint) 39 | 40 | input = Protocol::HTTP::Body::Writable.new 41 | response = client.get("/", body: input) 42 | 43 | begin 44 | stream = Protocol::HTTP::Body::Stream.new(response.body, input) 45 | 46 | stream.write("Hello, ") 47 | stream.write("World!") 48 | stream.close_write 49 | 50 | while chunk = stream.readpartial(1024) 51 | puts chunk 52 | end 53 | rescue EOFError 54 | # Ignore EOF errors. 55 | ensure 56 | stream.close 57 | end 58 | ensure 59 | server_task.stop 60 | end 61 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2018-2025, by Samuel Williams. 4 | Copyright, 2019, by Yuta Iwama. 5 | Copyright, 2020, by Olle Jonsson. 6 | Copyright, 2020, by Bryan Powell. 7 | Copyright, 2020-2023, by Bruno Sutic. 8 | Copyright, 2022, by Herrick Fang. 9 | Copyright, 2022, by Dan Olson. 10 | Copyright, 2023, by Genki Takiuchi. 11 | Copyright, 2023-2024, by Thomas Morgan. 12 | Copyright, 2023, by Marcelo Junior. 13 | Copyright, 2024, by Earlopain. 14 | Copyright, 2025, by William T. Nelson. 15 | 16 | Permission is hereby granted, free of charge, to any person obtaining a copy 17 | of this software and associated documentation files (the "Software"), to deal 18 | in the Software without restriction, including without limitation the rights 19 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 20 | copies of the Software, and to permit persons to whom the Software is 21 | furnished to do so, subject to the following conditions: 22 | 23 | The above copyright notice and this permission notice shall be included in all 24 | copies or substantial portions of the Software. 25 | 26 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 27 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 28 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 29 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 30 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 31 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 32 | SOFTWARE. 33 | -------------------------------------------------------------------------------- /lib/protocol/http/header/cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "multiple" 7 | require_relative "../cookie" 8 | 9 | module Protocol 10 | module HTTP 11 | module Header 12 | # The `cookie` header contains stored HTTP cookies previously sent by the server with the `set-cookie` header. 13 | # 14 | # It is used by clients to send key-value pairs representing stored cookies back to the server. 15 | class Cookie < Multiple 16 | # Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects. 17 | # 18 | # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. 19 | def to_h 20 | cookies = self.collect do |string| 21 | HTTP::Cookie.parse(string) 22 | end 23 | 24 | cookies.map{|cookie| [cookie.name, cookie]}.to_h 25 | end 26 | 27 | # Whether this header is acceptable in HTTP trailers. 28 | # Cookie headers should not appear in trailers as they contain state information needed early in processing. 29 | # @returns [Boolean] `false`, as cookie headers are needed during initial request processing. 30 | def self.trailer? 31 | false 32 | end 33 | end 34 | 35 | # The `set-cookie` header sends cookies from the server to the user agent. 36 | # 37 | # It is used to store cookies on the client side, which are then sent back to the server in subsequent requests using the `cookie` header. 38 | class SetCookie < Cookie 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/protocol/http/header/accept_language.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | require_relative "../quoted_string" 8 | require_relative "../error" 9 | 10 | module Protocol 11 | module HTTP 12 | module Header 13 | # The `accept-language` header represents a list of languages that the client can accept. 14 | class AcceptLanguage < Split 15 | ParseError = Class.new(Error) 16 | 17 | # https://tools.ietf.org/html/rfc3066#section-2.1 18 | NAME = /\*|[A-Z]{1,8}(-[A-Z0-9]{1,8})*/i 19 | 20 | # https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 21 | QVALUE = /0(\.[0-9]{0,6})?|1(\.[0]{0,6})?/ 22 | 23 | # https://greenbytes.de/tech/webdav/rfc7231.html#quality.values 24 | LANGUAGE = /\A(?#{NAME})(\s*;\s*q=(?#{QVALUE}))?\z/ 25 | 26 | Language = Struct.new(:name, :q) do 27 | def quality_factor 28 | (q || 1.0).to_f 29 | end 30 | 31 | def <=> other 32 | other.quality_factor <=> self.quality_factor 33 | end 34 | end 35 | 36 | # Parse the `accept-language` header value into a list of languages. 37 | # 38 | # @returns [Array(Charset)] the list of character sets and their associated quality factors. 39 | def languages 40 | self.map do |value| 41 | if match = value.match(LANGUAGE) 42 | Language.new(match[:name], match[:q]) 43 | else 44 | raise ParseError.new("Could not parse language: #{value.inspect}") 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/protocol/http/header/etags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | # Copyright, 2023, by Thomas Morgan. 6 | 7 | require "protocol/http/header/etags" 8 | 9 | describe Protocol::HTTP::Header::ETags do 10 | let(:header) {subject.parse(description)} 11 | 12 | with "*" do 13 | it "is a wildcard" do 14 | expect(header).to be(:wildcard?) 15 | end 16 | 17 | it "matches anything" do 18 | expect(header).to be(:match?, '"anything"') 19 | end 20 | end 21 | 22 | with '"abcd"' do 23 | it "is not a wildcard" do 24 | expect(header).not.to be(:wildcard?) 25 | end 26 | 27 | it "matches itself" do 28 | expect(header).to be(:match?, '"abcd"') 29 | end 30 | 31 | it "strongly matches only another strong etag" do 32 | expect(header).to be(:strong_match?, '"abcd"') 33 | expect(header).not.to be(:strong_match?, 'W/"abcd"') 34 | end 35 | 36 | it "weakly matches both weak and strong etags" do 37 | expect(header).to be(:weak_match?, '"abcd"') 38 | expect(header).to be(:weak_match?, 'W/"abcd"') 39 | end 40 | 41 | it "does not match anything else" do 42 | expect(header).not.to be(:match?, '"anything else"') 43 | end 44 | end 45 | 46 | with 'W/"abcd"' do 47 | it "never strongly matches" do 48 | expect(header).not.to be(:strong_match?, '"abcd"') 49 | expect(header).not.to be(:strong_match?, 'W/"abcd"') 50 | end 51 | 52 | it "weakly matches both weak and strong etags" do 53 | expect(header).to be(:weak_match?, '"abcd"') 54 | expect(header).to be(:weak_match?, 'W/"abcd"') 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /examples/streaming/simple.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/client" 9 | require "async/http/server" 10 | require "async/http/endpoint" 11 | 12 | require "protocol/http/body/streamable" 13 | require "protocol/http/body/writable" 14 | require "protocol/http/body/stream" 15 | 16 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") 17 | 18 | Async do 19 | server = Async::HTTP::Server.for(endpoint) do |request| 20 | output = Protocol::HTTP::Body::Streamable.response(request) do |stream| 21 | $stderr.puts "Server sending text..." 22 | stream.write("Hello from server!") 23 | rescue EOFError 24 | $stderr.puts "Server EOF." 25 | # Ignore EOF errors. 26 | ensure 27 | $stderr.puts "Server closing stream." 28 | stream.close 29 | end 30 | 31 | Protocol::HTTP::Response[200, {}, output] 32 | end 33 | 34 | server_task = Async{server.run} 35 | 36 | client = Async::HTTP::Client.new(endpoint) 37 | 38 | streamable = Protocol::HTTP::Body::Streamable.request do |stream| 39 | while chunk = stream.readpartial(1024) 40 | $stderr.puts "Client chunk: #{chunk.inspect}" 41 | end 42 | rescue EOFError 43 | $stderr.puts "Client EOF." 44 | # Ignore EOF errors. 45 | ensure 46 | $stderr.puts "Client closing stream." 47 | stream.close 48 | end 49 | 50 | $stderr.puts "Client sending request..." 51 | response = client.get("/", body: streamable) 52 | $stderr.puts "Client received response and streaming it..." 53 | streamable.stream(response.body) 54 | ensure 55 | server_task.stop 56 | end 57 | -------------------------------------------------------------------------------- /lib/protocol/http/header/vary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | 8 | module Protocol 9 | module HTTP 10 | module Header 11 | # Represents the `vary` header, which specifies the request headers a server considers when determining the response. 12 | # 13 | # The `vary` header is used in HTTP responses to indicate which request headers affect the selected response. It allows caches to differentiate stored responses based on specific request headers. 14 | class Vary < Split 15 | # Parses a raw header value. 16 | # 17 | # @parameter value [String] a raw header value containing comma-separated header names. 18 | # @returns [Vary] a new instance with normalized (lowercase) header names. 19 | def self.parse(value) 20 | self.new(value.downcase.split(COMMA)) 21 | end 22 | 23 | # Coerces a value into a parsed header object. 24 | # 25 | # @parameter value [String | Array] the value to coerce. 26 | # @returns [Vary] a parsed header object with normalized values. 27 | def self.coerce(value) 28 | case value 29 | when Array 30 | self.new(value.map(&:downcase)) 31 | else 32 | self.parse(value.to_s) 33 | end 34 | end 35 | 36 | # Adds one or more comma-separated values to the `vary` header. The values are converted to lowercase for normalization. 37 | # 38 | # @parameter value [String] a raw header value containing one or more values separated by commas. 39 | def << value 40 | super(value.downcase) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | 47 | -------------------------------------------------------------------------------- /examples/streaming/unidirectional2.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/client" 9 | require "async/http/server" 10 | require "async/http/endpoint" 11 | 12 | require "protocol/http/body/stream" 13 | require "protocol/http/body/writable" 14 | 15 | def make_server(endpoint) 16 | Async::HTTP::Server.for(endpoint) do |request| 17 | output = Protocol::HTTP::Body::Writable.new 18 | stream = Protocol::HTTP::Body::Stream.new(request.body, output) 19 | 20 | Async do 21 | stream.write("Hello, ") 22 | stream.write("World!") 23 | 24 | stream.close_write 25 | 26 | # Simple echo server: 27 | $stderr.puts "Server reading chunks..." 28 | while chunk = stream.readpartial(1024) 29 | puts chunk 30 | end 31 | rescue EOFError 32 | # Ignore EOF errors. 33 | ensure 34 | stream.close 35 | end 36 | 37 | Protocol::HTTP::Response[200, {}, output] 38 | end 39 | end 40 | 41 | Async do |task| 42 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") 43 | 44 | server_task = task.async{make_server(endpoint).run} 45 | 46 | client = Async::HTTP::Client.new(endpoint) 47 | 48 | input = Protocol::HTTP::Body::Writable.new 49 | response = client.get("/", body: input) 50 | 51 | begin 52 | stream = Protocol::HTTP::Body::Stream.new(response.body, input) 53 | 54 | $stderr.puts "Client echoing chunks..." 55 | while chunk = stream.readpartial(1024) 56 | stream.write(chunk) 57 | end 58 | rescue EOFError 59 | # Ignore EOF errors. 60 | ensure 61 | stream.close 62 | end 63 | ensure 64 | server_task.stop 65 | end 66 | -------------------------------------------------------------------------------- /test/protocol/http/header/cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2025, by Samuel Williams. 5 | # Copyright, 2022, by Herrick Fang. 6 | 7 | require "protocol/http/header/cookie" 8 | 9 | describe Protocol::HTTP::Header::Cookie do 10 | let(:header) {subject.parse(description)} 11 | let(:cookies) {header.to_h} 12 | 13 | with "session=123; secure" do 14 | it "can parse cookies" do 15 | expect(cookies).to have_keys("session") 16 | 17 | session = cookies["session"] 18 | expect(session).to have_attributes( 19 | name: be == "session", 20 | value: be == "123", 21 | ) 22 | expect(session.directives).to have_keys("secure") 23 | end 24 | end 25 | 26 | with "session=123; path=/; secure" do 27 | it "can parse cookies" do 28 | session = cookies["session"] 29 | expect(session).to have_attributes( 30 | name: be == "session", 31 | value: be == "123", 32 | directives: be == {"path" => "/", "secure" => true}, 33 | ) 34 | end 35 | 36 | it "has string representation" do 37 | session = cookies["session"] 38 | expect(session.to_s).to be == "session=123;path=/;secure" 39 | end 40 | end 41 | 42 | with "session=abc123; secure" do 43 | it "can parse cookies" do 44 | expect(cookies).to have_keys("session") 45 | 46 | session = cookies["session"] 47 | expect(session).to have_attributes( 48 | name: be == "session", 49 | value: be == "abc123", 50 | ) 51 | expect(session.directives).to have_keys("secure") 52 | end 53 | 54 | it "has string representation" do 55 | session = cookies["session"] 56 | expect(session.to_s).to be == "session=abc123;secure" 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/protocol/http/methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "protocol/http/methods" 7 | 8 | ValidMethod = Sus::Shared("valid method") do |name| 9 | it "defines #{name} method" do 10 | expect(Protocol::HTTP::Methods.constants).to be(:include?, name.to_sym) 11 | end 12 | 13 | it "has correct value" do 14 | expect(Protocol::HTTP::Methods.const_get(name)).to be == name.to_s 15 | end 16 | 17 | it "is a valid method" do 18 | expect(Protocol::HTTP::Methods).to be(:valid?, name) 19 | end 20 | end 21 | 22 | describe Protocol::HTTP::Methods do 23 | it "defines several methods" do 24 | expect(subject.constants).not.to be(:empty?) 25 | end 26 | 27 | it_behaves_like ValidMethod, "GET" 28 | it_behaves_like ValidMethod, "POST" 29 | it_behaves_like ValidMethod, "PUT" 30 | it_behaves_like ValidMethod, "PATCH" 31 | it_behaves_like ValidMethod, "DELETE" 32 | it_behaves_like ValidMethod, "HEAD" 33 | it_behaves_like ValidMethod, "OPTIONS" 34 | it_behaves_like ValidMethod, "TRACE" 35 | it_behaves_like ValidMethod, "CONNECT" 36 | 37 | it "defines exactly 9 methods" do 38 | expect(subject.constants.length).to be == 9 39 | end 40 | 41 | with ".valid?" do 42 | with "FOOBAR" do 43 | it "is not a valid method" do 44 | expect(subject).not.to be(:valid?, description) 45 | end 46 | end 47 | 48 | with "GETEMALL" do 49 | it "is not a valid method" do 50 | expect(subject).not.to be(:valid?, description) 51 | end 52 | end 53 | 54 | with "Accept:" do 55 | it "is not a valid method" do 56 | expect(subject).not.to be(:valid?, description) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/protocol/http/header/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "time" 7 | 8 | module Protocol 9 | module HTTP 10 | module Header 11 | # The `date` header represents the date and time at which the message was originated. 12 | # 13 | # This header is typically included in HTTP responses and follows the format defined in RFC 9110. 14 | class Date < String 15 | # Parses a raw header value. 16 | # 17 | # @parameter value [String] a raw header value. 18 | # @returns [Date] a new instance. 19 | def self.parse(value) 20 | self.new(value) 21 | end 22 | 23 | # Coerces a value into a parsed header object. 24 | # 25 | # @parameter value [String] the value to coerce. 26 | # @returns [Date] a parsed header object. 27 | def self.coerce(value) 28 | self.new(value.to_s) 29 | end 30 | 31 | # Replaces the current value of the `date` header. 32 | # 33 | # @parameter value [String] a raw header value for the `date` header. 34 | def << value 35 | replace(value) 36 | end 37 | 38 | # Converts the `date` header value to a `Time` object. 39 | # 40 | # @returns [Time] the parsed time object corresponding to the `date` header value. 41 | def to_time 42 | ::Time.parse(self) 43 | end 44 | 45 | # Whether this header is acceptable in HTTP trailers. 46 | # Date headers can safely appear in trailers as they provide metadata about response generation. 47 | # @returns [Boolean] `true`, as date headers are metadata that can be computed after response generation. 48 | def self.trailer? 49 | true 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/protocol/http/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "protocol/http/middleware" 7 | 8 | describe Protocol::HTTP::Middleware do 9 | it "can wrap a block" do 10 | middleware = subject.for do |request| 11 | Protocol::HTTP::Response[200] 12 | end 13 | 14 | request = Protocol::HTTP::Request["GET", "/"] 15 | 16 | response = middleware.call(request) 17 | 18 | expect(response).to have_attributes( 19 | status: be == 200, 20 | ) 21 | end 22 | 23 | it "can invoke delegate" do 24 | request = :request 25 | 26 | delegate = subject.new(nil) 27 | expect(delegate).to(receive(:call) do |request| 28 | expect(request).to be_equal(request) 29 | end.and_return(nil)) 30 | 31 | middleware = subject.new(delegate) 32 | middleware.call(request) 33 | end 34 | 35 | it "can close delegate" do 36 | delegate = subject.new(nil) 37 | expect(delegate).to receive(:close).and_return(nil) 38 | 39 | middleware = subject.new(delegate) 40 | middleware.close 41 | end 42 | end 43 | 44 | describe Protocol::HTTP::Middleware::Okay do 45 | let(:middleware) {subject} 46 | 47 | it "responds with 200" do 48 | request = Protocol::HTTP::Request["GET", "/"] 49 | 50 | response = middleware.call(request) 51 | 52 | expect(response).to have_attributes( 53 | status: be == 200, 54 | ) 55 | end 56 | end 57 | 58 | describe Protocol::HTTP::Middleware::NotFound do 59 | let(:middleware) {subject} 60 | 61 | it "responds with 404" do 62 | request = Protocol::HTTP::Request["GET", "/"] 63 | 64 | response = middleware.call(request) 65 | 66 | expect(response).to have_attributes( 67 | status: be == 404, 68 | ) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /examples/streaming/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../../../async-http 3 | specs: 4 | async-http (0.75.0) 5 | async (>= 2.10.2) 6 | async-pool (~> 0.7) 7 | io-endpoint (~> 0.11) 8 | io-stream (~> 0.4) 9 | protocol-http (~> 0.33) 10 | protocol-http1 (~> 0.20) 11 | protocol-http2 (~> 0.18) 12 | traces (>= 0.10) 13 | 14 | PATH 15 | remote: ../.. 16 | specs: 17 | protocol-http (0.33.0) 18 | 19 | GEM 20 | remote: https://rubygems.org/ 21 | specs: 22 | async (2.17.0) 23 | console (~> 1.26) 24 | fiber-annotation 25 | io-event (~> 1.6, >= 1.6.5) 26 | async-pool (0.8.1) 27 | async (>= 1.25) 28 | metrics 29 | traces 30 | console (1.27.0) 31 | fiber-annotation 32 | fiber-local (~> 1.1) 33 | json 34 | debug (1.9.2) 35 | irb (~> 1.10) 36 | reline (>= 0.3.8) 37 | fiber-annotation (0.2.0) 38 | fiber-local (1.1.0) 39 | fiber-storage 40 | fiber-storage (1.0.0) 41 | io-console (0.7.2) 42 | io-endpoint (0.13.1) 43 | io-event (1.6.5) 44 | io-stream (0.4.0) 45 | irb (1.14.0) 46 | rdoc (>= 4.0.0) 47 | reline (>= 0.4.2) 48 | json (2.7.2) 49 | metrics (0.10.2) 50 | protocol-hpack (1.5.0) 51 | protocol-http1 (0.22.0) 52 | protocol-http (~> 0.22) 53 | protocol-http2 (0.18.0) 54 | protocol-hpack (~> 1.4) 55 | protocol-http (~> 0.18) 56 | psych (5.1.2) 57 | stringio 58 | rdoc (6.7.0) 59 | psych (>= 4.0.0) 60 | reline (0.5.10) 61 | io-console (~> 0.5) 62 | stringio (3.1.1) 63 | traces (0.13.1) 64 | 65 | PLATFORMS 66 | ruby 67 | x86_64-linux 68 | 69 | DEPENDENCIES 70 | async 71 | async-http! 72 | debug 73 | protocol-http! 74 | 75 | BUNDLED WITH 76 | 2.5.16 77 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /test/protocol/http/body/head.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "protocol/http/body/head" 7 | require "protocol/http/body/buffered" 8 | 9 | describe Protocol::HTTP::Body::Head do 10 | with "zero length" do 11 | let(:body) {subject.new(0)} 12 | 13 | it "should be ready" do 14 | expect(body).to be(:ready?) 15 | end 16 | 17 | it "should be empty" do 18 | expect(body).to be(:empty?) 19 | end 20 | 21 | with "#join" do 22 | it "should be nil" do 23 | expect(body.join).to be_nil 24 | end 25 | end 26 | end 27 | 28 | with "non-zero length" do 29 | let(:body) {subject.new(1)} 30 | 31 | it "should be empty" do 32 | expect(body).to be(:empty?) 33 | end 34 | 35 | with "#read" do 36 | it "should be nil" do 37 | expect(body.join).to be_nil 38 | end 39 | end 40 | 41 | with "#join" do 42 | it "should be nil" do 43 | expect(body.join).to be_nil 44 | end 45 | end 46 | end 47 | 48 | with ".for" do 49 | with "body" do 50 | let(:source) {Protocol::HTTP::Body::Buffered.wrap("!")} 51 | let(:body) {subject.for(source)} 52 | 53 | it "captures length and closes existing body" do 54 | expect(source).to receive(:close) 55 | 56 | expect(body).to have_attributes(length: be == 1) 57 | body.close 58 | end 59 | end 60 | 61 | with "content length" do 62 | let(:body) {subject.for(nil, 42)} 63 | 64 | it "uses the content length if no body is provided" do 65 | expect(body).to have_attributes(length: be == 42) 66 | expect(body).to be(:empty?) 67 | expect(body).to be(:ready?) 68 | end 69 | end 70 | end 71 | 72 | with ".for with nil body" do 73 | it "returns nil when body is nil" do 74 | body = subject.for(nil) 75 | expect(body).to be_nil 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/protocol/http/header/etag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | module Header 9 | # The `etag` header represents the entity tag for a resource. 10 | # 11 | # The `etag` header provides a unique identifier for a specific version of a resource, typically used for cache validation or conditional requests. It can be either a strong or weak validator as defined in RFC 9110. 12 | class ETag < String 13 | # Parses a raw header value. 14 | # 15 | # @parameter value [String] a raw header value. 16 | # @returns [ETag] a new instance. 17 | def self.parse(value) 18 | self.new(value) 19 | end 20 | 21 | # Coerces a value into a parsed header object. 22 | # 23 | # @parameter value [String] the value to coerce. 24 | # @returns [ETag] a parsed header object. 25 | def self.coerce(value) 26 | self.new(value.to_s) 27 | end 28 | 29 | # Replaces the current value of the `etag` header. 30 | # 31 | # @parameter value [String] a raw header value for the `etag` header. 32 | def << value 33 | replace(value) 34 | end 35 | 36 | # Checks whether the `etag` is a weak validator. 37 | # 38 | # Weak validators indicate semantically equivalent content but may not be byte-for-byte identical. 39 | # 40 | # @returns [Boolean] whether the `etag` is weak. 41 | def weak? 42 | self.start_with?("W/") 43 | end 44 | 45 | # Whether this header is acceptable in HTTP trailers. 46 | # ETag headers can safely appear in trailers as they provide cache validation metadata. 47 | # @returns [Boolean] `true`, as ETag headers are metadata that can be computed after response generation. 48 | def self.trailer? 49 | true 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /examples/streaming/bidirectional2.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/client" 9 | require "async/http/server" 10 | require "async/http/endpoint" 11 | 12 | require "protocol/http/body/streamable" 13 | require "protocol/http/body/writable" 14 | require "protocol/http/body/stream" 15 | 16 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") 17 | 18 | Async do 19 | server = Async::HTTP::Server.for(endpoint) do |request| 20 | output = Protocol::HTTP::Body::Streamable.response(request) do |stream| 21 | $stderr.puts "Server writing chunks..." 22 | stream.write("Hello, ") 23 | stream.write("World!") 24 | 25 | $stderr.puts "Server reading chunks..." 26 | while chunk = stream.readpartial(1024) 27 | puts chunk 28 | end 29 | rescue EOFError 30 | $stderr.puts "Server EOF." 31 | # Ignore EOF errors. 32 | ensure 33 | $stderr.puts "Server closing stream." 34 | stream.close 35 | end 36 | 37 | Protocol::HTTP::Response[200, {}, output] 38 | end 39 | 40 | server_task = Async{server.run} 41 | 42 | client = Async::HTTP::Client.new(endpoint) 43 | 44 | streamable = Protocol::HTTP::Body::Streamable.request do |stream| 45 | # Simple echo client: 46 | while chunk = stream.readpartial(1024) 47 | $stderr.puts "Client chunk: #{chunk.inspect}" 48 | stream.write(chunk) 49 | $stderr.puts "Client waiting for next chunk..." 50 | end 51 | rescue EOFError 52 | $stderr.puts "Client EOF." 53 | # Ignore EOF errors. 54 | ensure 55 | $stderr.puts "Client closing stream." 56 | stream.close 57 | end 58 | 59 | $stderr.puts "Client sending request..." 60 | response = client.get("/", body: streamable) 61 | $stderr.puts "Client received response and streaming it..." 62 | streamable.stream(response.body) 63 | $stderr.puts "Client done streaming response." 64 | ensure 65 | server_task.stop 66 | end 67 | -------------------------------------------------------------------------------- /lib/protocol/http/body/inflate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "zlib" 7 | 8 | require_relative "deflate" 9 | 10 | module Protocol 11 | module HTTP 12 | module Body 13 | # A body which decompresses the contents using the DEFLATE or GZIP algorithm. 14 | class Inflate < ZStream 15 | # Create a new body which decompresses the given body using the GZIP algorithm by default. 16 | # 17 | # @parameter body [Readable] the body to wrap. 18 | # @parameter window_size [Integer] the window size to use for decompression. 19 | def self.for(body, window_size = GZIP) 20 | self.new(body, Zlib::Inflate.new(window_size)) 21 | end 22 | 23 | # Read from the underlying stream and inflate it. 24 | # 25 | # @returns [String | Nil] the inflated data, or nil if the stream is finished. 26 | def read 27 | if stream = @stream 28 | # Read from the underlying stream and inflate it: 29 | while chunk = super 30 | @input_length += chunk.bytesize 31 | 32 | # It's possible this triggers the stream to finish. 33 | chunk = stream.inflate(chunk) 34 | 35 | break unless chunk&.empty? 36 | end 37 | 38 | if chunk 39 | @output_length += chunk.bytesize 40 | elsif !stream.closed? 41 | chunk = stream.finish 42 | @output_length += chunk.bytesize 43 | end 44 | 45 | # If the stream is finished, we need to close it and potentially return nil: 46 | if stream.finished? 47 | @stream = nil 48 | stream.close 49 | 50 | while super 51 | # There is data left in the stream, so we need to keep reading until it's all consumed. 52 | end 53 | 54 | if chunk.empty? 55 | return nil 56 | end 57 | end 58 | 59 | return chunk 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/protocol/http/header/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | # Copyright, 2024, by Earlopain. 6 | 7 | module Protocol 8 | module HTTP 9 | module Header 10 | # Used for basic authorization. 11 | # 12 | # ~~~ ruby 13 | # headers.add('authorization', Authorization.basic("my_username", "my_password")) 14 | # ~~~ 15 | # 16 | # TODO Support other authorization mechanisms, e.g. bearer token. 17 | class Authorization < String 18 | # Parses a raw header value. 19 | # 20 | # @parameter value [String] a raw header value. 21 | # @returns [Authorization] a new instance. 22 | def self.parse(value) 23 | self.new(value) 24 | end 25 | 26 | # Coerces a value into a parsed header object. 27 | # 28 | # @parameter value [String] the value to coerce. 29 | # @returns [Authorization] a parsed header object. 30 | def self.coerce(value) 31 | self.new(value.to_s) 32 | end 33 | 34 | # Splits the header into the credentials. 35 | # 36 | # @returns [Tuple(String, String)] The username and password. 37 | def credentials 38 | self.split(/\s+/, 2) 39 | end 40 | 41 | # Generate a new basic authorization header, encoding the given username and password. 42 | # 43 | # @parameter username [String] The username. 44 | # @parameter password [String] The password. 45 | # @returns [Authorization] The basic authorization header. 46 | def self.basic(username, password) 47 | strict_base64_encoded = ["#{username}:#{password}"].pack("m0") 48 | 49 | self.new( 50 | "Basic #{strict_base64_encoded}" 51 | ) 52 | end 53 | 54 | # Whether this header is acceptable in HTTP trailers. 55 | # @returns [Boolean] `false`, as authorization headers are used for request authentication. 56 | def self.trailer? 57 | false 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/protocol/http/body/reader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022, by Dan Olson. 5 | # Copyright, 2023-2025, by Samuel Williams. 6 | 7 | require "protocol/http/body/reader" 8 | require "protocol/http/body/buffered" 9 | 10 | require "tempfile" 11 | 12 | class TestReader 13 | include Protocol::HTTP::Body::Reader 14 | 15 | def initialize(body) 16 | @body = body 17 | end 18 | 19 | attr :body 20 | end 21 | 22 | describe Protocol::HTTP::Body::Reader do 23 | let(:body) {Protocol::HTTP::Body::Buffered.wrap("thequickbrownfox")} 24 | let(:reader) {TestReader.new(body)} 25 | 26 | with "#finish" do 27 | it "returns a buffered representation" do 28 | expect(reader.finish).to be == body 29 | end 30 | end 31 | 32 | with "#discard" do 33 | it "discards the body" do 34 | expect(body).to receive(:discard) 35 | expect(reader.discard).to be_nil 36 | end 37 | end 38 | 39 | with "#buffered!" do 40 | it "buffers the body" do 41 | expect(reader.buffered!).to be_equal(reader) 42 | expect(reader.body).to be == body 43 | end 44 | end 45 | 46 | with "#close" do 47 | it "closes the underlying body" do 48 | expect(body).to receive(:close) 49 | reader.close 50 | 51 | expect(reader).not.to be(:body?) 52 | end 53 | end 54 | 55 | with "#save" do 56 | it "saves to the provided filename" do 57 | Tempfile.create do |file| 58 | reader.save(file.path) 59 | expect(File.read(file.path)).to be == "thequickbrownfox" 60 | end 61 | end 62 | 63 | it "saves by truncating an existing file if it exists" do 64 | Tempfile.create do |file| 65 | File.write(file.path, "hello" * 100) 66 | reader.save(file.path) 67 | expect(File.read(file.path)).to be == "thequickbrownfox" 68 | end 69 | end 70 | 71 | it "mirrors the interface of File.open" do 72 | Tempfile.create do |file| 73 | reader.save(file.path, "w") 74 | expect(File.read(file.path)).to be == "thequickbrownfox" 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/protocol/http/header/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/date" 7 | 8 | describe Protocol::HTTP::Header::Date do 9 | let(:header) {subject.parse(description)} 10 | 11 | with "Wed, 21 Oct 2015 07:28:00 GMT" do 12 | it "can parse time" do 13 | time = header.to_time 14 | expect(time).to be_a(::Time) 15 | 16 | expect(time).to have_attributes( 17 | year: be == 2015, 18 | month: be == 10, 19 | mday: be == 21, 20 | hour: be == 7, 21 | min: be == 28, 22 | sec: be == 0 23 | ) 24 | end 25 | end 26 | 27 | with "#<<" do 28 | let(:header) {subject.new} 29 | 30 | it "can replace values" do 31 | header << "Wed, 21 Oct 2015 07:28:00 GMT" 32 | expect(header.to_time).to have_attributes( 33 | year: be == 2015, 34 | month: be == 10, 35 | mday: be == 21 36 | ) 37 | 38 | header << "Wed, 22 Oct 2015 07:28:00 GMT" 39 | expect(header.to_time).to have_attributes( 40 | year: be == 2015, 41 | month: be == 10, 42 | mday: be == 22 43 | ) 44 | end 45 | end 46 | 47 | with ".coerce" do 48 | it "coerces string to Date" do 49 | result = subject.coerce("Wed, 21 Oct 2015 07:28:00 GMT") 50 | expect(result).to be_a(subject) 51 | expect(result.to_time.year).to be == 2015 52 | end 53 | end 54 | 55 | describe Protocol::HTTP::Headers do 56 | let(:headers) {subject[[ 57 | ["Date", "Wed, 21 Oct 2015 07:28:00 GMT"], 58 | ["Expires", "Wed, 21 Oct 2015 07:28:00 GMT"], 59 | ["Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"], 60 | ["If-Modified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"], 61 | ["If-Unmodified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"] 62 | ]] 63 | } 64 | 65 | it "should parse date headers" do 66 | # When you convert headers into a hash, the policy is applied (i.e. conversion to Date instances): 67 | headers.to_h.each do |key, value| 68 | expect(value).to be_a(Protocol::HTTP::Header::Date) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/protocol/http/middleware/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require_relative "../middleware" 7 | 8 | module Protocol 9 | module HTTP 10 | class Middleware 11 | # A convenient interface for constructing middleware stacks. 12 | class Builder 13 | # Initialize the builder with the given default application. 14 | # 15 | # @parameter default_app [Object] The default application to use if no middleware is specified. 16 | def initialize(default_app = NotFound) 17 | @use = [] 18 | @app = default_app 19 | end 20 | 21 | # Use the given middleware with the given arguments and options. 22 | # 23 | # @parameter middleware [Class | Object] The middleware class to use. 24 | # @parameter arguments [Array] The arguments to pass to the middleware constructor. 25 | # @parameter options [Hash] The options to pass to the middleware constructor. 26 | # @parameter block [Proc] The block to pass to the middleware constructor. 27 | def use(middleware, *arguments, **options, &block) 28 | @use << proc {|app| middleware.new(app, *arguments, **options, &block)} 29 | end 30 | 31 | # Specify the (default) middleware application to use. 32 | # 33 | # @parameter app [Middleware] The application to use if no middleware is able to handle the request. 34 | def run(app) 35 | @app = app 36 | end 37 | 38 | # Convert the builder to an application by chaining the middleware together. 39 | # 40 | # @returns [Middleware] The application. 41 | def to_app 42 | @use.reverse.inject(@app) {|app, use| use.call(app)} 43 | end 44 | end 45 | 46 | # Build a middleware application using the given block. 47 | def self.build(&block) 48 | builder = Builder.new 49 | 50 | if block_given? 51 | if block.arity == 0 52 | builder.instance_exec(&block) 53 | else 54 | yield builder 55 | end 56 | end 57 | 58 | return builder.to_app 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /examples/streaming/bidirectional.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/client" 9 | require "async/http/server" 10 | require "async/http/endpoint" 11 | 12 | require "protocol/http/body/streamable" 13 | require "protocol/http/body/writable" 14 | require "protocol/http/body/stream" 15 | 16 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") 17 | 18 | Async do 19 | server = Async::HTTP::Server.for(endpoint) do |request| 20 | output = Protocol::HTTP::Body::Streamable.response(request) do |stream| 21 | # Simple echo server: 22 | while chunk = stream.readpartial(1024) 23 | $stderr.puts "Server chunk: #{chunk.inspect}" 24 | stream.write(chunk) 25 | $stderr.puts "Server waiting for next chunk..." 26 | end 27 | $stderr.puts "Server done reading request." 28 | rescue EOFError 29 | $stderr.puts "Server EOF." 30 | # Ignore EOF errors. 31 | ensure 32 | $stderr.puts "Server closing stream." 33 | stream.close 34 | end 35 | 36 | Protocol::HTTP::Response[200, {}, output] 37 | end 38 | 39 | server_task = Async{server.run} 40 | 41 | client = Async::HTTP::Client.new(endpoint) 42 | 43 | streamable = Protocol::HTTP::Body::Streamable.request do |stream| 44 | stream.write("Hello, ") 45 | stream.write("World!") 46 | 47 | $stderr.puts "Client closing write..." 48 | stream.close_write 49 | 50 | $stderr.puts "Client reading response..." 51 | 52 | while chunk = stream.readpartial(1024) 53 | $stderr.puts "Client chunk: #{chunk.inspect}" 54 | puts chunk 55 | end 56 | $stderr.puts "Client done reading response." 57 | rescue EOFError 58 | $stderr.puts "Client EOF." 59 | # Ignore EOF errors. 60 | ensure 61 | $stderr.puts "Client closing stream: #{$!}" 62 | stream.close 63 | end 64 | 65 | $stderr.puts "Client sending request..." 66 | response = client.get("/", body: streamable) 67 | $stderr.puts "Client received response and streaming it..." 68 | streamable.stream(response.body) 69 | ensure 70 | server_task.stop 71 | end 72 | -------------------------------------------------------------------------------- /test/protocol/http/body/deflate.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2025, by Samuel Williams. 6 | 7 | require "protocol/http/body/buffered" 8 | require "protocol/http/body/deflate" 9 | require "protocol/http/body/inflate" 10 | 11 | require "securerandom" 12 | 13 | describe Protocol::HTTP::Body::Deflate do 14 | let(:body) {Protocol::HTTP::Body::Buffered.new} 15 | let(:compressed_body) {Protocol::HTTP::Body::Deflate.for(body)} 16 | let(:decompressed_body) {Protocol::HTTP::Body::Inflate.for(compressed_body)} 17 | 18 | it "should round-trip data" do 19 | body.write("Hello World!") 20 | 21 | expect(decompressed_body.join).to be == "Hello World!" 22 | end 23 | 24 | let(:data) {"Hello World!" * 10_000} 25 | 26 | it "should round-trip data" do 27 | body.write(data) 28 | 29 | expect(decompressed_body.read).to be == data 30 | expect(decompressed_body.read).to be == nil 31 | 32 | expect(compressed_body.ratio).to be < 1.0 33 | expect(decompressed_body.ratio).to be > 1.0 34 | end 35 | 36 | it "should round-trip chunks" do 37 | 10.times do 38 | body.write("Hello World!") 39 | end 40 | 41 | 10.times do 42 | expect(decompressed_body.read).to be == "Hello World!" 43 | end 44 | expect(decompressed_body.read).to be == nil 45 | end 46 | 47 | with "#length" do 48 | it "should be unknown" do 49 | expect(compressed_body).to have_attributes( 50 | length: be_nil, 51 | ) 52 | 53 | expect(decompressed_body).to have_attributes( 54 | length: be_nil, 55 | ) 56 | end 57 | end 58 | 59 | with "#inspect" do 60 | it "can generate string representation" do 61 | expect(compressed_body.inspect).to be == "# | #" 62 | end 63 | end 64 | 65 | with "#as_json" do 66 | it "includes compression information" do 67 | expect(compressed_body.as_json).to have_keys( 68 | class: be == "Protocol::HTTP::Body::Deflate", 69 | input_length: be == 0, 70 | output_length: be == 0, 71 | compression_ratio: be == 100.0 72 | ) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/protocol/http/body/head.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | # Copyright, 2025, by William T. Nelson. 6 | 7 | require_relative "readable" 8 | 9 | module Protocol 10 | module HTTP 11 | module Body 12 | # Represents a body suitable for HEAD requests, in other words, a body that is empty and has a known length. 13 | class Head < Readable 14 | # Create a head body for the given body, capturing its length and then closing it. 15 | # 16 | # If a body is provided, the length is determined from the body, and the body is closed. 17 | # If no body is provided, and the content length is provided, a head body is created with that length. 18 | # This is useful for creating a head body when you only know the content length but not the actual body, which may happen in adapters for HTTP applications where the application may not provide a body for HEAD requests, but the content length is known. 19 | # 20 | # @parameter body [Readable | Nil] the body to create a head for. 21 | # @parameter length [Integer | Nil] the content length of the body, if known. 22 | # @returns [Head | Nil] the head body, or nil if the body is nil. 23 | def self.for(body, length = nil) 24 | if body 25 | head = self.new(body.length) 26 | body.close 27 | return head 28 | elsif length 29 | return self.new(length) 30 | end 31 | 32 | return nil 33 | end 34 | 35 | # Initialize the head body with the given length. 36 | # 37 | # @parameter length [Integer] the length of the body. 38 | def initialize(length) 39 | @length = length 40 | end 41 | 42 | # @returns [Boolean] the body is empty. 43 | def empty? 44 | true 45 | end 46 | 47 | # @returns [Boolean] the body is ready. 48 | def ready? 49 | true 50 | end 51 | 52 | # @returns [Integer] the length of the body, if known. 53 | def length 54 | @length 55 | end 56 | 57 | # Inspect the head body. 58 | # 59 | # @returns [String] a string representation of the head body. 60 | def inspect 61 | "#<#{self.class} #{@length} bytes (empty)>" 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/protocol/http/header/vary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/vary" 7 | 8 | describe Protocol::HTTP::Header::Vary do 9 | let(:header) {subject.parse(description)} 10 | 11 | with "#<<" do 12 | it "can append normalised header names" do 13 | header << "Accept-Language" 14 | expect(header).to be(:include?, "accept-language") 15 | end 16 | end 17 | 18 | with "accept-language" do 19 | it "should be case insensitive" do 20 | expect(header).to be(:include?, "accept-language") 21 | end 22 | 23 | it "should not have unspecific keys" do 24 | expect(header).not.to be(:include?, "user-agent") 25 | end 26 | end 27 | 28 | with "Accept-Language" do 29 | it "should be case insensitive" do 30 | expect(header).to be(:include?, "accept-language") 31 | end 32 | 33 | it "uses normalised lower case keys" do 34 | expect(header).not.to be(:include?, "Accept-Language") 35 | end 36 | end 37 | 38 | with ".coerce" do 39 | it "normalizes array values to lowercase" do 40 | header = subject.coerce(["Accept-Language", "User-Agent"]) 41 | expect(header).to be(:include?, "accept-language") 42 | expect(header).to be(:include?, "user-agent") 43 | expect(header).not.to be(:include?, "Accept-Language") 44 | end 45 | 46 | it "normalizes string values to lowercase" do 47 | header = subject.coerce("Accept-Language, User-Agent") 48 | expect(header).to be(:include?, "accept-language") 49 | expect(header).to be(:include?, "user-agent") 50 | end 51 | end 52 | 53 | with ".new" do 54 | it "preserves case when given array" do 55 | header = subject.new(["Accept-Language", "User-Agent"]) 56 | expect(header).to be(:include?, "Accept-Language") 57 | expect(header).to be(:include?, "User-Agent") 58 | end 59 | 60 | it "can initialize with string for backward compatibility" do 61 | header = subject.new("Accept-Language, User-Agent") 62 | expect(header).to be(:include?, "accept-language") 63 | expect(header).to be(:include?, "user-agent") 64 | end 65 | 66 | it "raises ArgumentError for invalid value types" do 67 | expect{subject.new(123)}.to raise_exception(ArgumentError) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/protocol/http/body/readable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "protocol/http/body/stream" 7 | require "protocol/http/body/readable" 8 | 9 | describe Protocol::HTTP::Body::Readable do 10 | let(:body) {subject.new} 11 | 12 | it "might not be empty" do 13 | expect(body).not.to be(:empty?) 14 | end 15 | 16 | it "should not be ready" do 17 | expect(body).not.to be(:ready?) 18 | end 19 | 20 | with "#buffered" do 21 | it "is unable to buffer by default" do 22 | expect(body.buffered).to be_nil 23 | end 24 | end 25 | 26 | with "#finish" do 27 | it "should return empty buffered representation" do 28 | expect(body.finish).to be(:empty?) 29 | end 30 | end 31 | 32 | with "#call" do 33 | let(:output) {Protocol::HTTP::Body::Buffered.new} 34 | let(:stream) {Protocol::HTTP::Body::Stream.new(nil, output)} 35 | 36 | it "can stream (empty) data" do 37 | body.call(stream) 38 | 39 | expect(output).to be(:empty?) 40 | end 41 | 42 | it "flushes the stream if it is not ready" do 43 | chunks = ["Hello World"] 44 | 45 | mock(body) do |mock| 46 | mock.replace(:read) do 47 | chunks.pop 48 | end 49 | 50 | mock.replace(:ready?) do 51 | false 52 | end 53 | end 54 | 55 | expect(stream).to receive(:flush) 56 | 57 | body.call(stream) 58 | end 59 | end 60 | 61 | with "#join" do 62 | it "should be nil" do 63 | expect(body.join).to be_nil 64 | end 65 | end 66 | 67 | with "#discard" do 68 | it "should read all chunks" do 69 | expect(body).to receive(:read).and_return(nil) 70 | expect(body.discard).to be_nil 71 | end 72 | end 73 | 74 | with "#as_json" do 75 | it "generates a JSON representation" do 76 | expect(body.as_json).to have_keys( 77 | class: be == subject.name, 78 | length: be_nil, 79 | stream: be == false, 80 | ready: be == false, 81 | empty: be == false, 82 | ) 83 | end 84 | 85 | it "generates a JSON string" do 86 | expect(JSON.dump(body)).to be == body.to_json 87 | end 88 | end 89 | 90 | with "#rewindable?" do 91 | it "is not rewindable" do 92 | expect(body).not.to be(:rewindable?) 93 | expect(body.rewind).to be == false 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/protocol/http/body/completable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "wrapper" 7 | 8 | module Protocol 9 | module HTTP 10 | module Body 11 | # Invokes a callback once the body has completed, either successfully or due to an error. 12 | class Completable < Wrapper 13 | # Wrap a message body with a callback. If the body is empty, the callback is invoked immediately. 14 | # 15 | # @parameter message [Request | Response] the message body. 16 | # @parameter block [Proc] the callback to invoke when the body is closed. 17 | def self.wrap(message, &block) 18 | if body = message&.body and !body.empty? 19 | message.body = self.new(message.body, block) 20 | else 21 | yield 22 | end 23 | end 24 | 25 | # Initialize the completable body with a callback. 26 | # 27 | # @parameter body [Readable] the body to wrap. 28 | # @parameter callback [Proc] the callback to invoke when the body is closed. 29 | def initialize(body, callback) 30 | super(body) 31 | 32 | @callback = callback 33 | end 34 | 35 | # @returns [Boolean] completable bodies are not rewindable. 36 | def rewindable? 37 | false 38 | end 39 | 40 | # Rewind the body, is not supported. 41 | def rewind 42 | false 43 | end 44 | 45 | # Close the body and invoke the callback. If an error is given, it is passed to the callback. 46 | # 47 | # The calback is only invoked once, and before `super` is invoked. 48 | def close(error = nil) 49 | if @callback 50 | @callback.call(error) 51 | @callback = nil 52 | end 53 | 54 | super 55 | end 56 | 57 | # Convert the body to a hash suitable for serialization. 58 | # 59 | # @returns [Hash] The body as a hash. 60 | def as_json(...) 61 | super.merge( 62 | callback: @callback&.to_s 63 | ) 64 | end 65 | 66 | # Inspect the completable body. 67 | # 68 | # @returns [String] a string representation of the completable body. 69 | def inspect 70 | callback_status = @callback ? "callback pending" : "callback completed" 71 | return "#{super} | #<#{self.class} #{callback_status}>" 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/protocol/http/header/accept_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/accept_encoding" 7 | 8 | describe Protocol::HTTP::Header::AcceptEncoding::Encoding do 9 | it "should have default quality_factor of 1.0" do 10 | encoding = subject.new("utf-8", nil) 11 | expect(encoding.quality_factor).to be == 1.0 12 | end 13 | end 14 | 15 | describe Protocol::HTTP::Header::AcceptEncoding do 16 | let(:header) {subject.parse(description)} 17 | let(:encodings) {header.encodings.sort} 18 | 19 | with "gzip, deflate;q=0.5, identity;q=0.25" do 20 | it "can parse charsets" do 21 | expect(header.length).to be == 3 22 | 23 | expect(encodings[0].name).to be == "gzip" 24 | expect(encodings[0].quality_factor).to be == 1.0 25 | 26 | expect(encodings[1].name).to be == "deflate" 27 | expect(encodings[1].quality_factor).to be == 0.5 28 | 29 | expect(encodings[2].name).to be == "identity" 30 | expect(encodings[2].quality_factor).to be == 0.25 31 | end 32 | end 33 | 34 | with "identity;q=0.25, deflate;q=0.5, gzip" do 35 | it "should order based on quality factor" do 36 | expect(encodings.collect(&:name)).to be == %w{gzip deflate identity} 37 | end 38 | end 39 | 40 | with "br,deflate;q=0.8,identity;q=0.6,gzip" do 41 | it "should order based on quality factor" do 42 | expect(encodings.collect(&:name)).to be == %w{br gzip deflate identity} 43 | end 44 | end 45 | 46 | with "*;q=0" do 47 | it "should accept wildcard encoding" do 48 | expect(encodings[0].name).to be == "*" 49 | expect(encodings[0].quality_factor).to be == 0 50 | end 51 | end 52 | 53 | with "br, gzip;q=0.5, deflate;q=0.5" do 54 | it "should preserve relative order" do 55 | expect(encodings[0].name).to be == "br" 56 | expect(encodings[1].name).to be == "gzip" 57 | expect(encodings[2].name).to be == "deflate" 58 | end 59 | end 60 | 61 | it "should not accept invalid input" do 62 | bad_values = [ 63 | # Invalid quality factor: 64 | "br;f=1", 65 | 66 | # Invalid parameter: 67 | "br;gzip", 68 | 69 | # Invalid use of separator: 70 | ";", 71 | 72 | # Empty (we ignore this one): 73 | # "," 74 | ] 75 | 76 | bad_values.each do |value| 77 | expect{subject.parse(value).encodings}.to raise_exception(subject::ParseError) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/protocol/http/header/accept_charset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/accept_charset" 7 | 8 | describe Protocol::HTTP::Header::AcceptCharset::Charset do 9 | it "should have default quality_factor of 1.0" do 10 | charset = subject.new("utf-8", nil) 11 | expect(charset.quality_factor).to be == 1.0 12 | end 13 | end 14 | 15 | describe Protocol::HTTP::Header::AcceptCharset do 16 | let(:header) {subject.parse(description)} 17 | let(:charsets) {header.charsets.sort} 18 | 19 | with "utf-8, iso-8859-1;q=0.5, windows-1252;q=0.25" do 20 | it "can parse charsets" do 21 | expect(header.length).to be == 3 22 | 23 | expect(charsets[0].name).to be == "utf-8" 24 | expect(charsets[0].quality_factor).to be == 1.0 25 | 26 | expect(charsets[1].name).to be == "iso-8859-1" 27 | expect(charsets[1].quality_factor).to be == 0.5 28 | 29 | expect(charsets[2].name).to be == "windows-1252" 30 | expect(charsets[2].quality_factor).to be == 0.25 31 | end 32 | end 33 | 34 | with "windows-1252;q=0.25, iso-8859-1;q=0.5, utf-8" do 35 | it "should order based on quality factor" do 36 | expect(charsets.collect(&:name)).to be == %w{utf-8 iso-8859-1 windows-1252} 37 | end 38 | end 39 | 40 | with "us-ascii,iso-8859-1;q=0.8,windows-1252;q=0.6,utf-8" do 41 | it "should order based on quality factor" do 42 | expect(charsets.collect(&:name)).to be == %w{us-ascii utf-8 iso-8859-1 windows-1252} 43 | end 44 | end 45 | 46 | with "*;q=0" do 47 | it "should accept wildcard charset" do 48 | expect(charsets[0].name).to be == "*" 49 | expect(charsets[0].quality_factor).to be == 0 50 | end 51 | end 52 | 53 | with "utf-8, iso-8859-1;q=0.5, windows-1252;q=0.5" do 54 | it "should preserve relative order" do 55 | expect(charsets[0].name).to be == "utf-8" 56 | expect(charsets[1].name).to be == "iso-8859-1" 57 | expect(charsets[2].name).to be == "windows-1252" 58 | end 59 | end 60 | 61 | it "should not accept invalid input" do 62 | bad_values = [ 63 | # Invalid quality factor: 64 | "utf-8;f=1", 65 | 66 | # Invalid parameter: 67 | "us-ascii;utf-8", 68 | 69 | # Invalid use of separator: 70 | ";", 71 | 72 | # Empty charset (we ignore this one): 73 | # "," 74 | ] 75 | 76 | bad_values.each do |value| 77 | expect{subject.parse(value).charsets}.to raise_exception(subject::ParseError) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/protocol/http/body/digestable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require "protocol/http/body/digestable" 7 | require "protocol/http/body/buffered" 8 | 9 | describe Protocol::HTTP::Body::Digestable do 10 | let(:source) {Protocol::HTTP::Body::Buffered.new} 11 | let(:body) {subject.new(source)} 12 | 13 | with ".wrap" do 14 | let(:source) {Protocol::HTTP::Body::Buffered.wrap("HelloWorld")} 15 | let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} 16 | 17 | it "can wrap a message" do 18 | Protocol::HTTP::Body::Digestable.wrap(message) do |digestable| 19 | expect(digestable).to have_attributes( 20 | digest: be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4", 21 | ) 22 | end 23 | 24 | expect(message.body.join).to be == "HelloWorld" 25 | end 26 | end 27 | 28 | with "#digest" do 29 | def before 30 | source.write "Hello" 31 | source.write "World" 32 | 33 | super 34 | end 35 | 36 | it "can compute digest" do 37 | 2.times {body.read} 38 | 39 | expect(body.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4" 40 | end 41 | 42 | it "can recompute digest" do 43 | expect(body.read).to be == "Hello" 44 | expect(body.digest).to be == "185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969" 45 | 46 | expect(body.read).to be == "World" 47 | expect(body.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4" 48 | 49 | expect(body.etag).to be == '"872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"' 50 | expect(body.etag(weak: true)).to be == 'W/"872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"' 51 | end 52 | end 53 | 54 | with "#as_json" do 55 | it "includes digest information" do 56 | expect(body.as_json).to have_keys( 57 | class: be == "Protocol::HTTP::Body::Digestable", 58 | digest_class: be == "Digest::SHA256", 59 | callback: be == nil 60 | ) 61 | end 62 | 63 | with "callback" do 64 | let(:callback) {proc {puts "digest complete"}} 65 | let(:body) {subject.new(source, Digest::SHA256.new, callback)} 66 | 67 | it "includes callback information" do 68 | expect(body.as_json).to have_keys( 69 | class: be == "Protocol::HTTP::Body::Digestable", 70 | digest_class: be == "Digest::SHA256", 71 | callback: be =~ /Proc/ 72 | ) 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/protocol/http/header/multiple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | module Header 9 | # Represents headers that can contain multiple distinct values separated by newline characters. 10 | # 11 | # This isn't a specific header but is used as a base for headers that store multiple values, such as cookies. The values are split and stored as an array internally, and serialized back to a newline-separated string when needed. 12 | class Multiple < Array 13 | # Parses a raw header value. 14 | # 15 | # Multiple headers receive each value as a separate header entry, so this method takes a single string value and creates a new instance containing it. 16 | # 17 | # @parameter value [String] a single raw header value. 18 | # @returns [Multiple] a new instance containing the parsed value. 19 | def self.parse(value) 20 | self.new([value]) 21 | end 22 | 23 | # Coerces a value into a parsed header object. 24 | # 25 | # This method is used by the Headers class when setting values via `[]=` to convert application values into the appropriate policy type. 26 | # 27 | # @parameter value [String | Array] the value to coerce. 28 | # @returns [Multiple] a parsed header object. 29 | def self.coerce(value) 30 | case value 31 | when Array 32 | self.new(value.map(&:to_s)) 33 | else 34 | self.parse(value.to_s) 35 | end 36 | end 37 | 38 | # Initializes the multiple header with the given values. 39 | # 40 | # @parameter value [Array | Nil] an array of header values, or `nil` for an empty header. 41 | def initialize(value = nil) 42 | super() 43 | 44 | if value 45 | self.concat(value) 46 | end 47 | end 48 | 49 | # Converts the parsed header value into a raw header value. 50 | # 51 | # Multiple headers are transmitted as separate header entries, so this serializes to a newline-separated string for storage. 52 | # 53 | # @returns [String] a raw header value (newline-separated string). 54 | def to_s 55 | join("\n") 56 | end 57 | 58 | # Whether this header is acceptable in HTTP trailers. 59 | # This is a base class for headers with multiple values, default is to disallow in trailers. 60 | # @returns [Boolean] `false`, as most multiple-value headers should not appear in trailers by default. 61 | def self.trailer? 62 | false 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/protocol/http/header/trailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/trailer" 7 | 8 | describe Protocol::HTTP::Header::Trailer do 9 | let(:header) {subject.parse(description)} 10 | 11 | with "etag" do 12 | it "contains etag header" do 13 | expect(header).to be(:include?, "etag") 14 | end 15 | 16 | it "has one header" do 17 | expect(header.length).to be == 1 18 | end 19 | end 20 | 21 | with "etag, content-md5" do 22 | it "contains multiple headers" do 23 | expect(header).to be(:include?, "etag") 24 | expect(header).to be(:include?, "content-md5") 25 | end 26 | 27 | it "has correct count" do 28 | expect(header.length).to be == 2 29 | end 30 | end 31 | 32 | with "etag, content-md5, expires" do 33 | it "handles three headers" do 34 | expect(header).to be(:include?, "etag") 35 | expect(header).to be(:include?, "content-md5") 36 | expect(header).to be(:include?, "expires") 37 | end 38 | 39 | it "serializes correctly" do 40 | expect(header.to_s).to be == "etag,content-md5,expires" 41 | end 42 | end 43 | 44 | with "etag , content-md5 , expires" do 45 | it "strips whitespace" do 46 | expect(header.length).to be == 3 47 | expect(header).to be(:include?, "etag") 48 | expect(header).to be(:include?, "content-md5") 49 | end 50 | end 51 | 52 | with "empty header value" do 53 | let(:header) {subject.new} 54 | 55 | it "handles empty trailer" do 56 | expect(header).to be(:empty?) 57 | expect(header.to_s).to be == "" 58 | end 59 | end 60 | 61 | with "#<<" do 62 | let(:header) {subject.parse("etag")} 63 | 64 | it "can add headers" do 65 | header << "content-md5, expires" 66 | expect(header.length).to be == 3 67 | expect(header).to be(:include?, "expires") 68 | end 69 | end 70 | 71 | with ".trailer?" do 72 | it "should be forbidden in trailers" do 73 | expect(subject).not.to be(:trailer?) 74 | end 75 | end 76 | 77 | with ".new" do 78 | it "preserves values when given array" do 79 | header = subject.new(["etag", "content-md5"]) 80 | expect(header).to be(:include?, "etag") 81 | expect(header).to be(:include?, "content-md5") 82 | end 83 | 84 | it "can initialize with string (backward compatibility)" do 85 | header = subject.new("etag, content-md5") 86 | expect(header).to be(:include?, "etag") 87 | expect(header).to be(:include?, "content-md5") 88 | end 89 | 90 | it "raises ArgumentError for invalid value types" do 91 | expect{subject.new(123)}.to raise_exception(ArgumentError) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/protocol/http/body/digestable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "wrapper" 7 | 8 | require "digest/sha2" 9 | 10 | module Protocol 11 | module HTTP 12 | module Body 13 | # Invokes a callback once the body has finished reading. 14 | class Digestable < Wrapper 15 | # Wrap a message body with a callback. If the body is empty, the callback is not invoked, as there is no data to digest. 16 | # 17 | # @parameter message [Request | Response] the message body. 18 | # @parameter digest [Digest] the digest to use. 19 | # @parameter block [Proc] the callback to invoke when the body is closed. 20 | def self.wrap(message, digest = Digest::SHA256.new, &block) 21 | if body = message&.body and !body.empty? 22 | message.body = self.new(message.body, digest, block) 23 | end 24 | end 25 | 26 | # Initialize the digestable body with a callback. 27 | # 28 | # @parameter body [Readable] the body to wrap. 29 | # @parameter digest [Digest] the digest to use. 30 | # @parameter callback [Block] The callback is invoked when the digest is complete. 31 | def initialize(body, digest = Digest::SHA256.new, callback = nil) 32 | super(body) 33 | 34 | @digest = digest 35 | @callback = callback 36 | end 37 | 38 | # @attribute [Digest] digest the digest object. 39 | attr :digest 40 | 41 | # Generate an appropriate ETag for the digest, assuming it is complete. If you call this method before the body is fully read, the ETag will be incorrect. 42 | # 43 | # @parameter weak [Boolean] If true, the ETag is marked as weak. 44 | # @returns [String] the ETag. 45 | def etag(weak: false) 46 | if weak 47 | "W/\"#{digest.hexdigest}\"" 48 | else 49 | "\"#{digest.hexdigest}\"" 50 | end 51 | end 52 | 53 | # Read the body and update the digest. When the body is fully read, the callback is invoked with `self` as the argument. 54 | # 55 | # @returns [String | Nil] the next chunk of data, or nil if the body is fully read. 56 | def read 57 | if chunk = super 58 | @digest.update(chunk) 59 | 60 | return chunk 61 | else 62 | @callback&.call(self) 63 | 64 | return nil 65 | end 66 | end 67 | 68 | # Convert the body to a hash suitable for serialization. 69 | # 70 | # @returns [Hash] The body as a hash. 71 | def as_json(...) 72 | super.merge( 73 | digest_class: @digest.class.name, 74 | callback: @callback&.to_s 75 | ) 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/protocol/http/content_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "middleware" 7 | 8 | require_relative "body/buffered" 9 | require_relative "body/deflate" 10 | 11 | module Protocol 12 | module HTTP 13 | # Encode a response according the the request's acceptable encodings. 14 | class ContentEncoding < Middleware 15 | # The default wrappers to use for encoding content. 16 | DEFAULT_WRAPPERS = { 17 | "gzip" => Body::Deflate.method(:for) 18 | } 19 | 20 | # The default content types to apply encoding to. 21 | DEFAULT_CONTENT_TYPES = %r{^(text/.*?)|(.*?/json)|(.*?/javascript)$} 22 | 23 | # Initialize the content encoding middleware. 24 | # 25 | # @parameter delegate [Middleware] The next middleware in the chain. 26 | # @parameter content_types [Regexp] The content types to apply encoding to. 27 | # @parameter wrappers [Hash] The encoding wrappers to use. 28 | def initialize(delegate, content_types = DEFAULT_CONTENT_TYPES, wrappers = DEFAULT_WRAPPERS) 29 | super(delegate) 30 | 31 | @content_types = content_types 32 | @wrappers = wrappers 33 | end 34 | 35 | # Encode the response body according to the request's acceptable encodings. 36 | # 37 | # @parameter request [Request] The request. 38 | # @returns [Response] The response. 39 | def call(request) 40 | response = super 41 | 42 | # Early exit if the response has already specified a content-encoding. 43 | return response if response.headers["content-encoding"] 44 | 45 | # This is a very tricky issue, so we avoid it entirely. 46 | # https://lists.w3.org/Archives/Public/ietf-http-wg/2014JanMar/1179.html 47 | return response if response.partial? 48 | 49 | body = response.body 50 | 51 | # If there is no response body, there is nothing to encode: 52 | return response if body.nil? or body.empty? 53 | 54 | # Ensure that caches are aware we are varying the response based on the accept-encoding request header: 55 | response.headers.add("vary", "accept-encoding") 56 | 57 | if accept_encoding = request.headers["accept-encoding"] 58 | if content_type = response.headers["content-type"] and @content_types =~ content_type 59 | accept_encoding.each do |name| 60 | if wrapper = @wrappers[name] 61 | response.headers["content-encoding"] = name 62 | 63 | body = wrapper.call(body) 64 | 65 | break 66 | end 67 | end 68 | 69 | response.body = body 70 | end 71 | end 72 | 73 | return response 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/protocol/http/header/digest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | require_relative "../quoted_string" 8 | require_relative "../error" 9 | 10 | module Protocol 11 | module HTTP 12 | module Header 13 | # The `digest` header provides a digest of the message body for integrity verification. 14 | # 15 | # This header allows servers to send cryptographic hashes of the response body, enabling clients to verify data integrity. Multiple digest algorithms can be specified, and the header is particularly useful as a trailer since the digest can only be computed after the entire message body is available. 16 | # 17 | # ## Examples 18 | # 19 | # ```ruby 20 | # digest = Digest.new("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=") 21 | # digest << "md5=9bb58f26192e4ba00f01e2e7b136bbd8" 22 | # puts digest.to_s 23 | # # => "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8" 24 | # ``` 25 | class Digest < Split 26 | ParseError = Class.new(Error) 27 | 28 | # https://tools.ietf.org/html/rfc3230#section-4.3.2 29 | ENTRY = /\A(?[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?.*)\z/ 30 | 31 | # A single digest entry in the Digest header. 32 | Entry = Struct.new(:algorithm, :value) do 33 | # Create a new digest entry. 34 | # 35 | # @parameter algorithm [String] the digest algorithm (e.g., "sha-256", "md5"). 36 | # @parameter value [String] the base64-encoded or hex-encoded digest value. 37 | def initialize(algorithm, value) 38 | super(algorithm.downcase, value) 39 | end 40 | 41 | # Convert the entry to its string representation. 42 | # 43 | # @returns [String] the formatted digest string. 44 | def to_s 45 | "#{algorithm}=#{value}" 46 | end 47 | end 48 | 49 | # Parse the `digest` header value into a list of digest entries. 50 | # 51 | # @returns [Array(Entry)] the list of digest entries with their algorithms and values. 52 | def entries 53 | self.map do |value| 54 | if match = value.match(ENTRY) 55 | Entry.new(match[:algorithm], match[:value]) 56 | else 57 | raise ParseError.new("Could not parse digest value: #{value.inspect}") 58 | end 59 | end 60 | end 61 | 62 | # Whether this header is acceptable in HTTP trailers. 63 | # @returns [Boolean] `true`, as digest headers contain integrity hashes that can only be calculated after the entire message body is available. 64 | def self.trailer? 65 | true 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/protocol/http/header/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2024, by Thomas Morgan. 6 | 7 | require "protocol/http/headers" 8 | require "protocol/http/cookie" 9 | 10 | describe Protocol::HTTP::Header::Connection do 11 | let(:header) {subject.parse(description)} 12 | 13 | with "close" do 14 | it "should indiciate connection will be closed" do 15 | expect(header).to be(:close?) 16 | end 17 | 18 | it "should indiciate connection will not be keep-alive" do 19 | expect(header).not.to be(:keep_alive?) 20 | end 21 | end 22 | 23 | with "keep-alive" do 24 | it "should indiciate connection will not be closed" do 25 | expect(header).not.to be(:close?) 26 | end 27 | 28 | it "should indiciate connection is not keep-alive" do 29 | expect(header).to be(:keep_alive?) 30 | end 31 | end 32 | 33 | with "close, keep-alive" do 34 | it "should prioritize close over keep-alive" do 35 | expect(header).to be(:close?) 36 | expect(header).not.to be(:keep_alive?) 37 | end 38 | end 39 | 40 | with "upgrade" do 41 | it "should indiciate connection can be upgraded" do 42 | expect(header).to be(:upgrade?) 43 | end 44 | end 45 | 46 | with "#<<" do 47 | let(:header) {subject.new} 48 | 49 | it "can append values" do 50 | header << "close" 51 | expect(header).to be(:close?) 52 | 53 | header << "upgrade" 54 | expect(header).to be(:upgrade?) 55 | 56 | expect(header.to_s).to be == "close,upgrade" 57 | end 58 | end 59 | 60 | with ".coerce" do 61 | it "normalizes array values to lowercase" do 62 | header = subject.coerce(["CLOSE", "UPGRADE"]) 63 | expect(header).to be(:include?, "close") 64 | expect(header).to be(:include?, "upgrade") 65 | expect(header).not.to be(:include?, "CLOSE") 66 | end 67 | 68 | it "normalizes string values to lowercase" do 69 | header = subject.coerce("CLOSE, UPGRADE") 70 | expect(header).to be(:include?, "close") 71 | expect(header).to be(:include?, "upgrade") 72 | end 73 | end 74 | 75 | with ".new" do 76 | it "preserves case when given array" do 77 | header = subject.new(["CLOSE", "UPGRADE"]) 78 | expect(header).to be(:include?, "CLOSE") 79 | expect(header).to be(:include?, "UPGRADE") 80 | end 81 | 82 | it "normalizes when given string (backward compatibility)" do 83 | header = subject.new("CLOSE, UPGRADE") 84 | expect(header).to be(:include?, "close") 85 | expect(header).to be(:include?, "upgrade") 86 | end 87 | 88 | it "raises ArgumentError for invalid value types" do 89 | expect{subject.new(123)}.to raise_exception(ArgumentError) 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/protocol/http/accept_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "middleware" 7 | 8 | require_relative "body/buffered" 9 | require_relative "body/inflate" 10 | 11 | module Protocol 12 | module HTTP 13 | # A middleware that sets the accept-encoding header and decodes the response according to the content-encoding header. 14 | class AcceptEncoding < Middleware 15 | # The header used to request encodings. 16 | ACCEPT_ENCODING = "accept-encoding".freeze 17 | 18 | # The header used to specify encodings. 19 | CONTENT_ENCODING = "content-encoding".freeze 20 | 21 | # The default wrappers to use for decoding content. 22 | DEFAULT_WRAPPERS = { 23 | "gzip" => Body::Inflate.method(:for), 24 | "identity" => ->(body) {body}, # Identity means no encoding 25 | 26 | # There is no point including this: 27 | # 'identity' => ->(body){body}, 28 | } 29 | 30 | # Initialize the middleware with the given delegate and wrappers. 31 | # 32 | # @parameter delegate [Protocol::HTTP::Middleware] The delegate middleware. 33 | # @parameter wrappers [Hash] A hash of encoding names to wrapper functions. 34 | def initialize(delegate, wrappers = DEFAULT_WRAPPERS) 35 | super(delegate) 36 | 37 | @accept_encoding = wrappers.keys.join(", ") 38 | @wrappers = wrappers 39 | end 40 | 41 | # Set the accept-encoding header and decode the response body. 42 | # 43 | # @parameter request [Protocol::HTTP::Request] The request to modify. 44 | # @returns [Protocol::HTTP::Response] The response. 45 | def call(request) 46 | request.headers[ACCEPT_ENCODING] = @accept_encoding 47 | 48 | response = super 49 | 50 | if body = response.body and !body.empty? 51 | if content_encoding = response.headers[CONTENT_ENCODING] 52 | # Process encodings in reverse order and remove them when they are decoded: 53 | while name = content_encoding.last 54 | # Look up wrapper with case-insensitive matching: 55 | wrapper = @wrappers[name.downcase] 56 | 57 | if wrapper 58 | body = wrapper.call(body) 59 | # Remove the encoding we just processed: 60 | content_encoding.pop 61 | else 62 | # Unknown encoding - stop processing here: 63 | break 64 | end 65 | end 66 | 67 | # Update the response body: 68 | response.body = body 69 | 70 | # Remove the content-encoding header if we decoded all encodings: 71 | if content_encoding.empty? 72 | response.headers.delete(CONTENT_ENCODING) 73 | end 74 | end 75 | end 76 | 77 | return response 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/protocol/http/header/priority.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "split" 7 | 8 | module Protocol 9 | module HTTP 10 | module Header 11 | # Represents the `priority` header, used to indicate the relative importance of an HTTP request. 12 | # 13 | # The `priority` header allows clients to express their preference for how resources should be prioritized by the server. It supports directives like `u=` to specify the urgency level of a request, and `i` to indicate whether a response can be delivered incrementally. The urgency levels range from 0 (highest priority) to 7 (lowest priority), while the `i` directive is a boolean flag. 14 | class Priority < Split 15 | # Parses a raw header value. 16 | # 17 | # @parameter value [String] a raw header value containing comma-separated directives. 18 | # @returns [Priority] a new instance with normalized (lowercase) directives. 19 | def self.parse(value) 20 | self.new(value.downcase.split(COMMA)) 21 | end 22 | 23 | # Coerces a value into a parsed header object. 24 | # 25 | # @parameter value [String | Array] the value to coerce. 26 | # @returns [Priority] a parsed header object with normalized values. 27 | def self.coerce(value) 28 | case value 29 | when Array 30 | self.new(value.map(&:downcase)) 31 | else 32 | self.parse(value.to_s) 33 | end 34 | end 35 | 36 | # Add a value to the priority header. 37 | # 38 | # @parameter value [String] a raw header value containing directives to add to the header. 39 | def << value 40 | super(value.downcase) 41 | end 42 | 43 | # The default urgency level if not specified. 44 | DEFAULT_URGENCY = 3 45 | 46 | # The urgency level, if specified using `u=`. 0 is the highest priority, and 7 is the lowest. 47 | # 48 | # Note that when duplicate Dictionary keys are encountered, all but the last instance are ignored. 49 | # 50 | # @returns [Integer | Nil] the urgency level if specified, or `nil` if not present. 51 | def urgency(default = DEFAULT_URGENCY) 52 | if value = self.reverse_find{|value| value.start_with?("u=")} 53 | _, level = value.split("=", 2) 54 | return Integer(level) 55 | end 56 | 57 | return default 58 | end 59 | 60 | # Checks if the response should be delivered incrementally. 61 | # 62 | # The `i` directive, when present, indicates that the response can be delivered incrementally as data becomes available. 63 | # 64 | # @returns [Boolean] whether the request should be delivered incrementally. 65 | def incremental? 66 | self.include?("i") 67 | end 68 | end 69 | end 70 | end 71 | end 72 | 73 | -------------------------------------------------------------------------------- /test/protocol/http/header/accept_language.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/accept_language" 7 | 8 | describe Protocol::HTTP::Header::AcceptLanguage::Language do 9 | it "should have default quality_factor of 1.0" do 10 | language = subject.new("utf-8", nil) 11 | expect(language.quality_factor).to be == 1.0 12 | end 13 | end 14 | 15 | describe Protocol::HTTP::Header::AcceptLanguage do 16 | let(:header) {subject.parse(description)} 17 | let(:languages) {header.languages.sort} 18 | 19 | with "da, en-gb;q=0.5, en;q=0.25" do 20 | it "can parse languages" do 21 | expect(header.length).to be == 3 22 | 23 | expect(languages[0].name).to be == "da" 24 | expect(languages[0].quality_factor).to be == 1.0 25 | 26 | expect(languages[1].name).to be == "en-gb" 27 | expect(languages[1].quality_factor).to be == 0.5 28 | 29 | expect(languages[2].name).to be == "en" 30 | expect(languages[2].quality_factor).to be == 0.25 31 | end 32 | end 33 | 34 | with "en-gb;q=0.25, en;q=0.5, en-us" do 35 | it "should order based on quality factor" do 36 | expect(languages.collect(&:name)).to be == %w{en-us en en-gb} 37 | end 38 | end 39 | 40 | with "en-us,en-gb;q=0.8,en;q=0.6,es-419" do 41 | it "should order based on quality factor" do 42 | expect(languages.collect(&:name)).to be == %w{en-us es-419 en-gb en} 43 | end 44 | end 45 | 46 | with "*;q=0" do 47 | it "should accept wildcard language" do 48 | expect(languages[0].name).to be == "*" 49 | expect(languages[0].quality_factor).to be == 0 50 | end 51 | end 52 | 53 | with "en, de;q=0.5, jp;q=0.5" do 54 | it "should preserve relative order" do 55 | expect(languages[0].name).to be == "en" 56 | expect(languages[1].name).to be == "de" 57 | expect(languages[2].name).to be == "jp" 58 | end 59 | end 60 | 61 | with "de, en-US; q=0.7, en ; q=0.3" do 62 | it "should parse with optional whitespace" do 63 | expect(languages[0].name).to be == "de" 64 | expect(languages[1].name).to be == "en-US" 65 | expect(languages[2].name).to be == "en" 66 | end 67 | end 68 | 69 | with "en;q=0.123456" do 70 | it "accepts quality factors with up to 6 decimal places" do 71 | expect(languages[0].name).to be == "en" 72 | expect(languages[0].quality_factor).to be == 0.123456 73 | end 74 | end 75 | 76 | it "should not accept invalid input" do 77 | bad_values = [ 78 | # Invalid quality factor: 79 | "en;f=1", 80 | 81 | # Invalid parameter: 82 | "de;fr", 83 | 84 | # Invalid use of separator: 85 | ";", 86 | 87 | # Empty (we ignore this one): 88 | # "," 89 | ] 90 | 91 | bad_values.each do |value| 92 | expect{subject.parse(value).languages}.to raise_exception(subject::ParseError) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/protocol/http/body/wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require_relative "readable" 7 | 8 | module Protocol 9 | module HTTP 10 | module Body 11 | # Wrapping body instance. Typically you'd override `#read`. 12 | class Wrapper < Readable 13 | # Wrap the body of the given message in a new instance of this class. 14 | # 15 | # @parameter message [Request | Response] the message to wrap. 16 | # @returns [Wrapper | Nil] the wrapped body or `nil`` if the body was `nil`. 17 | def self.wrap(message) 18 | if body = message.body 19 | message.body = self.new(body) 20 | end 21 | end 22 | 23 | # Initialize the wrapper with the given body. 24 | # 25 | # @parameter body [Readable] The body to wrap. 26 | def initialize(body) 27 | @body = body 28 | end 29 | 30 | # @attribute [Readable] The wrapped body. 31 | attr :body 32 | 33 | # Close the body. 34 | # 35 | # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. 36 | def close(error = nil) 37 | @body.close(error) 38 | 39 | # It's a no-op: 40 | # super 41 | end 42 | 43 | # Forwards to the wrapped body. 44 | def empty? 45 | @body.empty? 46 | end 47 | 48 | # Forwards to the wrapped body. 49 | def ready? 50 | @body.ready? 51 | end 52 | 53 | # Forwards to the wrapped body. 54 | def buffered 55 | @body.buffered 56 | end 57 | 58 | # Forwards to the wrapped body. 59 | def rewind 60 | @body.rewind 61 | end 62 | 63 | # Forwards to the wrapped body. 64 | def rewindable? 65 | @body.rewindable? 66 | end 67 | 68 | # Forwards to the wrapped body. 69 | def length 70 | @body.length 71 | end 72 | 73 | # Forwards to the wrapped body. 74 | def read 75 | @body.read 76 | end 77 | 78 | # Forwards to the wrapped body. 79 | def discard 80 | @body.discard 81 | end 82 | 83 | # Convert the body to a hash suitable for serialization. 84 | # 85 | # @returns [Hash] The body as a hash. 86 | def as_json(...) 87 | { 88 | class: self.class.name, 89 | body: @body&.as_json 90 | } 91 | end 92 | 93 | # Convert the body to JSON. 94 | # 95 | # @returns [String] The body as JSON. 96 | def to_json(...) 97 | as_json.to_json(...) 98 | end 99 | 100 | # Inspect the wrapped body. The wrapper, by default, is transparent. 101 | # 102 | # @returns [String] a string representation of the wrapped body. 103 | def inspect 104 | @body.inspect 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/protocol/http/body/reader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2022, by Dan Olson. 6 | 7 | module Protocol 8 | module HTTP 9 | module Body 10 | # General operations for interacting with a request or response body. 11 | # 12 | # This module is included in both {Request} and {Response}. 13 | module Reader 14 | # Read chunks from the body. 15 | # 16 | # @yields {|chunk| ...} chunks from the body. 17 | def each(&block) 18 | if @body 19 | @body.each(&block) 20 | @body = nil 21 | end 22 | end 23 | 24 | # Reads the entire request/response body. 25 | # 26 | # @returns [String] the entire body as a string. 27 | def read 28 | if @body 29 | buffer = @body.join 30 | @body = nil 31 | 32 | return buffer 33 | end 34 | end 35 | 36 | # Gracefully finish reading the body. This will buffer the remainder of the body. 37 | # 38 | # @returns [Buffered] buffers the entire body. 39 | def finish 40 | if @body 41 | body = @body.finish 42 | @body = nil 43 | 44 | return body 45 | end 46 | end 47 | 48 | # Discard the body as efficiently as possible. 49 | def discard 50 | if body = @body 51 | @body = nil 52 | body.discard 53 | end 54 | 55 | return nil 56 | end 57 | 58 | # Buffer the entire request/response body. 59 | # 60 | # @returns [Reader] itself. 61 | def buffered! 62 | if @body 63 | @body = @body.finish 64 | end 65 | 66 | # TODO Should this return @body instead? It seems more useful. 67 | return self 68 | end 69 | 70 | # Write the body of the response to the given file path. 71 | # 72 | # @parameter path [String] the path to write the body to. 73 | # @parameter mode [Integer] the mode to open the file with. 74 | # @parameter options [Hash] additional options to pass to `File.open`. 75 | def save(path, mode = ::File::WRONLY|::File::CREAT|::File::TRUNC, **options) 76 | if @body 77 | ::File.open(path, mode, **options) do |file| 78 | self.each do |chunk| 79 | file.write(chunk) 80 | end 81 | end 82 | end 83 | end 84 | 85 | # Close the connection as quickly as possible. Discards body. May close the underlying connection if necessary to terminate the stream. 86 | # 87 | # @parameter error [Exception | Nil] the error that caused the stream to be closed, if any. 88 | def close(error = nil) 89 | if @body 90 | @body.close(error) 91 | @body = nil 92 | end 93 | end 94 | 95 | # Whether there is a body? 96 | # 97 | # @returns [Boolean] whether there is a body. 98 | def body? 99 | @body and !@body.empty? 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/protocol/http/header/transfer_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/transfer_encoding" 7 | 8 | describe Protocol::HTTP::Header::TransferEncoding do 9 | let(:header) {subject.parse(description)} 10 | 11 | with "chunked" do 12 | it "detects chunked encoding" do 13 | expect(header).to be(:chunked?) 14 | end 15 | end 16 | 17 | with "gzip" do 18 | it "detects gzip encoding" do 19 | expect(header).to be(:gzip?) 20 | end 21 | end 22 | 23 | with "deflate" do 24 | it "detects deflate encoding" do 25 | expect(header).to be(:deflate?) 26 | end 27 | end 28 | 29 | with "compress" do 30 | it "detects compress encoding" do 31 | expect(header).to be(:compress?) 32 | end 33 | end 34 | 35 | with "identity" do 36 | it "detects identity encoding" do 37 | expect(header).to be(:identity?) 38 | end 39 | end 40 | 41 | with "gzip, chunked" do 42 | it "handles multiple encodings" do 43 | expect(header.length).to be == 2 44 | expect(header).to be(:include?, "gzip") 45 | expect(header).to be(:include?, "chunked") 46 | expect(header).to be(:gzip?) 47 | expect(header).to be(:chunked?) 48 | end 49 | end 50 | 51 | with "empty header value" do 52 | let(:header) {subject.new} 53 | 54 | it "handles empty transfer encoding" do 55 | expect(header).to be(:empty?) 56 | expect(header).not.to be(:chunked?) 57 | end 58 | end 59 | 60 | with "#<<" do 61 | let(:header) {subject.new} 62 | 63 | it "can add encodings" do 64 | header << "gzip" 65 | expect(header).to be(:gzip?) 66 | 67 | header << "chunked" 68 | expect(header).to be(:chunked?) 69 | end 70 | end 71 | 72 | with ".trailer?" do 73 | it "should be forbidden in trailers" do 74 | expect(subject).not.to be(:trailer?) 75 | end 76 | end 77 | 78 | with ".coerce" do 79 | it "normalizes array values to lowercase" do 80 | header = subject.coerce(["GZIP", "CHUNKED"]) 81 | expect(header).to be(:include?, "gzip") 82 | expect(header).to be(:include?, "chunked") 83 | expect(header).not.to be(:include?, "GZIP") 84 | end 85 | 86 | it "normalizes string values to lowercase" do 87 | header = subject.coerce("GZIP, CHUNKED") 88 | expect(header).to be(:include?, "gzip") 89 | expect(header).to be(:include?, "chunked") 90 | end 91 | end 92 | 93 | with ".new" do 94 | it "preserves case when given array" do 95 | header = subject.new(["GZIP", "CHUNKED"]) 96 | expect(header).to be(:include?, "GZIP") 97 | expect(header).to be(:include?, "CHUNKED") 98 | end 99 | 100 | it "normalizes when given string (backward compatibility)" do 101 | header = subject.new("GZIP, CHUNKED") 102 | expect(header).to be(:include?, "gzip") 103 | expect(header).to be(:include?, "chunked") 104 | end 105 | 106 | it "raises ArgumentError for invalid value types" do 107 | expect{subject.new(123)}.to raise_exception(ArgumentError) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/protocol/http/header/etags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | # Copyright, 2023, by Thomas Morgan. 6 | 7 | require_relative "split" 8 | 9 | module Protocol 10 | module HTTP 11 | module Header 12 | # The `etags` header represents a list of entity tags (ETags) for resources. 13 | # 14 | # The `etags` header is used for conditional requests to compare the current version of a resource with previously stored versions. It supports both strong and weak validators, as well as the wildcard character (`*`) to indicate a match for any resource. 15 | class ETags < Split 16 | # Checks if the `etags` header contains the wildcard (`*`) character. 17 | # 18 | # The wildcard character matches any resource version, regardless of its actual value. 19 | # 20 | # @returns [Boolean] whether the wildcard is present. 21 | def wildcard? 22 | self.include?("*") 23 | end 24 | 25 | # Checks if the specified ETag matches the `etags` header. 26 | # 27 | # This method returns `true` if the wildcard is present or if the exact ETag is found in the list. Note that this implementation is not strictly compliant with the RFC-specified format. 28 | # 29 | # @parameter etag [String] the ETag to compare against the `etags` header. 30 | # @returns [Boolean] whether the specified ETag matches. 31 | def match?(etag) 32 | wildcard? || self.include?(etag) 33 | end 34 | 35 | # Checks for a strong match with the specified ETag, useful with the `if-match` header. 36 | # 37 | # A strong match requires that the ETag in the header list matches the specified ETag and that neither is a weak validator. 38 | # 39 | # @parameter etag [String] the ETag to compare against the `etags` header. 40 | # @returns [Boolean] whether a strong match is found. 41 | def strong_match?(etag) 42 | wildcard? || (!weak_tag?(etag) && self.include?(etag)) 43 | end 44 | 45 | # Checks for a weak match with the specified ETag, useful with the `if-none-match` header. 46 | # 47 | # A weak match allows for semantically equivalent content, including weak validators and their strong counterparts. 48 | # 49 | # @parameter etag [String] the ETag to compare against the `etags` header. 50 | # @returns [Boolean] whether a weak match is found. 51 | def weak_match?(etag) 52 | wildcard? || self.include?(etag) || self.include?(opposite_tag(etag)) 53 | end 54 | 55 | private 56 | 57 | # Converts a weak tag to its strong counterpart or vice versa. 58 | # 59 | # @parameter etag [String] the ETag to convert. 60 | # @returns [String] the opposite form of the provided ETag. 61 | def opposite_tag(etag) 62 | weak_tag?(etag) ? etag[2..-1] : "W/#{etag}" 63 | end 64 | 65 | # Checks if the given ETag is a weak validator. 66 | # 67 | # @parameter tag [String] the ETag to check. 68 | # @returns [Boolean] whether the tag is weak. 69 | def weak_tag?(tag) 70 | tag&.start_with? "W/" 71 | end 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/protocol/http/header/priority.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/priority" 7 | 8 | describe Protocol::HTTP::Header::Priority do 9 | let(:header) {subject.parse(description)} 10 | 11 | with "u=1, i" do 12 | it "correctly parses priority header" do 13 | expect(header).to have_attributes( 14 | urgency: be == 1, 15 | incremental?: be == true, 16 | ) 17 | end 18 | end 19 | 20 | with "u=0" do 21 | it "correctly parses priority header" do 22 | expect(header).to have_attributes( 23 | urgency: be == 0, 24 | incremental?: be == false, 25 | ) 26 | end 27 | end 28 | 29 | with "i" do 30 | it "correctly parses incremental flag" do 31 | expect(header).to have_attributes( 32 | # Default urgency level is used: 33 | urgency: be == 3, 34 | incremental?: be == true, 35 | ) 36 | end 37 | end 38 | 39 | with "u=6" do 40 | it "correctly parses urgency level" do 41 | expect(header).to have_attributes( 42 | urgency: be == 6, 43 | ) 44 | end 45 | end 46 | 47 | with "u=9, i" do 48 | it "gracefully handles non-standard urgency levels" do 49 | expect(header).to have_attributes( 50 | # Non-standard value is preserved 51 | urgency: be == 9, 52 | incremental?: be == true, 53 | ) 54 | end 55 | end 56 | 57 | with "u=2, u=5" do 58 | it "prioritizes the last urgency directive" do 59 | expect(header).to have_attributes( 60 | urgency: be == 5, 61 | ) 62 | end 63 | end 64 | 65 | with "#<<" do 66 | let(:header) {subject.new} 67 | 68 | it "can append values" do 69 | header << "u=4" 70 | expect(header).to have_attributes( 71 | urgency: be == 4, 72 | ) 73 | end 74 | 75 | it "can append incremental flag" do 76 | header << "i" 77 | expect(header).to have_attributes( 78 | incremental?: be == true, 79 | ) 80 | end 81 | end 82 | 83 | with ".coerce" do 84 | it "normalizes array values to lowercase" do 85 | header = subject.coerce(["U=3", "I"]) 86 | expect(header).to be(:include?, "u=3") 87 | expect(header).to be(:include?, "i") 88 | expect(header).not.to be(:include?, "U=3") 89 | end 90 | 91 | it "normalizes string values to lowercase" do 92 | header = subject.coerce("U=5, I") 93 | expect(header).to be(:include?, "u=5") 94 | expect(header).to be(:include?, "i") 95 | end 96 | end 97 | 98 | with ".new" do 99 | it "preserves case when given array" do 100 | header = subject.new(["U=3", "I"]) 101 | expect(header).to be(:include?, "U=3") 102 | expect(header).to be(:include?, "I") 103 | end 104 | 105 | it "normalizes when given string (backward compatibility)" do 106 | header = subject.new("U=5, I") 107 | expect(header).to be(:include?, "u=5") 108 | expect(header).to be(:include?, "i") 109 | end 110 | 111 | it "raises ArgumentError for invalid value types" do 112 | expect{subject.new(123)}.to raise_exception(ArgumentError) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/protocol/http/header/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | # Copyright, 2024, by Thomas Morgan. 6 | 7 | require_relative "split" 8 | 9 | module Protocol 10 | module HTTP 11 | module Header 12 | # Represents the `connection` HTTP header, which controls options for the current connection. 13 | # 14 | # The `connection` header is used to specify control options such as whether the connection should be kept alive, closed, or upgraded to a different protocol. 15 | class Connection < Split 16 | # The `keep-alive` directive indicates that the connection should remain open for future requests or responses, avoiding the overhead of opening a new connection. 17 | KEEP_ALIVE = "keep-alive" 18 | 19 | # The `close` directive indicates that the connection should be closed after the current request and response are complete. 20 | CLOSE = "close" 21 | 22 | # The `upgrade` directive indicates that the connection should be upgraded to a different protocol, as specified in the `Upgrade` header. 23 | UPGRADE = "upgrade" 24 | 25 | # Parses a raw header value. 26 | # 27 | # @parameter value [String] a raw header value containing comma-separated directives. 28 | # @returns [Connection] a new instance with normalized (lowercase) directives. 29 | def self.parse(value) 30 | self.new(value.downcase.split(COMMA)) 31 | end 32 | 33 | # Coerces a value into a parsed header object. 34 | # 35 | # @parameter value [String | Array] the value to coerce. 36 | # @returns [Connection] a parsed header object with normalized values. 37 | def self.coerce(value) 38 | case value 39 | when Array 40 | self.new(value.map(&:downcase)) 41 | else 42 | self.parse(value.to_s) 43 | end 44 | end 45 | 46 | # Adds a directive to the `connection` header. The value will be normalized to lowercase before being added. 47 | # 48 | # @parameter value [String] a raw header value containing directives to add. 49 | def << value 50 | super(value.downcase) 51 | end 52 | 53 | # @returns [Boolean] whether the `keep-alive` directive is present and the connection is not marked for closure with the `close` directive. 54 | def keep_alive? 55 | self.include?(KEEP_ALIVE) && !close? 56 | end 57 | 58 | # @returns [Boolean] whether the `close` directive is present, indicating that the connection should be closed after the current request and response. 59 | def close? 60 | self.include?(CLOSE) 61 | end 62 | 63 | # @returns [Boolean] whether the `upgrade` directive is present, indicating that the connection should be upgraded to a different protocol. 64 | def upgrade? 65 | self.include?(UPGRADE) 66 | end 67 | 68 | # Whether this header is acceptable in HTTP trailers. 69 | # Connection headers control the current connection and must not appear in trailers. 70 | # @returns [Boolean] `false`, as connection headers are hop-by-hop and forbidden in trailers. 71 | def self.trailer? 72 | false 73 | end 74 | end 75 | end 76 | end 77 | end 78 | 79 | -------------------------------------------------------------------------------- /lib/protocol/http/body/rewindable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | # Copyright, 2025, by William T. Nelson. 6 | 7 | require_relative "wrapper" 8 | require_relative "buffered" 9 | 10 | module Protocol 11 | module HTTP 12 | module Body 13 | # A body which buffers all its contents as it is read. 14 | # 15 | # As the body is buffered in memory, you may want to ensure your server has sufficient (virtual) memory available to buffer the entire body. 16 | class Rewindable < Wrapper 17 | # Wrap the given message body in a rewindable body, if it is not already rewindable. 18 | # 19 | # @parameter message [Request | Response] the message to wrap. 20 | def self.wrap(message) 21 | if body = message.body 22 | if body.rewindable? 23 | body 24 | else 25 | message.body = self.new(body) 26 | end 27 | end 28 | end 29 | 30 | # Initialize the body with the given body. 31 | # 32 | # @parameter body [Readable] the body to wrap. 33 | def initialize(body) 34 | super(body) 35 | 36 | @chunks = [] 37 | @index = 0 38 | end 39 | 40 | # @returns [Boolean] Whether the body is empty. 41 | def empty? 42 | (@index >= @chunks.size) && super 43 | end 44 | 45 | # @returns [Boolean] Whether the body is ready to be read. 46 | def ready? 47 | (@index < @chunks.size) || super 48 | end 49 | 50 | # A rewindable body wraps some other body. Convert it to a buffered body. The buffered body will share the same chunks as the rewindable body. 51 | # 52 | # @returns [Buffered] the buffered body. 53 | def buffered 54 | Buffered.new(@chunks) 55 | end 56 | 57 | # Read the next available chunk. This may return a buffered chunk if the stream has been rewound, or a chunk from the underlying stream, if available. 58 | # 59 | # @returns [String | Nil] The chunk of data, or `nil` if the stream has finished. 60 | def read 61 | if @index < @chunks.size 62 | chunk = @chunks[@index] 63 | @index += 1 64 | else 65 | if chunk = super 66 | @chunks << -chunk 67 | @index += 1 68 | end 69 | end 70 | 71 | # We dup them on the way out, so that if someone modifies the string, it won't modify the rewindability. 72 | return chunk 73 | end 74 | 75 | # Rewind the stream to the beginning. 76 | def rewind 77 | @index = 0 78 | end 79 | 80 | # @returns [Boolean] Whether the stream is rewindable, which it is. 81 | def rewindable? 82 | true 83 | end 84 | 85 | # Convert the body to a hash suitable for serialization. 86 | # 87 | # @returns [Hash] The body as a hash. 88 | def as_json(...) 89 | super.merge( 90 | index: @index, 91 | chunks: @chunks.size 92 | ) 93 | end 94 | 95 | # Inspect the rewindable body. 96 | # 97 | # @returns [String] a string representation of the body. 98 | def inspect 99 | "#{super} | #<#{self.class} #{@index}/#{@chunks.size} chunks read>" 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/protocol/http/body/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "protocol/http/body/file" 7 | 8 | describe Protocol::HTTP::Body::File do 9 | let(:path) {File.expand_path("file_spec.txt", __dir__)} 10 | let(:body) {subject.open(path)} 11 | 12 | after do 13 | @body&.close 14 | end 15 | 16 | # with '#stream?' do 17 | # it "should be streamable" do 18 | # expect(body).to be(:stream?) 19 | # end 20 | # end 21 | 22 | with "#join" do 23 | it "should read entire file" do 24 | expect(body.join).to be == "Hello World" 25 | end 26 | end 27 | 28 | with "#close" do 29 | it "should close file" do 30 | body.close 31 | 32 | expect(body).to be(:empty?) 33 | expect(body.file).to be(:closed?) 34 | end 35 | end 36 | 37 | with "#rewindable?" do 38 | it "should be rewindable" do 39 | expect(body).to be(:rewindable?) 40 | end 41 | end 42 | 43 | with "#rewind" do 44 | it "should rewind file" do 45 | expect(body.read).to be == "Hello World" 46 | expect(body).to be(:empty?) 47 | 48 | body.rewind 49 | 50 | expect(body).not.to be(:empty?) 51 | expect(body.read).to be == "Hello World" 52 | end 53 | end 54 | 55 | with "#buffered" do 56 | it "should return a new instance" do 57 | buffered = body.buffered 58 | 59 | expect(buffered).to be_a(Protocol::HTTP::Body::File) 60 | expect(buffered).not.to be_equal(body) 61 | ensure 62 | buffered&.close 63 | end 64 | end 65 | 66 | with "#inspect" do 67 | it "generates a string representation" do 68 | expect(body.inspect).to be =~ /Protocol::HTTP::Body::File (.*?), \d+ bytes remaining/ 69 | end 70 | 71 | with "range" do 72 | let(:body) {subject.new(File.open(path), 5..10)} 73 | 74 | it "shows offset when present" do 75 | expect(body.inspect).to be =~ /Protocol::HTTP::Body::File (.*?) \+5, \d+ bytes remaining/ 76 | end 77 | end 78 | end 79 | 80 | with "entire file" do 81 | it "should read entire file" do 82 | expect(body.read).to be == "Hello World" 83 | end 84 | 85 | it "should use binary encoding" do 86 | expect(::File).to receive(:open).with(path, ::File::RDONLY | ::File::BINARY) 87 | 88 | chunk = body.read 89 | 90 | expect(chunk.encoding).to be == Encoding::BINARY 91 | end 92 | 93 | with "#ready?" do 94 | it "should be ready" do 95 | expect(body).to be(:ready?) 96 | end 97 | end 98 | end 99 | 100 | with "partial file" do 101 | let(:body) {subject.open(path, 2...4)} 102 | 103 | it "should read specified range" do 104 | expect(body.read).to be == "ll" 105 | end 106 | end 107 | 108 | with "#call" do 109 | let(:output) {StringIO.new} 110 | 111 | it "can stream output" do 112 | body.call(output) 113 | 114 | expect(output.string).to be == "Hello World" 115 | end 116 | 117 | with "/dev/zero" do 118 | it "can stream partial output" do 119 | skip unless File.exist?("/dev/zero") 120 | 121 | body = subject.open("/dev/zero", 0...10) 122 | 123 | body.call(output) 124 | 125 | expect(output.string).to be == "\x00" * 10 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/protocol/http/body/rewindable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "protocol/http/body/rewindable" 7 | require "protocol/http/request" 8 | 9 | describe Protocol::HTTP::Body::Rewindable do 10 | let(:source) {Protocol::HTTP::Body::Buffered.new} 11 | let(:body) {subject.new(source)} 12 | 13 | it "can write and read data" do 14 | 3.times do |i| 15 | source.write("Hello World #{i}") 16 | expect(body.read).to be == "Hello World #{i}" 17 | end 18 | end 19 | 20 | it "can write and read data multiple times" do 21 | 3.times do |i| 22 | source.write("Hello World #{i}") 23 | end 24 | 25 | 3.times do 26 | body.rewind 27 | 28 | expect(body).to be(:ready?) 29 | expect(body.read).to be == "Hello World 0" 30 | end 31 | end 32 | 33 | it "can buffer data in order" do 34 | 3.times do |i| 35 | source.write("Hello World #{i}") 36 | end 37 | 38 | 2.times do 39 | body.rewind 40 | 41 | 3.times do |i| 42 | expect(body.read).to be == "Hello World #{i}" 43 | end 44 | end 45 | end 46 | 47 | with ".wrap" do 48 | with "a buffered body" do 49 | let(:body) {Protocol::HTTP::Body::Buffered.new} 50 | let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} 51 | 52 | it "returns the body" do 53 | expect(subject.wrap(message)).to be == body 54 | end 55 | end 56 | 57 | with "a non-rewindable body" do 58 | let(:body) {Protocol::HTTP::Body::Readable.new} 59 | let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} 60 | 61 | it "returns a new rewindable body" do 62 | expect(subject.wrap(message)).to be_a(Protocol::HTTP::Body::Rewindable) 63 | end 64 | end 65 | end 66 | 67 | with "#buffered" do 68 | it "can generate buffered representation" do 69 | 3.times do |i| 70 | source.write("Hello World #{i}") 71 | end 72 | 73 | expect(body.buffered).to be(:empty?) 74 | 75 | # Read one chunk into the internal buffer: 76 | body.read 77 | 78 | expect(body.buffered.chunks).to be == ["Hello World 0"] 79 | end 80 | end 81 | 82 | with "#empty?" do 83 | it "can read and re-read the body" do 84 | source.write("Hello World") 85 | expect(body).not.to be(:empty?) 86 | 87 | expect(body.read).to be == "Hello World" 88 | expect(body).to be(:empty?) 89 | 90 | body.rewind 91 | expect(body.read).to be == "Hello World" 92 | expect(body).to be(:empty?) 93 | end 94 | end 95 | 96 | with "#rewindable?" do 97 | it "is rewindable" do 98 | expect(body).to be(:rewindable?) 99 | end 100 | end 101 | 102 | with "#inspect" do 103 | it "can generate string representation" do 104 | expect(body.inspect).to be == "# | #" 105 | end 106 | end 107 | 108 | with "#as_json" do 109 | it "includes rewind tracking information" do 110 | expect(body.as_json).to have_keys( 111 | class: be == "Protocol::HTTP::Body::Rewindable", 112 | index: be == 0, 113 | chunks: be == 0 114 | ) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/protocol/http/body/wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "protocol/http/body/wrapper" 7 | require "protocol/http/body/buffered" 8 | require "protocol/http/request" 9 | 10 | require "json" 11 | require "stringio" 12 | 13 | describe Protocol::HTTP::Body::Wrapper do 14 | let(:source) {Protocol::HTTP::Body::Buffered.new} 15 | let(:body) {subject.new(source)} 16 | 17 | with "#stream?" do 18 | it "should not be streamable" do 19 | expect(body).not.to be(:stream?) 20 | end 21 | end 22 | 23 | it "should proxy close" do 24 | expect(source).to receive(:close).and_return(nil) 25 | body.close 26 | end 27 | 28 | it "should proxy empty?" do 29 | expect(source).to receive(:empty?).and_return(true) 30 | expect(body.empty?).to be == true 31 | end 32 | 33 | it "should proxy ready?" do 34 | expect(source).to receive(:ready?).and_return(true) 35 | expect(body.ready?).to be == true 36 | end 37 | 38 | it "should proxy length" do 39 | expect(source).to receive(:length).and_return(1) 40 | expect(body.length).to be == 1 41 | end 42 | 43 | it "should proxy read" do 44 | expect(source).to receive(:read).and_return("!") 45 | expect(body.read).to be == "!" 46 | end 47 | 48 | it "should proxy inspect" do 49 | expect(source).to receive(:inspect).and_return("!") 50 | expect(body.inspect).to be(:include?, "!") 51 | end 52 | 53 | with ".wrap" do 54 | let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} 55 | 56 | it "should wrap body" do 57 | subject.wrap(message) 58 | 59 | expect(message.body).to be_a(Protocol::HTTP::Body::Wrapper) 60 | end 61 | end 62 | 63 | with "#buffered" do 64 | it "should proxy buffered" do 65 | expect(source).to receive(:buffered).and_return(true) 66 | expect(body.buffered).to be == true 67 | end 68 | end 69 | 70 | with "#rewindable?" do 71 | it "should proxy rewindable?" do 72 | expect(source).to receive(:rewindable?).and_return(true) 73 | expect(body.rewindable?).to be == true 74 | end 75 | end 76 | 77 | with "#rewind" do 78 | it "should proxy rewind" do 79 | expect(source).to receive(:rewind).and_return(true) 80 | expect(body.rewind).to be == true 81 | end 82 | end 83 | 84 | with "#as_json" do 85 | it "generates a JSON representation" do 86 | expect(body.as_json).to have_keys( 87 | class: be == "Protocol::HTTP::Body::Wrapper", 88 | body: be == source.as_json 89 | ) 90 | end 91 | 92 | it "generates a JSON string" do 93 | expect(JSON.dump(body)).to be == body.to_json 94 | end 95 | end 96 | 97 | with "#each" do 98 | it "should invoke close correctly" do 99 | expect(body).to receive(:close) 100 | 101 | body.each{} 102 | end 103 | end 104 | 105 | with "#stream" do 106 | let(:stream) {StringIO.new} 107 | 108 | it "should invoke close correctly" do 109 | expect(body).to receive(:close) 110 | 111 | body.call(stream) 112 | end 113 | end 114 | 115 | with "#discard" do 116 | it "should proxy discard" do 117 | expect(source).to receive(:discard).and_return(nil) 118 | expect(body.discard).to be_nil 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/protocol/http/cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | # Copyright, 2022, by Herrick Fang. 6 | 7 | require_relative "quoted_string" 8 | 9 | module Protocol 10 | module HTTP 11 | # Represents an individual cookie key-value pair. 12 | class Cookie 13 | # Valid cookie name characters according to RFC 6265. 14 | # cookie-name = token (RFC 2616 defines token) 15 | VALID_COOKIE_KEY = /\A#{TOKEN}\z/.freeze 16 | 17 | # Valid cookie value characters according to RFC 6265. 18 | # cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) 19 | # cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E 20 | # Excludes control chars, whitespace, DQUOTE, comma, semicolon, and backslash 21 | VALID_COOKIE_VALUE = /\A[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\z/.freeze 22 | 23 | # Initialize the cookie with the given name, value, and directives. 24 | # 25 | # @parameter name [String] The name of the cookie, e.g. "session_id". 26 | # @parameter value [String] The value of the cookie, e.g. "1234". 27 | # @parameter directives [Hash] The directives of the cookie, e.g. `{"path" => "/"}`. 28 | # @raises [ArgumentError] If the name or value contains invalid characters. 29 | def initialize(name, value, directives = nil) 30 | unless VALID_COOKIE_KEY.match?(name) 31 | raise ArgumentError, "Invalid cookie name: #{name.inspect}" 32 | end 33 | 34 | if value && !VALID_COOKIE_VALUE.match?(value) 35 | raise ArgumentError, "Invalid cookie value: #{value.inspect}" 36 | end 37 | 38 | @name = name 39 | @value = value 40 | @directives = directives 41 | end 42 | 43 | # @attribute [String] The name of the cookie. 44 | attr_accessor :name 45 | 46 | # @attribute [String] The value of the cookie. 47 | attr_accessor :value 48 | 49 | # @attribute [Hash] The directives of the cookie. 50 | attr_accessor :directives 51 | 52 | # Convert the cookie to a string. 53 | # 54 | # @returns [String] The string representation of the cookie. 55 | def to_s 56 | buffer = String.new 57 | 58 | buffer << @name << "=" << @value 59 | 60 | if @directives 61 | @directives.each do |key, value| 62 | buffer << ";" 63 | buffer << key 64 | 65 | if value != true 66 | buffer << "=" << value.to_s 67 | end 68 | end 69 | end 70 | 71 | return buffer 72 | end 73 | 74 | # Parse a string into a cookie. 75 | # 76 | # @parameter string [String] The string to parse. 77 | # @returns [Cookie] The parsed cookie. 78 | def self.parse(string) 79 | head, *directives = string.split(/\s*;\s*/) 80 | 81 | key, value = head.split("=", 2) 82 | directives = self.parse_directives(directives) 83 | 84 | self.new(key, value, directives) 85 | end 86 | 87 | # Parse a list of strings into a hash of directives. 88 | # 89 | # @parameter strings [Array(String)] The list of strings to parse. 90 | # @returns [Hash] The hash of directives. 91 | def self.parse_directives(strings) 92 | strings.collect do |string| 93 | key, value = string.split("=", 2) 94 | [key, value || true] 95 | end.to_h 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/protocol/http/header/server_timing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | require_relative "../quoted_string" 8 | require_relative "../error" 9 | 10 | module Protocol 11 | module HTTP 12 | module Header 13 | # The `server-timing` header communicates performance metrics about the request-response cycle to the client. 14 | # 15 | # This header allows servers to send timing information about various server-side operations, which can be useful for performance monitoring and debugging. Each metric can include a name, optional duration, and optional description. 16 | # 17 | # ## Examples 18 | # 19 | # ```ruby 20 | # server_timing = ServerTiming.new("db;dur=53.2") 21 | # server_timing << "cache;dur=12.1;desc=\"Redis lookup\"" 22 | # puts server_timing.to_s 23 | # # => "db;dur=53.2, cache;dur=12.1;desc=\"Redis lookup\"" 24 | # ``` 25 | class ServerTiming < Split 26 | ParseError = Class.new(Error) 27 | 28 | # https://www.w3.org/TR/server-timing/ 29 | METRIC = /\A(?[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?.*))?\z/ 30 | PARAMETER = /(?dur|desc)=((?#{TOKEN})|(?#{QUOTED_STRING}))/ 31 | 32 | # A single metric in the Server-Timing header. 33 | Metric = Struct.new(:name, :duration, :description) do 34 | # Create a new server timing metric. 35 | # 36 | # @parameter name [String] the name of the metric. 37 | # @parameter duration [Float | Nil] the duration in milliseconds. 38 | # @parameter description [String | Nil] the description of the metric. 39 | def initialize(name, duration = nil, description = nil) 40 | super(name, duration, description) 41 | end 42 | 43 | # Convert the metric to its string representation. 44 | # 45 | # @returns [String] the formatted metric string. 46 | def to_s 47 | result = name.dup 48 | result << ";dur=#{duration}" if duration 49 | result << ";desc=\"#{description}\"" if description 50 | result 51 | end 52 | end 53 | 54 | # Parse the `server-timing` header value into a list of metrics. 55 | # 56 | # @returns [Array(Metric)] the list of metrics with their names, durations, and descriptions. 57 | def metrics 58 | self.map do |value| 59 | if match = value.match(METRIC) 60 | name = match[:name] 61 | parameters = match[:parameters] || "" 62 | 63 | duration = nil 64 | description = nil 65 | 66 | parameters.scan(PARAMETER) do |key, value, quoted_value| 67 | value = QuotedString.unquote(quoted_value) if quoted_value 68 | 69 | case key 70 | when "dur" 71 | duration = value.to_f 72 | when "desc" 73 | description = value 74 | end 75 | end 76 | 77 | Metric.new(name, duration, description) 78 | else 79 | raise ParseError.new("Could not parse server timing metric: #{value.inspect}") 80 | end 81 | end 82 | end 83 | 84 | # Whether this header is acceptable in HTTP trailers. 85 | # @returns [Boolean] `true`, as server-timing headers contain performance metrics that are typically calculated during response generation. 86 | def self.trailer? 87 | true 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/protocol/http/header/transfer_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "split" 7 | 8 | module Protocol 9 | module HTTP 10 | module Header 11 | # The `transfer-encoding` header indicates the encoding transformations that have been applied to the message body. 12 | # 13 | # The `transfer-encoding` header is used to specify the form of encoding used to safely transfer the message body between the sender and receiver. 14 | class TransferEncoding < Split 15 | # The `chunked` transfer encoding allows a server to send data of unknown length by breaking it into chunks. 16 | CHUNKED = "chunked" 17 | 18 | # The `gzip` transfer encoding compresses the message body using the gzip algorithm. 19 | GZIP = "gzip" 20 | 21 | # The `deflate` transfer encoding compresses the message body using the deflate algorithm. 22 | DEFLATE = "deflate" 23 | 24 | # The `compress` transfer encoding compresses the message body using the compress algorithm. 25 | COMPRESS = "compress" 26 | 27 | # The `identity` transfer encoding indicates no transformation has been applied. 28 | IDENTITY = "identity" 29 | 30 | # Parses a raw header value. 31 | # 32 | # @parameter value [String] a raw header value containing comma-separated encodings. 33 | # @returns [TransferEncoding] a new instance with normalized (lowercase) encodings. 34 | def self.parse(value) 35 | self.new(value.downcase.split(COMMA)) 36 | end 37 | 38 | # Coerces a value into a parsed header object. 39 | # 40 | # @parameter value [String | Array] the value to coerce. 41 | # @returns [TransferEncoding] a parsed header object with normalized values. 42 | def self.coerce(value) 43 | case value 44 | when Array 45 | self.new(value.map(&:downcase)) 46 | else 47 | self.parse(value.to_s) 48 | end 49 | end 50 | 51 | # Adds one or more comma-separated values to the transfer encoding header. The values are converted to lowercase for normalization. 52 | # 53 | # @parameter value [String] a raw header value containing one or more values separated by commas. 54 | def << value 55 | super(value.downcase) 56 | end 57 | 58 | # @returns [Boolean] whether the `chunked` encoding is present. 59 | def chunked? 60 | self.include?(CHUNKED) 61 | end 62 | 63 | # @returns [Boolean] whether the `gzip` encoding is present. 64 | def gzip? 65 | self.include?(GZIP) 66 | end 67 | 68 | # @returns [Boolean] whether the `deflate` encoding is present. 69 | def deflate? 70 | self.include?(DEFLATE) 71 | end 72 | 73 | # @returns [Boolean] whether the `compress` encoding is present. 74 | def compress? 75 | self.include?(COMPRESS) 76 | end 77 | 78 | # @returns [Boolean] whether the `identity` encoding is present. 79 | def identity? 80 | self.include?(IDENTITY) 81 | end 82 | 83 | # Whether this header is acceptable in HTTP trailers. 84 | # Transfer-Encoding headers control message framing and must not appear in trailers. 85 | # @returns [Boolean] `false`, as Transfer-Encoding headers are hop-by-hop and must precede the message body. 86 | def self.trailer? 87 | false 88 | end 89 | end 90 | end 91 | end 92 | end 93 | 94 | -------------------------------------------------------------------------------- /lib/protocol/http/header/split.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | module Header 9 | # Represents headers that can contain multiple distinct values separated by commas. 10 | # 11 | # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. 12 | class Split < Array 13 | # Regular expression used to split values on commas, with optional surrounding whitespace. 14 | COMMA = /\s*,\s*/ 15 | 16 | # Parses a raw header value. 17 | # 18 | # Split headers receive comma-separated values in a single header entry. This method splits the raw value into individual entries. 19 | # 20 | # @parameter value [String] a raw header value containing multiple entries separated by commas. 21 | # @returns [Split] a new instance containing the parsed values. 22 | def self.parse(value) 23 | self.new(value.split(COMMA)) 24 | end 25 | 26 | # Coerces a value into a parsed header object. 27 | # 28 | # This method is used by the Headers class when setting values via `[]=` to convert application values into the appropriate policy type. 29 | # 30 | # @parameter value [String | Array] the value to coerce. 31 | # @returns [Split] a parsed header object. 32 | def self.coerce(value) 33 | case value 34 | when Array 35 | self.new(value.map(&:to_s)) 36 | else 37 | self.parse(value.to_s) 38 | end 39 | end 40 | 41 | # Initializes a `Split` header with the given values. 42 | # 43 | # @parameter value [Array | String | Nil] an array of values, a raw header value, or `nil` for an empty header. 44 | def initialize(value = nil) 45 | if value.is_a?(Array) 46 | super(value) 47 | elsif value.is_a?(String) 48 | # Compatibility with the old constructor, prefer to use `parse` instead: 49 | super() 50 | self << value 51 | elsif value 52 | raise ArgumentError, "Invalid value: #{value.inspect}" 53 | end 54 | end 55 | 56 | # Adds one or more comma-separated values to the header. 57 | # 58 | # The input string is split into distinct entries and appended to the array. 59 | # 60 | # @parameter value [String] a raw header value containing one or more values separated by commas. 61 | def << value 62 | self.concat(value.split(COMMA)) 63 | end 64 | 65 | # Converts the parsed header value into a raw header value. 66 | # 67 | # @returns [String] a raw header value (comma-separated string). 68 | def to_s 69 | join(",") 70 | end 71 | 72 | # Whether this header is acceptable in HTTP trailers. 73 | # This is a base class for comma-separated headers, default is to disallow in trailers. 74 | # @returns [Boolean] `false`, as most comma-separated headers should not appear in trailers by default. 75 | def self.trailer? 76 | false 77 | end 78 | 79 | protected 80 | 81 | def reverse_find(&block) 82 | reverse_each do |value| 83 | return value if block.call(value) 84 | end 85 | 86 | return nil 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /context/headers.md: -------------------------------------------------------------------------------- 1 | # Headers 2 | 3 | This guide explains how to work with HTTP headers using `protocol-http`. 4 | 5 | ## Core Concepts 6 | 7 | `protocol-http` provides several core concepts for working with HTTP headers: 8 | 9 | - A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features. 10 | - Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting. 11 | - Trailer security validation to prevent HTTP request smuggling attacks. 12 | 13 | ## Usage 14 | 15 | The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers: 16 | 17 | ```ruby 18 | require 'protocol/http' 19 | 20 | headers = Protocol::HTTP::Headers.new 21 | headers.add('content-type', 'text/html') 22 | headers.add('set-cookie', 'session=abc123') 23 | 24 | # Access headers 25 | content_type = headers['content-type'] # => "text/html" 26 | 27 | # Check if header exists 28 | headers.include?('content-type') # => true 29 | ``` 30 | 31 | ### Header Policies 32 | 33 | Different header types have different behaviors for merging, validation, and trailer handling: 34 | 35 | ```ruby 36 | # Some headers can be specified multiple times 37 | headers.add('set-cookie', 'first=value1') 38 | headers.add('set-cookie', 'second=value2') 39 | 40 | # Others are singletons and will raise errors if duplicated 41 | headers.add('content-length', '100') 42 | # headers.add('content-length', '200') # Would raise DuplicateHeaderError 43 | ``` 44 | 45 | ### Structured Headers 46 | 47 | Some headers have specialized classes for parsing and formatting: 48 | 49 | ```ruby 50 | # Accept header with media ranges 51 | accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9') 52 | media_ranges = accept.media_ranges 53 | 54 | # Authorization header 55 | auth = Protocol::HTTP::Header::Authorization.basic('username', 'password') 56 | # => "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" 57 | ``` 58 | 59 | ### Trailer Security 60 | 61 | HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers: 62 | 63 | ```ruby 64 | # Working with trailers 65 | headers = Protocol::HTTP::Headers.new([ 66 | ['content-type', 'text/html'], 67 | ['content-length', '1000'] 68 | ]) 69 | 70 | # Start trailer section 71 | headers.trailer! 72 | 73 | # These will be allowed (safe metadata) 74 | headers.add('etag', '"12345"') 75 | headers.add('date', Time.now.httpdate) 76 | 77 | # These will be silently ignored for security 78 | headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk 79 | headers.add('connection', 'close') # Ignored - hop-by-hop header 80 | ``` 81 | 82 | The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers: 83 | 84 | **Allowed headers** (return `true` for `trailer?`): 85 | - `date` - Response generation timestamps. 86 | - `digest` - Content integrity verification. 87 | - `etag` - Cache validation tags. 88 | - `server-timing` - Performance metrics. 89 | 90 | **Forbidden headers** (return `false` for `trailer?`): 91 | - `authorization` - Prevents credential leakage. 92 | - `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior. 93 | - `cookie`, `set-cookie` - State information needed during initial processing. 94 | - `accept` - Content negotiation must occur before response generation. 95 | -------------------------------------------------------------------------------- /guides/headers/readme.md: -------------------------------------------------------------------------------- 1 | # Headers 2 | 3 | This guide explains how to work with HTTP headers using `protocol-http`. 4 | 5 | ## Core Concepts 6 | 7 | `protocol-http` provides several core concepts for working with HTTP headers: 8 | 9 | - A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features. 10 | - Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting. 11 | - Trailer security validation to prevent HTTP request smuggling attacks. 12 | 13 | ## Usage 14 | 15 | The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers: 16 | 17 | ```ruby 18 | require 'protocol/http' 19 | 20 | headers = Protocol::HTTP::Headers.new 21 | headers.add('content-type', 'text/html') 22 | headers.add('set-cookie', 'session=abc123') 23 | 24 | # Access headers 25 | content_type = headers['content-type'] # => "text/html" 26 | 27 | # Check if header exists 28 | headers.include?('content-type') # => true 29 | ``` 30 | 31 | ### Header Policies 32 | 33 | Different header types have different behaviors for merging, validation, and trailer handling: 34 | 35 | ```ruby 36 | # Some headers can be specified multiple times 37 | headers.add('set-cookie', 'first=value1') 38 | headers.add('set-cookie', 'second=value2') 39 | 40 | # Others are singletons and will raise errors if duplicated 41 | headers.add('content-length', '100') 42 | # headers.add('content-length', '200') # Would raise DuplicateHeaderError 43 | ``` 44 | 45 | ### Structured Headers 46 | 47 | Some headers have specialized classes for parsing and formatting: 48 | 49 | ```ruby 50 | # Accept header with media ranges 51 | accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9') 52 | media_ranges = accept.media_ranges 53 | 54 | # Authorization header 55 | auth = Protocol::HTTP::Header::Authorization.basic('username', 'password') 56 | # => "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" 57 | ``` 58 | 59 | ### Trailer Security 60 | 61 | HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers: 62 | 63 | ```ruby 64 | # Working with trailers 65 | headers = Protocol::HTTP::Headers.new([ 66 | ['content-type', 'text/html'], 67 | ['content-length', '1000'] 68 | ]) 69 | 70 | # Start trailer section 71 | headers.trailer! 72 | 73 | # These will be allowed (safe metadata) 74 | headers.add('etag', '"12345"') 75 | headers.add('date', Time.now.httpdate) 76 | 77 | # These will be silently ignored for security 78 | headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk 79 | headers.add('connection', 'close') # Ignored - hop-by-hop header 80 | ``` 81 | 82 | The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers: 83 | 84 | **Allowed headers** (return `true` for `trailer?`): 85 | - `date` - Response generation timestamps. 86 | - `digest` - Content integrity verification. 87 | - `etag` - Cache validation tags. 88 | - `server-timing` - Performance metrics. 89 | 90 | **Forbidden headers** (return `false` for `trailer?`): 91 | - `authorization` - Prevents credential leakage. 92 | - `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior. 93 | - `cookie`, `set-cookie` - State information needed during initial processing. 94 | - `accept` - Content negotiation must occur before response generation. 95 | -------------------------------------------------------------------------------- /test/protocol/http/header/cache_control.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | # Copyright, 2023, by Thomas Morgan. 6 | 7 | require "protocol/http/header/cache_control" 8 | 9 | describe Protocol::HTTP::Header::CacheControl do 10 | let(:header) {subject.parse(description)} 11 | 12 | with "max-age=60, s-maxage=30, public" do 13 | it "correctly parses cache header" do 14 | expect(header).to have_attributes( 15 | public?: be == true, 16 | private?: be == false, 17 | max_age: be == 60, 18 | s_maxage: be == 30, 19 | ) 20 | end 21 | end 22 | 23 | with "max-age=-10, s-maxage=0x22" do 24 | it "gracefully handles invalid values" do 25 | expect(header).to have_attributes( 26 | max_age: be == nil, 27 | s_maxage: be == nil, 28 | ) 29 | end 30 | end 31 | 32 | with "no-cache, no-store" do 33 | it "correctly parses cache header" do 34 | expect(header).to have_attributes( 35 | no_cache?: be == true, 36 | no_store?: be == true, 37 | ) 38 | end 39 | end 40 | 41 | with "static" do 42 | it "correctly parses cache header" do 43 | expect(header).to have_attributes( 44 | static?: be == true, 45 | ) 46 | end 47 | end 48 | 49 | with "dynamic" do 50 | it "correctly parses cache header" do 51 | expect(header).to have_attributes( 52 | dynamic?: be == true, 53 | ) 54 | end 55 | end 56 | 57 | with "streaming" do 58 | it "correctly parses cache header" do 59 | expect(header).to have_attributes( 60 | streaming?: be == true, 61 | ) 62 | end 63 | end 64 | 65 | with "must-revalidate" do 66 | it "correctly parses cache header" do 67 | expect(header).to have_attributes( 68 | must_revalidate?: be == true, 69 | ) 70 | end 71 | end 72 | 73 | with "proxy-revalidate" do 74 | it "correctly parses cache header" do 75 | expect(header).to have_attributes( 76 | proxy_revalidate?: be == true, 77 | ) 78 | end 79 | end 80 | 81 | with "#<<" do 82 | let(:header) {subject.new} 83 | 84 | it "can append values" do 85 | header << "max-age=60" 86 | expect(header).to have_attributes( 87 | max_age: be == 60, 88 | ) 89 | end 90 | end 91 | 92 | with ".coerce" do 93 | it "normalizes array values to lowercase" do 94 | header = subject.coerce(["PUBLIC", "NO-CACHE"]) 95 | expect(header).to be(:include?, "public") 96 | expect(header).to be(:include?, "no-cache") 97 | expect(header).not.to be(:include?, "PUBLIC") 98 | end 99 | 100 | it "normalizes string values to lowercase" do 101 | header = subject.coerce("PUBLIC, MAX-AGE=60") 102 | expect(header).to be(:include?, "public") 103 | expect(header).to be(:include?, "max-age=60") 104 | end 105 | end 106 | 107 | with ".new" do 108 | it "preserves case when given array" do 109 | header = subject.new(["PUBLIC", "NO-CACHE"]) 110 | expect(header).to be(:include?, "PUBLIC") 111 | expect(header).to be(:include?, "NO-CACHE") 112 | end 113 | 114 | it "normalizes when given string (backward compatibility)" do 115 | header = subject.new("PUBLIC, MAX-AGE=60") 116 | expect(header).to be(:include?, "public") 117 | expect(header).to be(:include?, "max-age=60") 118 | end 119 | 120 | it "raises ArgumentError for invalid value types" do 121 | expect{subject.new(123)}.to raise_exception(ArgumentError) 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /test/protocol/http/content_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "protocol/http/accept_encoding" 7 | require "protocol/http/content_encoding" 8 | 9 | describe Protocol::HTTP::ContentEncoding do 10 | with "complete text/plain response" do 11 | let(:middleware) {subject.new(Protocol::HTTP::Middleware::HelloWorld)} 12 | let(:accept_encoding) {Protocol::HTTP::AcceptEncoding.new(middleware)} 13 | 14 | it "can request resource without compression" do 15 | response = middleware.get("/index") 16 | 17 | expect(response).to be(:success?) 18 | expect(response.headers).not.to have_keys("content-encoding") 19 | expect(response.headers["vary"]).to be(:include?, "accept-encoding") 20 | 21 | expect(response.read).to be == "Hello World!" 22 | end 23 | 24 | it "can request a resource with the identity encoding" do 25 | response = accept_encoding.get("/index", {"accept-encoding" => "identity"}) 26 | 27 | expect(response).to be(:success?) 28 | expect(response.headers).not.to have_keys("content-encoding") 29 | expect(response.headers["vary"]).to be(:include?, "accept-encoding") 30 | 31 | expect(response.read).to be == "Hello World!" 32 | end 33 | 34 | it "can request resource with compression" do 35 | response = accept_encoding.get("/index", {"accept-encoding" => "gzip"}) 36 | expect(response).to be(:success?) 37 | 38 | expect(response.headers["vary"]).to be(:include?, "accept-encoding") 39 | 40 | expect(response.body).to be_a(Protocol::HTTP::Body::Inflate) 41 | expect(response.read).to be == "Hello World!" 42 | end 43 | end 44 | 45 | with "partial response" do 46 | let(:app) do 47 | proc do |request| 48 | Protocol::HTTP::Response[206, Protocol::HTTP::Headers["content-type" => "text/plain"], ["Hello World!"]] 49 | end 50 | end 51 | 52 | let(:client) {subject.new(app)} 53 | 54 | it "can request resource with compression" do 55 | response = client.get("/index", {"accept-encoding" => "gzip"}) 56 | expect(response).to be(:success?) 57 | 58 | expect(response.headers).not.to have_keys("content-encoding") 59 | expect(response.read).to be == "Hello World!" 60 | end 61 | end 62 | 63 | with "existing content encoding" do 64 | let(:app) do 65 | app = ->(request){Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain", "content-encoding" => "identity"], ["Hello World!"]] 66 | } 67 | end 68 | 69 | let(:client) {subject.new(app)} 70 | 71 | it "does not compress response" do 72 | response = client.get("/index", {"accept-encoding" => "gzip"}) 73 | 74 | expect(response).to be(:success?) 75 | expect(response.headers).to have_keys("content-encoding") 76 | expect(response.headers["content-encoding"]).to be == ["identity"] 77 | 78 | expect(response.read).to be == "Hello World!" 79 | end 80 | end 81 | 82 | with "nil body" do 83 | let(:app) do 84 | app = ->(request){Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain"], nil] 85 | } 86 | end 87 | 88 | let(:client) {subject.new(app)} 89 | 90 | it "does not compress response" do 91 | response = client.get("/index", {"accept-encoding" => "gzip"}) 92 | 93 | expect(response).to be(:success?) 94 | expect(response.headers).not.to have_keys("content-encoding") 95 | 96 | expect(response.read).to be == nil 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/protocol/http/body/completable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2025, by Samuel Williams. 5 | 6 | require "protocol/http/body/completable" 7 | require "protocol/http/body/buffered" 8 | require "protocol/http/request" 9 | 10 | describe Protocol::HTTP::Body::Completable do 11 | let(:body) {Protocol::HTTP::Body::Buffered.new} 12 | let(:callback) {Proc.new{}} 13 | let(:completable) {subject.new(body, callback)} 14 | 15 | it "can trigger callback when finished reading" do 16 | expect(callback).to receive(:call) 17 | 18 | expect(completable.read).to be_nil 19 | completable.close 20 | end 21 | 22 | AnImmediateCallback = Sus::Shared("an immediate callback") do 23 | it "invokes block immediately" do 24 | invoked = false 25 | 26 | wrapped = subject.wrap(message) do 27 | invoked = true 28 | end 29 | 30 | expect(invoked).to be == true 31 | expect(message.body).to be_equal(body) 32 | end 33 | end 34 | 35 | ADeferredCallback = Sus::Shared("a deferred callback") do 36 | it "invokes block when body is finished reading" do 37 | invoked = false 38 | 39 | wrapped = subject.wrap(message) do 40 | invoked = true 41 | end 42 | 43 | expect(invoked).to be == false 44 | expect(message.body).to be_equal(wrapped) 45 | 46 | wrapped.join 47 | 48 | expect(invoked).to be == true 49 | end 50 | end 51 | 52 | with ".wrap" do 53 | let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} 54 | 55 | with "empty body" do 56 | it_behaves_like AnImmediateCallback 57 | end 58 | 59 | with "nil body" do 60 | let(:body) {nil} 61 | 62 | it_behaves_like AnImmediateCallback 63 | end 64 | 65 | with "non-empty body" do 66 | let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello World")} 67 | 68 | it_behaves_like ADeferredCallback 69 | end 70 | end 71 | 72 | with "#finish" do 73 | it "invokes callback once" do 74 | expect(callback).to receive(:call) 75 | 76 | 2.times do 77 | completable.finish 78 | end 79 | end 80 | 81 | it "doesn't break #read after finishing" do 82 | completable.finish 83 | expect(completable.read).to be_nil 84 | end 85 | end 86 | 87 | with "#rewindable?" do 88 | it "is not rewindable" do 89 | # Because completion can only happen once, we can't rewind the body. 90 | expect(body).to be(:rewindable?) 91 | expect(completable).not.to be(:rewindable?) 92 | expect(completable.rewind).to be == false 93 | end 94 | end 95 | 96 | with "#close" do 97 | let(:events) {Array.new} 98 | let(:callback) {Proc.new{events << :close}} 99 | 100 | it "invokes callback once" do 101 | completable1 = subject.new(body, proc{events << :close1}) 102 | completable2 = subject.new(completable1, proc{events << :close2}) 103 | 104 | completable2.close 105 | 106 | expect(events).to be == [:close2, :close1] 107 | end 108 | end 109 | 110 | with "#as_json" do 111 | it "includes callback information" do 112 | completable = subject.new(body, proc{events << :close}) 113 | 114 | expect(completable.as_json).to have_keys( 115 | class: be == "Protocol::HTTP::Body::Completable", 116 | callback: be =~ /Proc/ 117 | ) 118 | end 119 | 120 | it "shows nil when no callback" do 121 | completable = subject.new(body, nil) 122 | 123 | expect(completable.as_json).to have_keys( 124 | class: be == "Protocol::HTTP::Body::Completable", 125 | callback: be == nil 126 | ) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/protocol/http/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "methods" 7 | require_relative "headers" 8 | require_relative "request" 9 | require_relative "response" 10 | 11 | module Protocol 12 | module HTTP 13 | # The middleware interface provides a convenient wrapper for implementing HTTP middleware. 14 | # 15 | # A middleware instance generally needs to respond to two methods: 16 | # 17 | # - `call(request)` -> `response` 18 | # - `close()` 19 | # 20 | # The call method is called for each request. The close method is called when the server is shutting down. 21 | # 22 | # You do not need to use the Middleware class to implement middleware. You can implement the interface directly. 23 | class Middleware < Methods 24 | # Convert a block to a middleware delegate. 25 | # 26 | # @parameter block [Proc] The block to convert to a middleware delegate. 27 | # @returns [Middleware] The middleware delegate. 28 | def self.for(&block) 29 | # Add a close method to the block. 30 | def block.close 31 | end 32 | 33 | return self.new(block) 34 | end 35 | 36 | # Initialize the middleware with the given delegate. 37 | # 38 | # @parameter delegate [Object] The delegate object. A delegate is used for passing along requests that are not handled by *this* middleware. 39 | def initialize(delegate) 40 | @delegate = delegate 41 | end 42 | 43 | # @attribute [Object] The delegate object that is used for passing along requests that are not handled by *this* middleware. 44 | attr :delegate 45 | 46 | # Close the middleware. Invokes the close method on the delegate. 47 | def close 48 | @delegate.close 49 | end 50 | 51 | # Call the middleware with the given request. Invokes the call method on the delegate. 52 | def call(request) 53 | @delegate.call(request) 54 | end 55 | 56 | # A simple middleware that always returns a 200 response. 57 | module Okay 58 | # Close the middleware - idempotent no-op. 59 | def self.close 60 | end 61 | 62 | # Call the middleware with the given request, always returning a 200 response. 63 | # 64 | # @parameter request [Request] The request object. 65 | # @returns [Response] The response object, which always contains a 200 status code. 66 | def self.call(request) 67 | Response[200] 68 | end 69 | end 70 | 71 | # A simple middleware that always returns a 404 response. 72 | module NotFound 73 | # Close the middleware - idempotent no-op. 74 | def self.close 75 | end 76 | 77 | # Call the middleware with the given request, always returning a 404 response. This middleware is useful as a default. 78 | # 79 | # @parameter request [Request] The request object. 80 | # @returns [Response] The response object, which always contains a 404 status code. 81 | def self.call(request) 82 | Response[404] 83 | end 84 | end 85 | 86 | # A simple middleware that always returns "Hello World!". 87 | module HelloWorld 88 | # Close the middleware - idempotent no-op. 89 | def self.close 90 | end 91 | 92 | # Call the middleware with the given request. 93 | # 94 | # @parameter request [Request] The request object. 95 | # @returns [Response] The response object, whihc always contains "Hello World!". 96 | def self.call(request) 97 | Response[200, Headers["content-type" => "text/plain"], ["Hello World!"]] 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/protocol/http/cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/cookie" 7 | 8 | describe Protocol::HTTP::Cookie do 9 | describe "#initialize" do 10 | it "accepts valid cookie names" do 11 | cookie = Protocol::HTTP::Cookie.new("session_id", "123") 12 | expect(cookie.name).to be == "session_id" 13 | expect(cookie.value).to be == "123" 14 | end 15 | 16 | it "accepts valid cookie values with allowed characters" do 17 | # Test cookie-octet range: !#$%&'()*+-./0-9:;<=>?@A-Z[]^_`a-z{|}~ 18 | cookie = Protocol::HTTP::Cookie.new("test", "abc123!#$%&'()*+-./:") 19 | expect(cookie.value).to be == "abc123!#$%&'()*+-./:" 20 | end 21 | 22 | it "rejects cookie names with invalid characters" do 23 | expect do 24 | Protocol::HTTP::Cookie.new("session id", "123") 25 | end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie name/) 26 | end 27 | 28 | it "rejects cookie names with semicolon" do 29 | expect do 30 | Protocol::HTTP::Cookie.new("session;id", "123") 31 | end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie name/) 32 | end 33 | 34 | it "rejects cookie values with control characters" do 35 | expect do 36 | Protocol::HTTP::Cookie.new("session", "123\n456") 37 | end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) 38 | end 39 | 40 | it "rejects cookie values with semicolon" do 41 | expect do 42 | Protocol::HTTP::Cookie.new("session", "123;456") 43 | end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) 44 | end 45 | 46 | it "rejects cookie values with comma" do 47 | expect do 48 | Protocol::HTTP::Cookie.new("session", "123,456") 49 | end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) 50 | end 51 | 52 | it "rejects cookie values with backslash" do 53 | expect do 54 | Protocol::HTTP::Cookie.new("session", "123\\456") 55 | end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) 56 | end 57 | 58 | it "rejects cookie values with double quote" do 59 | expect do 60 | Protocol::HTTP::Cookie.new("session", '"quoted"') 61 | end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) 62 | end 63 | 64 | it "accepts nil value" do 65 | cookie = Protocol::HTTP::Cookie.new("session", nil) 66 | expect(cookie.value).to be_nil 67 | end 68 | end 69 | 70 | describe "#to_s" do 71 | it "returns cookie name and value" do 72 | cookie = Protocol::HTTP::Cookie.new("session", "abc123") 73 | expect(cookie.to_s).to be == "session=abc123" 74 | end 75 | 76 | it "includes directives" do 77 | cookie = Protocol::HTTP::Cookie.new("session", "123", {"path" => "/", "secure" => true}) 78 | expect(cookie.to_s).to be == "session=123;path=/;secure" 79 | end 80 | end 81 | 82 | describe ".parse" do 83 | it "parses simple cookie" do 84 | cookie = Protocol::HTTP::Cookie.parse("session=123") 85 | expect(cookie.name).to be == "session" 86 | expect(cookie.value).to be == "123" 87 | end 88 | 89 | it "parses cookie with equals in value" do 90 | cookie = Protocol::HTTP::Cookie.parse("session=123==") 91 | expect(cookie.name).to be == "session" 92 | expect(cookie.value).to be == "123==" 93 | end 94 | 95 | it "parses cookie with directives" do 96 | cookie = Protocol::HTTP::Cookie.parse("session=123; path=/; secure") 97 | expect(cookie.name).to be == "session" 98 | expect(cookie.value).to be == "123" 99 | expect(cookie.directives).to be == {"path" => "/", "secure" => true} 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/protocol/http/methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | # Provides a convenient interface for commonly supported HTTP methods. 9 | # 10 | # | Method Name | Request Body | Response Body | Safe | Idempotent | Cacheable | 11 | # | ----------- | ------------ | ------------- | ---- | ---------- | --------- | 12 | # | GET | Optional | Yes | Yes | Yes | Yes | 13 | # | HEAD | Optional | No | Yes | Yes | Yes | 14 | # | POST | Yes | Yes | No | No | Yes | 15 | # | PUT | Yes | Yes | No | Yes | No | 16 | # | DELETE | Optional | Yes | No | Yes | No | 17 | # | CONNECT | Optional | Yes | No | No | No | 18 | # | OPTIONS | Optional | Yes | Yes | Yes | No | 19 | # | TRACE | No | Yes | Yes | Yes | No | 20 | # | PATCH | Yes | Yes | No | No | No | 21 | # 22 | # These methods are defined in this module using lower case names. They are for convenience only and you should not overload those methods. 23 | # 24 | # See for more details. 25 | class Methods 26 | # The GET method requests a representation of the specified resource. Requests using GET should only retrieve data. 27 | GET = "GET" 28 | 29 | # The HEAD method asks for a response identical to a GET request, but without the response body. 30 | HEAD = "HEAD" 31 | 32 | # The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server. 33 | POST = "POST" 34 | 35 | # The PUT method replaces all current representations of the target resource with the request payload. 36 | PUT = "PUT" 37 | 38 | # The DELETE method deletes the specified resource. 39 | DELETE = "DELETE" 40 | 41 | # The CONNECT method establishes a tunnel to the server identified by the target resource. 42 | CONNECT = "CONNECT" 43 | 44 | # The OPTIONS method describes the communication options for the target resource. 45 | OPTIONS = "OPTIONS" 46 | 47 | # The TRACE method performs a message loop-back test along the path to the target resource. 48 | TRACE = "TRACE" 49 | 50 | # The PATCH method applies partial modifications to a resource. 51 | PATCH = "PATCH" 52 | 53 | # Check if the given name is a valid HTTP method, according to this module. 54 | # 55 | # Note that this method only knows about the methods defined in this module, however there are many other methods defined in different specifications. 56 | # 57 | # @returns [Boolean] True if the name is a valid HTTP method. 58 | def self.valid?(name) 59 | const_defined?(name) 60 | rescue NameError 61 | # Ruby will raise an exception if the name is not valid for a constant. 62 | return false 63 | end 64 | 65 | # Enumerate all HTTP methods. 66 | # @yields {|name, value| ...} 67 | # @parameter name [Symbol] The name of the method, e.g. `:GET`. 68 | # @parameter value [String] The value of the method, e.g. `"GET"`. 69 | def self.each 70 | return to_enum(:each) unless block_given? 71 | 72 | constants.each do |name| 73 | yield name.downcase, const_get(name) 74 | end 75 | end 76 | 77 | self.each do |name, method| 78 | define_method(name) do |*arguments, **options| 79 | self.call( 80 | Request[method, *arguments, **options] 81 | ) 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/protocol/http/header/accept.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "protocol/http/header/accept" 7 | 8 | describe Protocol::HTTP::Header::Accept::MediaRange do 9 | it "should have default quality_factor of 1.0" do 10 | media_range = subject.new("text/plain", nil) 11 | expect(media_range.quality_factor).to be == 1.0 12 | end 13 | 14 | with "#to_s" do 15 | it "can convert to string" do 16 | media_range = subject.new("text", "plain", {"q" => "0.5"}) 17 | expect(media_range.to_s).to be == "text/plain;q=0.5" 18 | end 19 | end 20 | end 21 | 22 | describe Protocol::HTTP::Header::Accept do 23 | let(:header) {subject.parse(description)} 24 | let(:media_ranges) {header.media_ranges.sort} 25 | 26 | with "text/plain, text/html;q=0.5, text/xml;q=0.25" do 27 | it "can parse media ranges" do 28 | expect(header.length).to be == 3 29 | 30 | expect(media_ranges[0]).to have_attributes( 31 | type: be == "text", 32 | subtype: be == "plain", 33 | quality_factor: be == 1.0 34 | ) 35 | 36 | expect(media_ranges[1]).to have_attributes( 37 | type: be == "text", 38 | subtype: be == "html", 39 | quality_factor: be == 0.5 40 | ) 41 | 42 | expect(media_ranges[2]).to have_attributes( 43 | type: be == "text", 44 | subtype: be == "xml", 45 | quality_factor: be == 0.25 46 | ) 47 | end 48 | 49 | it "can convert to string" do 50 | expect(header.to_s).to be == "text/plain,text/html;q=0.5,text/xml;q=0.25" 51 | end 52 | end 53 | 54 | with "foobar" do 55 | it "fails to parse" do 56 | expect{media_ranges}.to raise_exception(Protocol::HTTP::Header::Accept::ParseError) 57 | end 58 | end 59 | 60 | with "text/html;q=0.25, text/xml;q=0.5, text/plain" do 61 | it "should order based on quality factor" do 62 | expect(media_ranges.collect(&:to_s)).to be == %w{text/plain text/xml;q=0.5 text/html;q=0.25} 63 | end 64 | end 65 | 66 | with "text/html, text/plain;q=0.8, text/xml;q=0.6, application/json" do 67 | it "should order based on quality factor" do 68 | expect(media_ranges.collect(&:to_s)).to be == %w{text/html application/json text/plain;q=0.8 text/xml;q=0.6} 69 | end 70 | end 71 | 72 | with "*/*" do 73 | it "should accept wildcard media range" do 74 | expect(media_ranges[0].to_s).to be == "*/*" 75 | end 76 | end 77 | 78 | with "text/html;schema=\"example.org\";q=0.5" do 79 | it "should parse parameters" do 80 | expect(media_ranges[0].parameters).to have_keys( 81 | "schema" => be == "example.org", 82 | "q" => be == "0.5", 83 | ) 84 | end 85 | end 86 | 87 | with ".coerce" do 88 | it "coerces array to Accept" do 89 | result = subject.coerce(["text/html", "application/json"]) 90 | expect(result).to be_a(subject) 91 | expect(result).to be == ["text/html", "application/json"] 92 | end 93 | 94 | it "coerces string to Accept" do 95 | result = subject.coerce("text/html, application/json") 96 | expect(result).to be_a(subject) 97 | expect(result).to be(:include?, "text/html") 98 | end 99 | end 100 | 101 | with ".new" do 102 | it "preserves values when given array" do 103 | header = subject.new(["text/html", "application/json"]) 104 | expect(header).to be(:include?, "text/html") 105 | expect(header).to be(:include?, "application/json") 106 | end 107 | 108 | it "can initialize with string (backward compatibility)" do 109 | header = subject.new("text/plain, text/html") 110 | expect(header).to be(:include?, "text/plain") 111 | expect(header).to be(:include?, "text/html") 112 | end 113 | 114 | it "raises ArgumentError for invalid value types" do 115 | expect{subject.new(123)}.to raise_exception(ArgumentError) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /context/streaming.md: -------------------------------------------------------------------------------- 1 | # Streaming 2 | 3 | This guide gives an overview of how to implement streaming requests and responses. 4 | 5 | ## Independent Uni-directional Streaming 6 | 7 | The request and response body work independently of each other can stream data in both directions. {ruby Protocol::HTTP::Body::Stream} provides an interface to merge these independent streams into an IO-like interface. 8 | 9 | ```ruby 10 | #!/usr/bin/env ruby 11 | 12 | require 'async' 13 | require 'async/http/client' 14 | require 'async/http/server' 15 | require 'async/http/endpoint' 16 | 17 | require 'protocol/http/body/stream' 18 | require 'protocol/http/body/writable' 19 | 20 | endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') 21 | 22 | Async do 23 | server = Async::HTTP::Server.for(endpoint) do |request| 24 | output = Protocol::HTTP::Body::Writable.new 25 | stream = Protocol::HTTP::Body::Stream.new(request.body, output) 26 | 27 | Async do 28 | # Simple echo server: 29 | while chunk = stream.readpartial(1024) 30 | stream.write(chunk) 31 | end 32 | rescue EOFError 33 | # Ignore EOF errors. 34 | ensure 35 | stream.close 36 | end 37 | 38 | Protocol::HTTP::Response[200, {}, output] 39 | end 40 | 41 | server_task = Async{server.run} 42 | 43 | client = Async::HTTP::Client.new(endpoint) 44 | 45 | input = Protocol::HTTP::Body::Writable.new 46 | response = client.get("/", body: input) 47 | 48 | begin 49 | stream = Protocol::HTTP::Body::Stream.new(response.body, input) 50 | 51 | stream.write("Hello, ") 52 | stream.write("World!") 53 | stream.close_write 54 | 55 | while chunk = stream.readpartial(1024) 56 | puts chunk 57 | end 58 | rescue EOFError 59 | # Ignore EOF errors. 60 | ensure 61 | stream.close 62 | end 63 | ensure 64 | server_task.stop 65 | end 66 | ``` 67 | 68 | This approach works quite well, especially when the input and output bodies are independently compressed, decompressed, or chunked. However, some protocols, notably, WebSockets operate on the raw connection and don't require this level of abstraction. 69 | 70 | ## Bi-directional Streaming 71 | 72 | While WebSockets can work on the above streaming interface, it's a bit more convenient to use the streaming interface directly, which gives raw access to the underlying stream where possible. 73 | 74 | ```ruby 75 | #!/usr/bin/env ruby 76 | 77 | require 'async' 78 | require 'async/http/client' 79 | require 'async/http/server' 80 | require 'async/http/endpoint' 81 | 82 | require 'protocol/http/body/stream' 83 | require 'protocol/http/body/writable' 84 | 85 | endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') 86 | 87 | Async do 88 | server = Async::HTTP::Server.for(endpoint) do |request| 89 | streamable = Protocol::HTTP::Body::Streamable. 90 | output = Protocol::HTTP::Body::Writable.new 91 | stream = Protocol::HTTP::Body::Stream.new(request.body, output) 92 | 93 | Async do 94 | # Simple echo server: 95 | while chunk = stream.readpartial(1024) 96 | stream.write(chunk) 97 | end 98 | rescue EOFError 99 | # Ignore EOF errors. 100 | ensure 101 | stream.close 102 | end 103 | 104 | Protocol::HTTP::Response[200, {}, output] 105 | end 106 | 107 | server_task = Async{server.run} 108 | 109 | client = Async::HTTP::Client.new(endpoint) 110 | 111 | input = Protocol::HTTP::Body::Writable.new 112 | response = client.get("/", body: input) 113 | 114 | begin 115 | stream = Protocol::HTTP::Body::Stream.new(response.body, input) 116 | 117 | stream.write("Hello, ") 118 | stream.write("World!") 119 | stream.close_write 120 | 121 | while chunk = stream.readpartial(1024) 122 | puts chunk 123 | end 124 | rescue EOFError 125 | # Ignore EOF errors. 126 | ensure 127 | stream.close 128 | end 129 | ensure 130 | server_task.stop 131 | end 132 | ``` 133 | -------------------------------------------------------------------------------- /guides/streaming/readme.md: -------------------------------------------------------------------------------- 1 | # Streaming 2 | 3 | This guide gives an overview of how to implement streaming requests and responses. 4 | 5 | ## Independent Uni-directional Streaming 6 | 7 | The request and response body work independently of each other can stream data in both directions. {ruby Protocol::HTTP::Body::Stream} provides an interface to merge these independent streams into an IO-like interface. 8 | 9 | ```ruby 10 | #!/usr/bin/env ruby 11 | 12 | require 'async' 13 | require 'async/http/client' 14 | require 'async/http/server' 15 | require 'async/http/endpoint' 16 | 17 | require 'protocol/http/body/stream' 18 | require 'protocol/http/body/writable' 19 | 20 | endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') 21 | 22 | Async do 23 | server = Async::HTTP::Server.for(endpoint) do |request| 24 | output = Protocol::HTTP::Body::Writable.new 25 | stream = Protocol::HTTP::Body::Stream.new(request.body, output) 26 | 27 | Async do 28 | # Simple echo server: 29 | while chunk = stream.readpartial(1024) 30 | stream.write(chunk) 31 | end 32 | rescue EOFError 33 | # Ignore EOF errors. 34 | ensure 35 | stream.close 36 | end 37 | 38 | Protocol::HTTP::Response[200, {}, output] 39 | end 40 | 41 | server_task = Async{server.run} 42 | 43 | client = Async::HTTP::Client.new(endpoint) 44 | 45 | input = Protocol::HTTP::Body::Writable.new 46 | response = client.get("/", body: input) 47 | 48 | begin 49 | stream = Protocol::HTTP::Body::Stream.new(response.body, input) 50 | 51 | stream.write("Hello, ") 52 | stream.write("World!") 53 | stream.close_write 54 | 55 | while chunk = stream.readpartial(1024) 56 | puts chunk 57 | end 58 | rescue EOFError 59 | # Ignore EOF errors. 60 | ensure 61 | stream.close 62 | end 63 | ensure 64 | server_task.stop 65 | end 66 | ``` 67 | 68 | This approach works quite well, especially when the input and output bodies are independently compressed, decompressed, or chunked. However, some protocols, notably, WebSockets operate on the raw connection and don't require this level of abstraction. 69 | 70 | ## Bi-directional Streaming 71 | 72 | While WebSockets can work on the above streaming interface, it's a bit more convenient to use the streaming interface directly, which gives raw access to the underlying stream where possible. 73 | 74 | ```ruby 75 | #!/usr/bin/env ruby 76 | 77 | require 'async' 78 | require 'async/http/client' 79 | require 'async/http/server' 80 | require 'async/http/endpoint' 81 | 82 | require 'protocol/http/body/stream' 83 | require 'protocol/http/body/writable' 84 | 85 | endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') 86 | 87 | Async do 88 | server = Async::HTTP::Server.for(endpoint) do |request| 89 | streamable = Protocol::HTTP::Body::Streamable. 90 | output = Protocol::HTTP::Body::Writable.new 91 | stream = Protocol::HTTP::Body::Stream.new(request.body, output) 92 | 93 | Async do 94 | # Simple echo server: 95 | while chunk = stream.readpartial(1024) 96 | stream.write(chunk) 97 | end 98 | rescue EOFError 99 | # Ignore EOF errors. 100 | ensure 101 | stream.close 102 | end 103 | 104 | Protocol::HTTP::Response[200, {}, output] 105 | end 106 | 107 | server_task = Async{server.run} 108 | 109 | client = Async::HTTP::Client.new(endpoint) 110 | 111 | input = Protocol::HTTP::Body::Writable.new 112 | response = client.get("/", body: input) 113 | 114 | begin 115 | stream = Protocol::HTTP::Body::Stream.new(response.body, input) 116 | 117 | stream.write("Hello, ") 118 | stream.write("World!") 119 | stream.close_write 120 | 121 | while chunk = stream.readpartial(1024) 122 | puts chunk 123 | end 124 | rescue EOFError 125 | # Ignore EOF errors. 126 | ensure 127 | stream.close 128 | end 129 | ensure 130 | server_task.stop 131 | end 132 | ``` 133 | -------------------------------------------------------------------------------- /lib/protocol/http/body/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "readable" 7 | 8 | module Protocol 9 | module HTTP 10 | module Body 11 | # A body which reads from a file. 12 | class File < Readable 13 | # The default block size. 14 | BLOCK_SIZE = 64*1024 15 | 16 | # The default mode for opening files. 17 | MODE = ::File::RDONLY | ::File::BINARY 18 | 19 | # Open a file at the given path. 20 | # 21 | # @parameter path [String] the path to the file. 22 | def self.open(path, *arguments, **options) 23 | self.new(::File.open(path, MODE), *arguments, **options) 24 | end 25 | 26 | # Initialize the file body with the given file. 27 | # 28 | # @parameter file [::File] the file to read from. 29 | # @parameter range [Range] the range of bytes to read from the file. 30 | # @parameter size [Integer] the size of the file, if known. 31 | # @parameter block_size [Integer] the block size to use when reading from the file. 32 | def initialize(file, range = nil, size: file.size, block_size: BLOCK_SIZE) 33 | @file = file 34 | @range = range 35 | 36 | @block_size = block_size 37 | 38 | if range 39 | @file.seek(range.min) 40 | @offset = range.min 41 | @length = @remaining = range.size 42 | else 43 | @file.seek(0) 44 | @offset = 0 45 | @length = @remaining = size 46 | end 47 | end 48 | 49 | # Close the file. 50 | # 51 | # @parameter error [Exception | Nil] the error that caused the file to be closed. 52 | def close(error = nil) 53 | @file.close 54 | @remaining = 0 55 | 56 | super 57 | end 58 | 59 | # @attribute [::File] file the file to read from. 60 | attr :file 61 | 62 | # @attribute [Integer] the offset to read from. 63 | attr :offset 64 | 65 | # @attribute [Integer] the number of bytes to read. 66 | attr :length 67 | 68 | # @returns [Boolean] whether more data should be read. 69 | def empty? 70 | @remaining == 0 71 | end 72 | 73 | # @returns [Boolean] whether the body is ready to be read, always true for files. 74 | def ready? 75 | true 76 | end 77 | 78 | # Returns a copy of the body, by duplicating the file descriptor, including the same range if specified. 79 | # 80 | # @returns [File] the duplicated body. 81 | def buffered 82 | self.class.new(@file.dup, @range, block_size: @block_size) 83 | end 84 | 85 | # Rewind the file to the beginning of the range. 86 | def rewind 87 | @file.seek(@offset) 88 | @remaining = @length 89 | end 90 | 91 | # @returns [Boolean] whether the body is rewindable, generally always true for seekable files. 92 | def rewindable? 93 | true 94 | end 95 | 96 | # Read the next chunk of data from the file. 97 | # 98 | # @returns [String | Nil] the next chunk of data, or nil if the file is fully read. 99 | def read 100 | if @remaining > 0 101 | amount = [@remaining, @block_size].min 102 | 103 | if chunk = @file.read(amount) 104 | @remaining -= chunk.bytesize 105 | 106 | return chunk 107 | end 108 | end 109 | end 110 | 111 | # def stream? 112 | # true 113 | # end 114 | 115 | # def call(stream) 116 | # IO.copy_stream(@file, stream, @remaining) 117 | # ensure 118 | # stream.close 119 | # end 120 | 121 | # Read all the remaining data from the file and return it as a single string. 122 | # 123 | # @returns [String] the remaining data. 124 | def join 125 | return "" if @remaining == 0 126 | 127 | buffer = @file.read(@remaining) 128 | 129 | @remaining = 0 130 | 131 | return buffer 132 | end 133 | 134 | # Inspect the file body. 135 | # 136 | # @returns [String] a string representation of the file body. 137 | def inspect 138 | if @offset > 0 139 | "#<#{self.class} #{@file.inspect} +#{@offset}, #{@remaining} bytes remaining>" 140 | else 141 | "#<#{self.class} #{@file.inspect}, #{@remaining} bytes remaining>" 142 | end 143 | end 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/protocol/http/body/deflate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "wrapper" 7 | 8 | require "zlib" 9 | 10 | module Protocol 11 | module HTTP 12 | module Body 13 | # A body which compresses or decompresses the contents using the DEFLATE or GZIP algorithm. 14 | class ZStream < Wrapper 15 | # The default compression level. 16 | DEFAULT_LEVEL = 7 17 | 18 | # The DEFLATE window size. 19 | DEFLATE = -Zlib::MAX_WBITS 20 | 21 | # The GZIP window size. 22 | GZIP = Zlib::MAX_WBITS | 16 23 | 24 | # The supported encodings. 25 | ENCODINGS = { 26 | "deflate" => DEFLATE, 27 | "gzip" => GZIP, 28 | } 29 | 30 | # Initialize the body with the given stream. 31 | # 32 | # @parameter body [Readable] the body to wrap. 33 | # @parameter stream [Zlib::Deflate | Zlib::Inflate] the stream to use for compression or decompression. 34 | def initialize(body, stream) 35 | super(body) 36 | 37 | @stream = stream 38 | 39 | @input_length = 0 40 | @output_length = 0 41 | end 42 | 43 | # Close the stream. 44 | # 45 | # @parameter error [Exception | Nil] the error that caused the stream to be closed. 46 | def close(error = nil) 47 | if stream = @stream 48 | @stream = nil 49 | stream.close unless stream.closed? 50 | end 51 | 52 | super 53 | end 54 | 55 | # The length of the output, if known. Generally, this is not known due to the nature of compression. 56 | def length 57 | # We don't know the length of the output until after it's been compressed. 58 | nil 59 | end 60 | 61 | # @attribute [Integer] input_length the total number of bytes read from the input. 62 | attr :input_length 63 | 64 | # @attribute [Integer] output_length the total number of bytes written to the output. 65 | attr :output_length 66 | 67 | # The compression ratio, according to the input and output lengths. 68 | # 69 | # @returns [Float] the compression ratio, e.g. 0.5 for 50% compression. 70 | def ratio 71 | if @input_length != 0 72 | @output_length.to_f / @input_length.to_f 73 | else 74 | 1.0 75 | end 76 | end 77 | 78 | # Convert the body to a hash suitable for serialization. 79 | # 80 | # @returns [Hash] The body as a hash. 81 | def as_json(...) 82 | super.merge( 83 | input_length: @input_length, 84 | output_length: @output_length, 85 | compression_ratio: (ratio * 100).round(2) 86 | ) 87 | end 88 | 89 | # Inspect the body, including the compression ratio. 90 | # 91 | # @returns [String] a string representation of the body. 92 | def inspect 93 | "#{super} | #<#{self.class} #{(ratio*100).round(2)}%>" 94 | end 95 | end 96 | 97 | # A body which compresses the contents using the DEFLATE or GZIP algorithm. 98 | class Deflate < ZStream 99 | # Create a new body which compresses the given body using the GZIP algorithm by default. 100 | # 101 | # @parameter body [Readable] the body to wrap. 102 | # @parameter window_size [Integer] the window size to use for compression. 103 | # @parameter level [Integer] the compression level to use. 104 | # @returns [Deflate] the wrapped body. 105 | def self.for(body, window_size = GZIP, level = DEFAULT_LEVEL) 106 | self.new(body, Zlib::Deflate.new(level, window_size)) 107 | end 108 | 109 | # Read a chunk from the underlying body and compress it. If the body is finished, the stream is flushed and finished, and the remaining data is returned. 110 | # 111 | # @returns [String | Nil] the compressed chunk or `nil` if the stream is closed. 112 | def read 113 | return if @stream.finished? 114 | 115 | # The stream might have been closed while waiting for the chunk to come in. 116 | if chunk = super 117 | @input_length += chunk.bytesize 118 | 119 | chunk = @stream.deflate(chunk, Zlib::SYNC_FLUSH) 120 | 121 | @output_length += chunk.bytesize 122 | 123 | return chunk 124 | elsif !@stream.closed? 125 | chunk = @stream.finish 126 | 127 | @output_length += chunk.bytesize 128 | 129 | return chunk.empty? ? nil : chunk 130 | end 131 | end 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /context/url-parsing.md: -------------------------------------------------------------------------------- 1 | # URL Parsing 2 | 3 | This guide explains how to use `Protocol::HTTP::URL` for parsing and manipulating URL components, particularly query strings and parameters. 4 | 5 | ## Overview 6 | 7 | {ruby Protocol::HTTP::URL} provides utilities for parsing and manipulating URL components, particularly query strings and parameters. It offers robust encoding/decoding capabilities for complex parameter structures. 8 | 9 | While basic query parameter encoding follows the `application/x-www-form-urlencoded` standard, there is no universal standard for serializing complex nested structures (arrays, nested objects) in URLs. Different frameworks use varying conventions for these cases, and this implementation follows common patterns where possible. 10 | 11 | ## Basic Query Parameter Parsing 12 | 13 | ``` ruby 14 | require 'protocol/http/url' 15 | 16 | # Parse query parameters from a URL: 17 | reference = Protocol::HTTP::Reference.parse("/search?q=ruby&category=programming&page=2") 18 | parameters = Protocol::HTTP::URL.decode(reference.query) 19 | # => {"q" => "ruby", "category" => "programming", "page" => "2"} 20 | 21 | # Symbolize keys for easier access: 22 | parameters = Protocol::HTTP::URL.decode(reference.query, symbolize_keys: true) 23 | # => {:q => "ruby", :category => "programming", :page => "2"} 24 | ``` 25 | 26 | ## Complex Parameter Structures 27 | 28 | The URL module handles nested parameters, arrays, and complex data structures: 29 | 30 | ``` ruby 31 | # Array parameters: 32 | query = "tags[]=ruby&tags[]=programming&tags[]=web" 33 | parameters = Protocol::HTTP::URL.decode(query) 34 | # => {"tags" => ["ruby", "programming", "web"]} 35 | 36 | # Nested hash parameters: 37 | query = "user[name]=John&user[email]=john@example.com&user[preferences][theme]=dark" 38 | parameters = Protocol::HTTP::URL.decode(query) 39 | # => {"user" => {"name" => "John", "email" => "john@example.com", "preferences" => {"theme" => "dark"}}} 40 | 41 | # Mixed structures: 42 | query = "filters[categories][]=books&filters[categories][]=movies&filters[price][min]=10&filters[price][max]=100" 43 | parameters = Protocol::HTTP::URL.decode(query) 44 | # => {"filters" => {"categories" => ["books", "movies"], "price" => {"min" => "10", "max" => "100"}}} 45 | ``` 46 | 47 | ## Encoding Parameters to Query Strings 48 | 49 | ``` ruby 50 | # Simple parameters: 51 | parameters = {"search" => "protocol-http", "limit" => "20"} 52 | query = Protocol::HTTP::URL.encode(parameters) 53 | # => "search=protocol-http&limit=20" 54 | 55 | # Array parameters: 56 | parameters = {"tags" => ["ruby", "http", "protocol"]} 57 | query = Protocol::HTTP::URL.encode(parameters) 58 | # => "tags[]=ruby&tags[]=http&tags[]=protocol" 59 | 60 | # Nested parameters: 61 | parameters = { 62 | user: { 63 | profile: { 64 | name: "Alice", 65 | settings: { 66 | notifications: true, 67 | theme: "light" 68 | } 69 | } 70 | } 71 | } 72 | query = Protocol::HTTP::URL.encode(parameters) 73 | # => "user[profile][name]=Alice&user[profile][settings][notifications]=true&user[profile][settings][theme]=light" 74 | ``` 75 | 76 | ## URL Escaping and Unescaping 77 | 78 | ``` ruby 79 | # Escape special characters: 80 | Protocol::HTTP::URL.escape("hello world!") 81 | # => "hello%20world%21" 82 | 83 | # Escape path components (preserves path separators): 84 | Protocol::HTTP::URL.escape_path("/path/with spaces/file.html") 85 | # => "/path/with%20spaces/file.html" 86 | 87 | # Unescape percent-encoded strings: 88 | Protocol::HTTP::URL.unescape("hello%20world%21") 89 | # => "hello world!" 90 | 91 | # Handle Unicode characters: 92 | Protocol::HTTP::URL.escape("café") 93 | # => "caf%C3%A9" 94 | 95 | Protocol::HTTP::URL.unescape("caf%C3%A9") 96 | # => "café" 97 | ``` 98 | 99 | ## Scanning and Processing Query Strings 100 | 101 | For custom processing, you can scan query strings directly: 102 | 103 | ``` ruby 104 | query = "name=John&age=30&active=true" 105 | 106 | Protocol::HTTP::URL.scan(query) do |key, value| 107 | puts "#{key}: #{value}" 108 | end 109 | # Output: 110 | # name: John 111 | # age: 30 112 | # active: true 113 | ``` 114 | 115 | ## Security and Limits 116 | 117 | The URL module includes built-in protection against deeply nested parameter attacks: 118 | 119 | ``` ruby 120 | # This will raise an error to prevent excessive nesting: 121 | begin 122 | Protocol::HTTP::URL.decode("a[b][c][d][e][f][g][h][i]=value") 123 | rescue ArgumentError => error 124 | puts error.message 125 | # => "Key length exceeded limit!" 126 | end 127 | 128 | # You can adjust the maximum nesting level: 129 | Protocol::HTTP::URL.decode("a[b][c]=value", 5) # Allow up to 5 levels of nesting 130 | ``` 131 | --------------------------------------------------------------------------------