├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── .mailmap ├── .rubocop.yml ├── bake.rb ├── benchmark └── string.rb ├── config ├── external.yaml └── sus.rb ├── examples └── streaming │ ├── bidirectional.rb │ ├── bidirectional2.rb │ ├── gems.locked │ ├── gems.rb │ ├── simple.rb │ ├── unidirectional.rb │ └── unidirectional2.rb ├── fixtures └── protocol │ └── http │ └── body │ ├── a_readable_body.rb │ └── a_writable_body.rb ├── gems.rb ├── guides ├── design-overview │ └── readme.md ├── getting-started │ └── readme.md ├── links.yaml └── streaming │ └── readme.md ├── lib └── protocol │ ├── http.rb │ └── http │ ├── accept_encoding.rb │ ├── body.rb │ ├── body │ ├── buffered.rb │ ├── completable.rb │ ├── deflate.rb │ ├── digestable.rb │ ├── file.rb │ ├── head.rb │ ├── inflate.rb │ ├── readable.rb │ ├── reader.rb │ ├── rewindable.rb │ ├── stream.rb │ ├── streamable.rb │ ├── wrapper.rb │ └── writable.rb │ ├── content_encoding.rb │ ├── cookie.rb │ ├── error.rb │ ├── header │ ├── accept.rb │ ├── accept_charset.rb │ ├── accept_encoding.rb │ ├── accept_language.rb │ ├── authorization.rb │ ├── cache_control.rb │ ├── connection.rb │ ├── cookie.rb │ ├── date.rb │ ├── etag.rb │ ├── etags.rb │ ├── multiple.rb │ ├── priority.rb │ ├── quoted_string.rb │ ├── split.rb │ └── vary.rb │ ├── headers.rb │ ├── methods.rb │ ├── middleware.rb │ ├── middleware │ └── builder.rb │ ├── peer.rb │ ├── reference.rb │ ├── request.rb │ ├── response.rb │ ├── url.rb │ └── version.rb ├── license.md ├── protocol-http.gemspec ├── readme.md ├── release.cert ├── releases.md └── test └── protocol └── http ├── body ├── buffered.rb ├── completable.rb ├── deflate.rb ├── digestable.rb ├── file.rb ├── file_spec.txt ├── head.rb ├── inflate.rb ├── readable.rb ├── reader.rb ├── reader_spec.txt ├── rewindable.rb ├── stream.rb ├── streamable.rb ├── wrapper.rb └── writable.rb ├── content_encoding.rb ├── header ├── accept.rb ├── accept_charset.rb ├── accept_encoding.rb ├── accept_language.rb ├── authorization.rb ├── cache_control.rb ├── connection.rb ├── cookie.rb ├── date.rb ├── etag.rb ├── etags.rb ├── multiple.rb ├── priority.rb ├── quoted_string.rb └── vary.rb ├── headers.rb ├── headers └── merged.rb ├── http.rb ├── methods.rb ├── middleware.rb ├── middleware └── builder.rb ├── peer.rb ├── reference.rb ├── request.rb ├── response.rb └── url.rb /.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 | -------------------------------------------------------------------------------- /.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 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.4" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.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 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.4" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 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: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.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 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.4" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.4" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 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 | - "3.2" 24 | - "3.3" 25 | - "3.4" 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{matrix.ruby}} 32 | bundler-cache: true 33 | 34 | - name: Run tests 35 | timeout-minutes: 10 36 | run: bundle exec bake test:external 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.2" 25 | - "3.3" 26 | - "3.4" 27 | 28 | experimental: [false] 29 | 30 | include: 31 | - os: ubuntu 32 | ruby: truffleruby 33 | experimental: true 34 | - os: ubuntu 35 | ruby: jruby 36 | experimental: true 37 | - os: ubuntu 38 | ruby: head 39 | experimental: true 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{matrix.ruby}} 46 | bundler-cache: true 47 | 48 | - name: Run tests 49 | timeout-minutes: 10 50 | run: bundle exec bake test 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Dan Olson 2 | Thomas Morgan 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Layout/EmptyLineAfterMagicComment: 49 | Enabled: true 50 | 51 | Style/FrozenStringLiteralComment: 52 | Enabled: true 53 | 54 | Style/StringLiterals: 55 | Enabled: true 56 | EnforcedStyle: double_quotes 57 | -------------------------------------------------------------------------------- /bake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, 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:readme:update"].call 12 | end 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | # Specify your gem's dependencies in protocol-http.gemspec 9 | gemspec 10 | 11 | group :maintenance, optional: true do 12 | gem "bake-modernize" 13 | gem "bake-gem" 14 | 15 | gem "utopia-project", "~> 0.18" 16 | gem "bake-releases" 17 | end 18 | 19 | group :test do 20 | gem "covered" 21 | gem "sus" 22 | gem "decode" 23 | gem "rubocop" 24 | 25 | gem "sus-fixtures-async" 26 | 27 | gem "bake-test" 28 | gem "bake-test-external" 29 | end 30 | 31 | # gem "async-http", path: "../async-http" 32 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to use `protocol-http` for building abstract HTTP interfaces. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add protocol-http 11 | ~~~ 12 | 13 | ## Core Concepts 14 | 15 | `protocol-http` has several core concepts: 16 | 17 | - A {ruby Protocol::HTTP::Request} instance which represents an abstract HTTP request. Specific versions of HTTP may subclass this to track additional state. 18 | - A {ruby Protocol::HTTP::Response} instance which represents an abstract HTTP response. Specific versions of HTTP may subclass this to track additional state. 19 | - A {ruby Protocol::HTTP::Middleware} interface for building HTTP applications. 20 | - A {ruby Protocol::HTTP::Headers} interface for storing HTTP headers with semantics based on documented specifications (RFCs, etc). 21 | - A set of {ruby Protocol::HTTP::Body} classes which handle the internal request and response bodies, including bi-directional streaming. 22 | 23 | ## Integration 24 | 25 | This gem does not provide any specific client or server implementation, rather it's used by several other gems. 26 | 27 | - [Protocol::HTTP1](https://github.com/socketry/protocol-http1) & [Protocol::HTTP2](https://github.com/socketry/protocol-http2) which provide client and server implementations. 28 | - [Async::HTTP](https://github.com/socketry/async-http) which provides connection pooling and concurrency. 29 | 30 | ## Usage 31 | 32 | ### Headers 33 | 34 | {ruby Protocol::HTTP::Headers} provides semantically meaningful interpretation of header values implements case-normalising keys. 35 | 36 | ``` ruby 37 | require 'protocol/http/headers' 38 | 39 | headers = Protocol::HTTP::Headers.new 40 | 41 | headers['Content-Type'] = "image/jpeg" 42 | 43 | headers['content-type'] 44 | # => "image/jpeg" 45 | ``` 46 | 47 | ### Hypertext References 48 | 49 | {ruby Protocol::HTTP::Reference} is used to construct "hypertext references" which consist of a path and URL-encoded key/value pairs. 50 | 51 | ``` ruby 52 | require 'protocol/http/reference' 53 | 54 | reference = Protocol::HTTP::Reference.new("/search", q: 'kittens') 55 | 56 | reference.to_s 57 | # => "/search?q=kittens" 58 | ``` 59 | 60 | ### URL Parsing 61 | 62 | {ruby Protocol::HTTP::URL} is used to parse incoming URLs to extract the query and other relevant details. 63 | 64 | ``` ruby 65 | require 'protocol/http/url' 66 | 67 | reference = Protocol::HTTP::Reference.parse("/search?q=kittens") 68 | 69 | parameters = Protocol::HTTP::URL.decode(reference.query) 70 | # => {"q"=>"kittens"} 71 | ``` 72 | 73 | This implementation may be merged with {ruby Protocol::HTTP::Reference} or removed in the future. 74 | -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 1 3 | design-overview: 4 | order: 10 5 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 25 | # There is no point including this: 26 | # 'identity' => ->(body){body}, 27 | } 28 | 29 | # Initialize the middleware with the given delegate and wrappers. 30 | # 31 | # @parameter delegate [Protocol::HTTP::Middleware] The delegate middleware. 32 | # @parameter wrappers [Hash] A hash of encoding names to wrapper functions. 33 | def initialize(delegate, wrappers = DEFAULT_WRAPPERS) 34 | super(delegate) 35 | 36 | @accept_encoding = wrappers.keys.join(", ") 37 | @wrappers = wrappers 38 | end 39 | 40 | # Set the accept-encoding header and decode the response body. 41 | # 42 | # @parameter request [Protocol::HTTP::Request] The request to modify. 43 | # @returns [Protocol::HTTP::Response] The response. 44 | def call(request) 45 | request.headers[ACCEPT_ENCODING] = @accept_encoding 46 | 47 | response = super 48 | 49 | if body = response.body and !body.empty? and content_encoding = response.headers.delete(CONTENT_ENCODING) 50 | # We want to unwrap all encodings 51 | content_encoding.reverse_each do |name| 52 | if wrapper = @wrappers[name] 53 | body = wrapper.call(body) 54 | end 55 | end 56 | 57 | response.body = body 58 | end 59 | 60 | return response 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/protocol/http/body/buffered.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | # Copyright, 2020, by Bryan Powell. 6 | # Copyright, 2025, by William T. Nelson. 7 | 8 | require_relative "readable" 9 | 10 | module Protocol 11 | module HTTP 12 | module Body 13 | # A body which buffers all its contents. 14 | class Buffered < Readable 15 | # Tries to wrap an object in a {Buffered} instance. 16 | # 17 | # For compatibility, also accepts anything that behaves like an `Array(String)`. 18 | # 19 | # @parameter body [String | Array(String) | Readable | nil] the body to wrap. 20 | # @returns [Readable | nil] the wrapped body or nil if nil was given. 21 | def self.wrap(object) 22 | if object.is_a?(Readable) 23 | return object 24 | elsif object.is_a?(Array) 25 | return self.new(object) 26 | elsif object.is_a?(String) 27 | return self.new([object]) 28 | elsif object 29 | return self.read(object) 30 | end 31 | end 32 | 33 | # Read the entire body into a buffered representation. 34 | # 35 | # @parameter body [Readable] the body to read. 36 | # @returns [Buffered] the buffered body. 37 | def self.read(body) 38 | chunks = [] 39 | 40 | body.each do |chunk| 41 | chunks << chunk 42 | end 43 | 44 | self.new(chunks) 45 | end 46 | 47 | # Initialize the buffered body with some chunks. 48 | # 49 | # @parameter chunks [Array(String)] the chunks to buffer. 50 | # @parameter length [Integer] the length of the body, if known. 51 | def initialize(chunks = [], length = nil) 52 | @chunks = chunks 53 | @length = length 54 | 55 | @index = 0 56 | end 57 | 58 | # @attribute [Array(String)] chunks the buffered chunks. 59 | attr :chunks 60 | 61 | # 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. 62 | # 63 | # @returns [Buffered] the buffered body. 64 | def buffered 65 | self.class.new(@chunks) 66 | end 67 | 68 | # Finish the body, this is a no-op. 69 | # 70 | # @returns [Buffered] self. 71 | def finish 72 | self 73 | end 74 | 75 | # Ensure that future reads return `nil`, but allow for rewinding. 76 | # 77 | # @parameter error [Exception | Nil] the error that caused the body to be closed, if any. 78 | def close(error = nil) 79 | @index = @chunks.length 80 | 81 | return nil 82 | end 83 | 84 | # Clear the buffered chunks. 85 | def clear 86 | @chunks = [] 87 | @length = 0 88 | @index = 0 89 | end 90 | 91 | # The length of the body. Will compute and cache the length of the body, if it was not provided. 92 | def length 93 | @length ||= @chunks.inject(0) {|sum, chunk| sum + chunk.bytesize} 94 | end 95 | 96 | # @returns [Boolean] if the body is empty. 97 | def empty? 98 | @index >= @chunks.length 99 | end 100 | 101 | # Whether the body is ready to be read. 102 | # @returns [Boolean] a buffered response is always ready. 103 | def ready? 104 | true 105 | end 106 | 107 | # Read the next chunk from the buffered body. 108 | # 109 | # @returns [String | Nil] the next chunk or nil if there are no more chunks. 110 | def read 111 | return nil unless @chunks 112 | 113 | if chunk = @chunks[@index] 114 | @index += 1 115 | 116 | return chunk.dup 117 | end 118 | end 119 | 120 | # Discard the body. Invokes {#close}. 121 | def discard 122 | # It's safe to call close here because there is no underlying stream to close: 123 | self.close 124 | end 125 | 126 | # Write a chunk to the buffered body. 127 | def write(chunk) 128 | @chunks << chunk 129 | end 130 | 131 | # Close the body for writing. This is a no-op. 132 | def close_write(error) 133 | # Nothing to do. 134 | end 135 | 136 | # Whether the body can be rewound. 137 | # 138 | # @returns [Boolean] if the body has chunks. 139 | def rewindable? 140 | @chunks != nil 141 | end 142 | 143 | # Rewind the body to the beginning, causing a subsequent read to return the first chunk. 144 | def rewind 145 | return false unless @chunks 146 | 147 | @index = 0 148 | 149 | return true 150 | end 151 | 152 | # Inspect the buffered body. 153 | # 154 | # @returns [String] a string representation of the buffered body. 155 | def inspect 156 | if @chunks 157 | "\#<#{self.class} #{@chunks.size} chunks, #{self.length} bytes>" 158 | end 159 | end 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/protocol/http/body/completable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, 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 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/protocol/http/body/deflate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, 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 | # Inspect the body, including the compression ratio. 79 | # 80 | # @returns [String] a string representation of the body. 81 | def inspect 82 | "#{super} | \#<#{self.class} #{(ratio*100).round(2)}%>" 83 | end 84 | end 85 | 86 | # A body which compresses the contents using the DEFLATE or GZIP algorithm. 87 | class Deflate < ZStream 88 | # Create a new body which compresses the given body using the GZIP algorithm by default. 89 | # 90 | # @parameter body [Readable] the body to wrap. 91 | # @parameter window_size [Integer] the window size to use for compression. 92 | # @parameter level [Integer] the compression level to use. 93 | # @returns [Deflate] the wrapped body. 94 | def self.for(body, window_size = GZIP, level = DEFAULT_LEVEL) 95 | self.new(body, Zlib::Deflate.new(level, window_size)) 96 | end 97 | 98 | # 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. 99 | # 100 | # @returns [String | Nil] the compressed chunk or `nil` if the stream is closed. 101 | def read 102 | return if @stream.finished? 103 | 104 | # The stream might have been closed while waiting for the chunk to come in. 105 | if chunk = super 106 | @input_length += chunk.bytesize 107 | 108 | chunk = @stream.deflate(chunk, Zlib::SYNC_FLUSH) 109 | 110 | @output_length += chunk.bytesize 111 | 112 | return chunk 113 | elsif !@stream.closed? 114 | chunk = @stream.finish 115 | 116 | @output_length += chunk.bytesize 117 | 118 | return chunk.empty? ? nil : chunk 119 | end 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/protocol/http/body/digestable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, 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 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/protocol/http/body/file.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 | # 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 | "\#<#{self.class} file=#{@file.inspect} offset=#{@offset} remaining=#{@remaining}>" 139 | end 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /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 | def self.for(body) 16 | head = self.new(body.length) 17 | 18 | body.close 19 | 20 | return head 21 | end 22 | 23 | # Initialize the head body with the given length. 24 | # 25 | # @parameter length [Integer] the length of the body. 26 | def initialize(length) 27 | @length = length 28 | end 29 | 30 | # @returns [Boolean] the body is empty. 31 | def empty? 32 | true 33 | end 34 | 35 | # @returns [Boolean] the body is ready. 36 | def ready? 37 | true 38 | end 39 | 40 | # @returns [Integer] the length of the body, if known. 41 | def length 42 | @length 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/protocol/http/body/inflate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, 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/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 | -------------------------------------------------------------------------------- /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 | # Inspect the rewindable body. 86 | # 87 | # @returns [String] a string representation of the body. 88 | def inspect 89 | "\#<#{self.class} #{@index}/#{@chunks.size} chunks read>" 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /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/writable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "readable" 7 | 8 | module Protocol 9 | module HTTP 10 | module Body 11 | # A dynamic body which you can write to and read from. 12 | class Writable < Readable 13 | # An error indicating that the body has been closed and no further writes are allowed. 14 | class Closed < StandardError 15 | end 16 | 17 | # Initialize the writable body. 18 | # 19 | # @parameter length [Integer] The length of the response body if known. 20 | # @parameter queue [Thread::Queue] Specify a different queue implementation, e.g. `Thread::SizedQueue` to enable back-pressure. 21 | def initialize(length = nil, queue: Thread::Queue.new) 22 | @length = length 23 | @queue = queue 24 | @count = 0 25 | @error = nil 26 | end 27 | 28 | # @attribute [Integer] The length of the response body if known. 29 | attr :length 30 | 31 | # Stop generating output; cause the next call to write to fail with the given error. Does not prevent existing chunks from being read. In other words, this indicates both that no more data will be or should be written to the body. 32 | # 33 | # @parameter error [Exception] The error that caused this body to be closed, if any. Will be raised on the next call to {read}. 34 | def close(error = nil) 35 | @error ||= error 36 | 37 | @queue.clear 38 | @queue.close 39 | 40 | super 41 | end 42 | 43 | # Whether the body is closed. A closed body can not be written to or read from. 44 | # 45 | # @returns [Boolean] Whether the body is closed. 46 | def closed? 47 | @queue.closed? 48 | end 49 | 50 | # @returns [Boolean] Whether the body is ready to be read from, without blocking. 51 | def ready? 52 | !@queue.empty? || @queue.closed? 53 | end 54 | 55 | # Indicates whether the body is empty. This can occur if the body has been closed, or if the producer has invoked {close_write} and the reader has consumed all available chunks. 56 | # 57 | # @returns [Boolean] Whether the body is empty. 58 | def empty? 59 | @queue.empty? && @queue.closed? 60 | end 61 | 62 | # Read the next available chunk. 63 | # 64 | # @returns [String | Nil] The next chunk, or `nil` if the body is finished. 65 | # @raises [Exception] If the body was closed due to an error. 66 | def read 67 | if @error 68 | raise @error 69 | end 70 | 71 | # This operation may result in @error being set. 72 | chunk = @queue.pop 73 | 74 | if @error 75 | raise @error 76 | end 77 | 78 | return chunk 79 | end 80 | 81 | # Write a single chunk to the body. Signal completion by calling {close_write}. 82 | # 83 | # @parameter chunk [String] The chunk to write. 84 | # @raises [Closed] If the body has been closed without error. 85 | # @raises [Exception] If the body has been closed due to an error. 86 | def write(chunk) 87 | if @queue.closed? 88 | raise(@error || Closed) 89 | end 90 | 91 | @queue.push(chunk) 92 | @count += 1 93 | end 94 | 95 | # Signal that no more data will be written to the body. 96 | # 97 | # @parameter error [Exception] The error that caused this body to be closed, if any. 98 | def close_write(error = nil) 99 | @error ||= error 100 | @queue.close 101 | end 102 | 103 | # The output interface for writing chunks to the body. 104 | class Output 105 | # Initialize the output with the given writable body. 106 | # 107 | # @parameter writable [Writable] The writable body. 108 | def initialize(writable) 109 | @writable = writable 110 | @closed = false 111 | end 112 | 113 | # @returns [Boolean] Whether the output is closed for writing only. 114 | def closed? 115 | @closed || @writable.closed? 116 | end 117 | 118 | # Write a chunk to the body. 119 | def write(chunk) 120 | @writable.write(chunk) 121 | end 122 | 123 | alias << write 124 | 125 | # Close the output stream. 126 | # 127 | # If an error is given, the error will be used to close the body by invoking {close} with the error. Otherwise, only the write side of the body will be closed. 128 | # 129 | # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. 130 | def close(error = nil) 131 | @closed = true 132 | 133 | if error 134 | @writable.close(error) 135 | else 136 | @writable.close_write 137 | end 138 | end 139 | end 140 | 141 | # Create an output wrapper which can be used to write chunks to the body. 142 | # 143 | # If a block is given, and the block raises an error, the error will used to close the body by invoking {close} with the error. 144 | # 145 | # @yields {|output| ...} if a block is given. 146 | # @parameter output [Output] The output wrapper. 147 | # @returns [Output] The output wrapper. 148 | def output 149 | output = Output.new(self) 150 | 151 | unless block_given? 152 | return output 153 | end 154 | 155 | begin 156 | yield output 157 | rescue => error 158 | raise error 159 | ensure 160 | output.close(error) 161 | end 162 | end 163 | 164 | # Inspect the body. 165 | # 166 | # @returns [String] A string representation of the body. 167 | def inspect 168 | if @error 169 | "\#<#{self.class} #{@count} chunks written, #{status}, error=#{@error}>" 170 | else 171 | "\#<#{self.class} #{@count} chunks written, #{status}>" 172 | end 173 | end 174 | 175 | private 176 | 177 | # @returns [String] A string representation of the body's status. 178 | def status 179 | if @queue.empty? 180 | if @queue.closed? 181 | "closed" 182 | else 183 | "waiting" 184 | end 185 | else 186 | if @queue.closed? 187 | "closing" 188 | else 189 | "ready" 190 | end 191 | end 192 | end 193 | end 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /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/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 "url" 8 | 9 | module Protocol 10 | module HTTP 11 | # Represents an individual cookie key-value pair. 12 | class Cookie 13 | # Initialize the cookie with the given name, value, and directives. 14 | # 15 | # @parameter name [String] The name of the cookiel, e.g. "session_id". 16 | # @parameter value [String] The value of the cookie, e.g. "1234". 17 | # @parameter directives [Hash] The directives of the cookie, e.g. `{"path" => "/"}`. 18 | def initialize(name, value, directives) 19 | @name = name 20 | @value = value 21 | @directives = directives 22 | end 23 | 24 | # @attribute [String] The name of the cookie. 25 | attr :name 26 | 27 | # @attribute [String] The value of the cookie. 28 | attr :value 29 | 30 | # @attribute [Hash] The directives of the cookie. 31 | attr :directives 32 | 33 | # Encode the name of the cookie. 34 | def encoded_name 35 | URL.escape(@name) 36 | end 37 | 38 | # Encode the value of the cookie. 39 | def encoded_value 40 | URL.escape(@value) 41 | end 42 | 43 | # Convert the cookie to a string. 44 | # 45 | # @returns [String] The string representation of the cookie. 46 | def to_s 47 | buffer = String.new.b 48 | 49 | buffer << encoded_name << "=" << encoded_value 50 | 51 | if @directives 52 | @directives.collect do |key, value| 53 | buffer << ";" 54 | 55 | case value 56 | when String 57 | buffer << key << "=" << value 58 | when TrueClass 59 | buffer << key 60 | end 61 | end 62 | end 63 | 64 | return buffer 65 | end 66 | 67 | # Parse a string into a cookie. 68 | # 69 | # @parameter string [String] The string to parse. 70 | # @returns [Cookie] The parsed cookie. 71 | def self.parse(string) 72 | head, *directives = string.split(/\s*;\s*/) 73 | 74 | key, value = head.split("=", 2) 75 | directives = self.parse_directives(directives) 76 | 77 | self.new( 78 | URL.unescape(key), 79 | URL.unescape(value), 80 | directives, 81 | ) 82 | end 83 | 84 | # Parse a list of strings into a hash of directives. 85 | # 86 | # @parameter strings [Array(String)] The list of strings to parse. 87 | # @returns [Hash] The hash of directives. 88 | def self.parse_directives(strings) 89 | strings.collect do |string| 90 | key, value = string.split("=", 2) 91 | [key, value || true] 92 | end.to_h 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/protocol/http/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2023, by Samuel Williams. 5 | 6 | module Protocol 7 | module HTTP 8 | # A generic, HTTP protocol error. 9 | class Error < StandardError 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/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_relative "split" 7 | require_relative "quoted_string" 8 | require_relative "../error" 9 | 10 | module Protocol 11 | module HTTP 12 | module Header 13 | # The `accept-content-type` header represents a list of content-types that the client can accept. 14 | class Accept < Array 15 | # Regular expression used to split values on commas, with optional surrounding whitespace, taking into account quoted strings. 16 | SEPARATOR = / 17 | (?: # Start non-capturing group 18 | "[^"\\]*" # Match quoted strings (no escaping of quotes within) 19 | | # OR 20 | [^,"]+ # Match non-quoted strings until a comma or quote 21 | )+ 22 | (?=,|\z) # Match until a comma or end of string 23 | /x 24 | 25 | ParseError = Class.new(Error) 26 | 27 | MEDIA_RANGE = /\A(?#{TOKEN})\/(?#{TOKEN})(?.*)\z/ 28 | 29 | PARAMETER = /\s*;\s*(?#{TOKEN})=((?#{TOKEN})|(?#{QUOTED_STRING}))/ 30 | 31 | # A single entry in the Accept: header, which includes a mime type and associated parameters. A media range can include wild cards, but a media type is a specific type and subtype. 32 | MediaRange = Struct.new(:type, :subtype, :parameters) do 33 | # Create a new media range. 34 | # 35 | # @parameter type [String] the type of the media range. 36 | # @parameter subtype [String] the subtype of the media range. 37 | # @parameter parameters [Hash] the parameters associated with the media range. 38 | def initialize(type, subtype = "*", parameters = {}) 39 | super(type, subtype, parameters) 40 | end 41 | 42 | # Compare the media range with another media range or a string, based on the quality factor. 43 | def <=> other 44 | other.quality_factor <=> self.quality_factor 45 | end 46 | 47 | private def parameters_string 48 | return "" if parameters == nil or parameters.empty? 49 | 50 | parameters.collect do |key, value| 51 | ";#{key.to_s}=#{QuotedString.quote(value.to_s)}" 52 | end.join 53 | end 54 | 55 | # The string representation of the media range, including the type, subtype, and any parameters. 56 | def to_s 57 | "#{type}/#{subtype}#{parameters_string}" 58 | end 59 | 60 | alias to_str to_s 61 | 62 | # The quality factor associated with the media range, which is used to determine the order of preference. 63 | # 64 | # @returns [Float] the quality factor, which defaults to 1.0 if not specified. 65 | def quality_factor 66 | parameters.fetch("q", 1.0).to_f 67 | end 68 | end 69 | 70 | # Parse the `accept` header value into a list of content types. 71 | # 72 | # @parameter value [String] the value of the header. 73 | def initialize(value = nil) 74 | if value 75 | super(value.scan(SEPARATOR).map(&:strip)) 76 | end 77 | end 78 | 79 | # Adds one or more comma-separated values to the header. 80 | # 81 | # The input string is split into distinct entries and appended to the array. 82 | # 83 | # @parameter value [String] the value or values to add, separated by commas. 84 | def << (value) 85 | self.concat(value.scan(SEPARATOR).map(&:strip)) 86 | end 87 | 88 | # Serializes the stored values into a comma-separated string. 89 | # 90 | # @returns [String] the serialized representation of the header values. 91 | def to_s 92 | join(",") 93 | end 94 | 95 | # Parse the `accept` header. 96 | # 97 | # @returns [Array(Charset)] the list of content types and their associated parameters. 98 | def media_ranges 99 | self.map do |value| 100 | self.parse_media_range(value) 101 | end 102 | end 103 | 104 | private 105 | 106 | def parse_media_range(value) 107 | if match = value.match(MEDIA_RANGE) 108 | type = match[:type] 109 | subtype = match[:subtype] 110 | parameters = {} 111 | 112 | match[:parameters].scan(PARAMETER) do |key, value, quoted_value| 113 | if quoted_value 114 | value = QuotedString.unquote(quoted_value) 115 | end 116 | 117 | parameters[key] = value 118 | end 119 | 120 | return MediaRange.new(type, subtype, parameters) 121 | else 122 | raise ParseError, "Invalid media type: #{value.inspect}" 123 | end 124 | end 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/protocol/http/header/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, 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 | # Splits the header into the credentials. 19 | # 20 | # @returns [Tuple(String, String)] The username and password. 21 | def credentials 22 | self.split(/\s+/, 2) 23 | end 24 | 25 | # Generate a new basic authorization header, encoding the given username and password. 26 | # 27 | # @parameter username [String] The username. 28 | # @parameter password [String] The password. 29 | # @returns [Authorization] The basic authorization header. 30 | def self.basic(username, password) 31 | strict_base64_encoded = ["#{username}:#{password}"].pack("m0") 32 | 33 | self.new( 34 | "Basic #{strict_base64_encoded}" 35 | ) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/protocol/http/header/cache_control.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 | # Represents the `cache-control` header, which is a list of cache directives. 13 | class CacheControl < Split 14 | # The `private` directive indicates that the response is intended for a single user and must not be stored by shared caches. 15 | PRIVATE = "private" 16 | 17 | # The `public` directive indicates that the response may be stored by any cache, even if it would normally be considered non-cacheable. 18 | PUBLIC = "public" 19 | 20 | # The `no-cache` directive indicates that caches must revalidate the response with the origin server before serving it to clients. 21 | NO_CACHE = "no-cache" 22 | 23 | # The `no-store` directive indicates that caches must not store the response under any circumstances. 24 | NO_STORE = "no-store" 25 | 26 | # The `max-age` directive indicates the maximum amount of time, in seconds, that a response is considered fresh. 27 | MAX_AGE = "max-age" 28 | 29 | # The `s-maxage` directive is similar to `max-age` but applies only to shared caches. If both `s-maxage` and `max-age` are present, `s-maxage` takes precedence in shared caches. 30 | S_MAXAGE = "s-maxage" 31 | 32 | # The `static` directive is a custom directive often used to indicate that the resource is immutable or rarely changes, allowing longer caching periods. 33 | STATIC = "static" 34 | 35 | # The `dynamic` directive is a custom directive used to indicate that the resource is generated dynamically and may change frequently, requiring shorter caching periods. 36 | DYNAMIC = "dynamic" 37 | 38 | # The `streaming` directive is a custom directive used to indicate that the resource is intended for progressive or chunked delivery, such as live video streams. 39 | STREAMING = "streaming" 40 | 41 | # The `must-revalidate` directive indicates that once a response becomes stale, caches must not use it to satisfy subsequent requests without revalidating it with the origin server. 42 | MUST_REVALIDATE = "must-revalidate" 43 | 44 | # The `proxy-revalidate` directive is similar to `must-revalidate` but applies only to shared caches. 45 | PROXY_REVALIDATE = "proxy-revalidate" 46 | 47 | # Initializes the cache control header with the given value. The value is expected to be a comma-separated string of cache directives. 48 | # 49 | # @parameter value [String | Nil] the raw Cache-Control header value. 50 | def initialize(value = nil) 51 | super(value&.downcase) 52 | end 53 | 54 | # Adds a directive to the Cache-Control header. The value will be normalized to lowercase before being added. 55 | # 56 | # @parameter value [String] the directive to add. 57 | def << value 58 | super(value.downcase) 59 | end 60 | 61 | # @returns [Boolean] whether the `static` directive is present. 62 | def static? 63 | self.include?(STATIC) 64 | end 65 | 66 | # @returns [Boolean] whether the `dynamic` directive is present. 67 | def dynamic? 68 | self.include?(DYNAMIC) 69 | end 70 | 71 | # @returns [Boolean] whether the `streaming` directive is present. 72 | def streaming? 73 | self.include?(STREAMING) 74 | end 75 | 76 | # @returns [Boolean] whether the `private` directive is present. 77 | def private? 78 | self.include?(PRIVATE) 79 | end 80 | 81 | # @returns [Boolean] whether the `public` directive is present. 82 | def public? 83 | self.include?(PUBLIC) 84 | end 85 | 86 | # @returns [Boolean] whether the `no-cache` directive is present. 87 | def no_cache? 88 | self.include?(NO_CACHE) 89 | end 90 | 91 | # @returns [Boolean] whether the `no-store` directive is present. 92 | def no_store? 93 | self.include?(NO_STORE) 94 | end 95 | 96 | # @returns [Boolean] whether the `must-revalidate` directive is present. 97 | def must_revalidate? 98 | self.include?(MUST_REVALIDATE) 99 | end 100 | 101 | # @returns [Boolean] whether the `proxy-revalidate` directive is present. 102 | def proxy_revalidate? 103 | self.include?(PROXY_REVALIDATE) 104 | end 105 | 106 | # @returns [Integer | Nil] the value of the `max-age` directive in seconds, or `nil` if the directive is not present or invalid. 107 | def max_age 108 | find_integer_value(MAX_AGE) 109 | end 110 | 111 | # @returns [Integer | Nil] the value of the `s-maxage` directive in seconds, or `nil` if the directive is not present or invalid. 112 | def s_maxage 113 | find_integer_value(S_MAXAGE) 114 | end 115 | 116 | private 117 | 118 | # Finds and parses an integer value from a directive. 119 | # 120 | # @parameter value_name [String] the directive name to search for (e.g., "max-age"). 121 | # @returns [Integer | Nil] the parsed integer value, or `nil` if not found or invalid. 122 | def find_integer_value(value_name) 123 | if value = self.find { |value| value.start_with?(value_name) } 124 | _, age = value.split("=", 2) 125 | 126 | if age =~ /\A[0-9]+\z/ 127 | return Integer(age) 128 | end 129 | end 130 | end 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /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 | # Initializes the connection header with the given value. The value is expected to be a comma-separated string of directives. 26 | # 27 | # @parameter value [String | Nil] the raw `connection` header value. 28 | def initialize(value = nil) 29 | super(value&.downcase) 30 | end 31 | 32 | # Adds a directive to the `connection` header. The value will be normalized to lowercase before being added. 33 | # 34 | # @parameter value [String] the directive to add. 35 | def << value 36 | super(value.downcase) 37 | end 38 | 39 | # @returns [Boolean] whether the `keep-alive` directive is present and the connection is not marked for closure with the `close` directive. 40 | def keep_alive? 41 | self.include?(KEEP_ALIVE) && !close? 42 | end 43 | 44 | # @returns [Boolean] whether the `close` directive is present, indicating that the connection should be closed after the current request and response. 45 | def close? 46 | self.include?(CLOSE) 47 | end 48 | 49 | # @returns [Boolean] whether the `upgrade` directive is present, indicating that the connection should be upgraded to a different protocol. 50 | def upgrade? 51 | self.include?(UPGRADE) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /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 | end 27 | 28 | # The `set-cookie` header sends cookies from the server to the user agent. 29 | # 30 | # 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. 31 | class SetCookie < Cookie 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/protocol/http/header/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, 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 | # Replaces the current value of the `date` header with the specified value. 16 | # 17 | # @parameter value [String] the new value for the `date` header. 18 | def << value 19 | replace(value) 20 | end 21 | 22 | # Converts the `date` header value to a `Time` object. 23 | # 24 | # @returns [Time] the parsed time object corresponding to the `date` header value. 25 | def to_time 26 | ::Time.parse(self) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /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 | # Replaces the current value of the `etag` header with the specified value. 14 | # 15 | # @parameter value [String] the new value for the `etag` header. 16 | def << value 17 | replace(value) 18 | end 19 | 20 | # Checks whether the `etag` is a weak validator. 21 | # 22 | # Weak validators indicate semantically equivalent content but may not be byte-for-byte identical. 23 | # 24 | # @returns [Boolean] whether the `etag` is weak. 25 | def weak? 26 | self.start_with?("W/") 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | # Initializes the multiple header with the given value. As the header key-value pair can only contain one value, the value given here is added to the internal array, and subsequent values can be added using the `<<` operator. 14 | # 15 | # @parameter value [String] the raw header value. 16 | def initialize(value) 17 | super() 18 | 19 | self << value 20 | end 21 | 22 | # Serializes the stored values into a newline-separated string. 23 | # 24 | # @returns [String] the serialized representation of the header values. 25 | def to_s 26 | join("\n") 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /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 | # Initialize the priority header with the given value. 16 | # 17 | # @parameter value [String | Nil] the value of the priority header, if any. The value should be a comma-separated string of directives. 18 | def initialize(value = nil) 19 | super(value&.downcase) 20 | end 21 | 22 | # Add a value to the priority header. 23 | # 24 | # @parameter value [String] the directive to add to the header. 25 | def << value 26 | super(value.downcase) 27 | end 28 | 29 | # The default urgency level if not specified. 30 | DEFAULT_URGENCY = 3 31 | 32 | # The urgency level, if specified using `u=`. 0 is the highest priority, and 7 is the lowest. 33 | # 34 | # Note that when duplicate Dictionary keys are encountered, all but the last instance are ignored. 35 | # 36 | # @returns [Integer | Nil] the urgency level if specified, or `nil` if not present. 37 | def urgency(default = DEFAULT_URGENCY) 38 | if value = self.reverse_find{|value| value.start_with?("u=")} 39 | _, level = value.split("=", 2) 40 | return Integer(level) 41 | end 42 | 43 | return default 44 | end 45 | 46 | # Checks if the response should be delivered incrementally. 47 | # 48 | # The `i` directive, when present, indicates that the response can be delivered incrementally as data becomes available. 49 | # 50 | # @returns [Boolean] whether the request should be delivered incrementally. 51 | def incremental? 52 | self.include?("i") 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/protocol/http/header/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 | module Header 9 | # According to https://tools.ietf.org/html/rfc7231#appendix-C 10 | TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i 11 | 12 | QUOTED_STRING = /"(?:.(?!(?. It should already match the QUOTED_STRING pattern above by the parser. 20 | def self.unquote(value, normalize_whitespace = true) 21 | value = value[1...-1] 22 | 23 | value.gsub!(/\\(.)/, '\1') 24 | 25 | if normalize_whitespace 26 | # LWS = [CRLF] 1*( SP | HT ) 27 | value.gsub!(/[\r\n]+\s+/, " ") 28 | end 29 | 30 | return value 31 | end 32 | 33 | QUOTES_REQUIRED = /[()<>@,;:\\"\/\[\]?={} \t]/ 34 | 35 | # Quote a string for HTTP header values if required. 36 | # 37 | # @raises [ArgumentError] if the value contains invalid characters like control characters or newlines. 38 | def self.quote(value, force = false) 39 | # Check if quoting is required: 40 | if value =~ QUOTES_REQUIRED or force 41 | "\"#{value.gsub(/["\\]/, '\\\\\0')}\"" 42 | else 43 | value 44 | end 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /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 | # Initializes a `Split` header with the given value. If the value is provided, it is split into distinct entries and stored as an array. 17 | # 18 | # @parameter value [String | Nil] the raw header value containing multiple entries separated by commas, or `nil` for an empty header. 19 | def initialize(value = nil) 20 | if value 21 | super(value.split(COMMA)) 22 | else 23 | super() 24 | end 25 | end 26 | 27 | # Adds one or more comma-separated values to the header. 28 | # 29 | # The input string is split into distinct entries and appended to the array. 30 | # 31 | # @parameter value [String] the value or values to add, separated by commas. 32 | def << value 33 | self.concat(value.split(COMMA)) 34 | end 35 | 36 | # Serializes the stored values into a comma-separated string. 37 | # 38 | # @returns [String] the serialized representation of the header values. 39 | def to_s 40 | join(",") 41 | end 42 | 43 | protected 44 | 45 | def reverse_find(&block) 46 | reverse_each do |value| 47 | return value if block.call(value) 48 | end 49 | 50 | return nil 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /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 | # Initializes a `Vary` header with the given value. The value is split into distinct entries and converted to lowercase for normalization. 16 | # 17 | # @parameter value [String] the raw header value containing request header names separated by commas. 18 | def initialize(value) 19 | super(value.downcase) 20 | end 21 | 22 | # Adds one or more comma-separated values to the `vary` header. The values are converted to lowercase for normalization. 23 | # 24 | # @parameter value [String] the value or values to add, separated by commas. 25 | def << value 26 | super(value.downcase) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/protocol/http/url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2022, by Herrick Fang. 6 | 7 | module Protocol 8 | module HTTP 9 | # Helpers for working with URLs. 10 | module URL 11 | # Escapes a string using percent encoding, e.g. `a b` -> `a%20b`. 12 | # 13 | # @parameter string [String] The string to escape. 14 | # @returns [String] The escaped string. 15 | def self.escape(string, encoding = string.encoding) 16 | string.b.gsub(/([^a-zA-Z0-9_.\-]+)/) do |m| 17 | "%" + m.unpack("H2" * m.bytesize).join("%").upcase 18 | end.force_encoding(encoding) 19 | end 20 | 21 | # Unescapes a percent encoded string, e.g. `a%20b` -> `a b`. 22 | # 23 | # @parameter string [String] The string to unescape. 24 | # @returns [String] The unescaped string. 25 | def self.unescape(string, encoding = string.encoding) 26 | string.b.gsub(/%(\h\h)/) do |hex| 27 | Integer($1, 16).chr 28 | end.force_encoding(encoding) 29 | end 30 | 31 | # Matches characters that are not allowed in a URI path segment. According to RFC 3986 Section 3.3 (https://tools.ietf.org/html/rfc3986#section-3.3), a valid path segment consists of "pchar" characters. This pattern identifies characters that must be percent-encoded when included in a URI path segment. 32 | NON_PATH_CHARACTER_PATTERN = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/]+)/.freeze 33 | 34 | # Escapes non-path characters using percent encoding. In other words, this method escapes characters that are not allowed in a URI path segment. According to RFC 3986 Section 3.3 (https://tools.ietf.org/html/rfc3986#section-3.3), a valid path segment consists of "pchar" characters. This method percent-encodes characters that are not "pchar" characters. 35 | # 36 | # @parameter path [String] The path to escape. 37 | # @returns [String] The escaped path. 38 | def self.escape_path(path) 39 | encoding = path.encoding 40 | path.b.gsub(NON_PATH_CHARACTER_PATTERN) do |m| 41 | "%" + m.unpack("H2" * m.bytesize).join("%").upcase 42 | end.force_encoding(encoding) 43 | end 44 | 45 | # Encodes a hash or array into a query string. This method is used to encode query parameters in a URL. For example, `{"a" => 1, "b" => 2}` is encoded as `a=1&b=2`. 46 | # 47 | # @parameter value [Hash | Array | Nil] The value to encode. 48 | # @parameter prefix [String] The prefix to use for keys. 49 | def self.encode(value, prefix = nil) 50 | case value 51 | when Array 52 | return value.map {|v| 53 | self.encode(v, "#{prefix}[]") 54 | }.join("&") 55 | when Hash 56 | return value.map {|k, v| 57 | self.encode(v, prefix ? "#{prefix}[#{escape(k.to_s)}]" : escape(k.to_s)) 58 | }.reject(&:empty?).join("&") 59 | when nil 60 | return prefix 61 | else 62 | raise ArgumentError, "value must be a Hash" if prefix.nil? 63 | 64 | return "#{prefix}=#{escape(value.to_s)}" 65 | end 66 | end 67 | 68 | # Scan a string for URL-encoded key/value pairs. 69 | # @yields {|key, value| ...} 70 | # @parameter key [String] The unescaped key. 71 | # @parameter value [String] The unescaped key. 72 | def self.scan(string) 73 | string.split("&") do |assignment| 74 | next if assignment.empty? 75 | 76 | key, value = assignment.split("=", 2) 77 | 78 | yield unescape(key), value.nil? ? value : unescape(value) 79 | end 80 | end 81 | 82 | # Split a key into parts, e.g. `a[b][c]` -> `["a", "b", "c"]`. 83 | # 84 | # @parameter name [String] The key to split. 85 | # @returns [Array(String)] The parts of the key. 86 | def self.split(name) 87 | name.scan(/([^\[]+)|(?:\[(.*?)\])/)&.tap do |parts| 88 | parts.flatten! 89 | parts.compact! 90 | end 91 | end 92 | 93 | # Assign a value to a nested hash. 94 | # 95 | # @parameter keys [Array(String)] The parts of the key. 96 | # @parameter value [Object] The value to assign. 97 | # @parameter parent [Hash] The parent hash. 98 | def self.assign(keys, value, parent) 99 | top, *middle = keys 100 | 101 | middle.each_with_index do |key, index| 102 | if key.nil? or key.empty? 103 | parent = (parent[top] ||= Array.new) 104 | top = parent.size 105 | 106 | if nested = middle[index+1] and last = parent.last 107 | top -= 1 unless last.include?(nested) 108 | end 109 | else 110 | parent = (parent[top] ||= Hash.new) 111 | top = key 112 | end 113 | end 114 | 115 | parent[top] = value 116 | end 117 | 118 | # Decode a URL-encoded query string into a hash. 119 | # 120 | # @parameter string [String] The query string to decode. 121 | # @parameter maximum [Integer] The maximum number of keys in a path. 122 | # @parameter symbolize_keys [Boolean] Whether to symbolize keys. 123 | # @returns [Hash] The decoded query string. 124 | def self.decode(string, maximum = 8, symbolize_keys: false) 125 | parameters = {} 126 | 127 | self.scan(string) do |name, value| 128 | keys = self.split(name) 129 | 130 | if keys.empty? 131 | raise ArgumentError, "Invalid key path: #{name.inspect}!" 132 | end 133 | 134 | if keys.size > maximum 135 | raise ArgumentError, "Key length exceeded limit!" 136 | end 137 | 138 | if symbolize_keys 139 | keys.collect!{|key| key.empty? ? nil : key.to_sym} 140 | end 141 | 142 | self.assign(keys, value, parameters) 143 | end 144 | 145 | return parameters 146 | end 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /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.50.1" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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", "Bryan Powell", "Dan Olson", "Earlopain", "Genki Takiuchi", "Marcelo Junior", "Olle Jonsson", "William T. Nelson", "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(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.required_ruby_version = ">= 3.2" 26 | end 27 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Protocol::HTTP 2 | 3 | Provides abstractions for working with the HTTP protocol. 4 | 5 | [![Development Status](https://github.com/socketry/protocol-http/workflows/Test/badge.svg)](https://github.com/socketry/protocol-http/actions?workflow=Test) 6 | 7 | ## Features 8 | 9 | - General abstractions for HTTP requests and responses. 10 | - Symmetrical interfaces for client and server. 11 | - Light-weight middleware model for building applications. 12 | 13 | ## Usage 14 | 15 | Please see the [project documentation](https://socketry.github.io/protocol-http/) for more details. 16 | 17 | - [Streaming](https://socketry.github.io/protocol-http/guides/streaming/index) - This guide gives an overview of how to implement streaming requests and responses. 18 | 19 | - [Getting Started](https://socketry.github.io/protocol-http/guides/getting-started/index) - This guide explains how to use `protocol-http` for building abstract HTTP interfaces. 20 | 21 | - [Design Overview](https://socketry.github.io/protocol-http/guides/design-overview/index) - This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers. 22 | 23 | ## Releases 24 | 25 | Please see the [project releases](https://socketry.github.io/protocol-http/releases/index) for all releases. 26 | 27 | ### v0.48.0 28 | 29 | - Add support for parsing `accept`, `accept-charset`, `accept-encoding` and `accept-language` headers into structured values. 30 | 31 | ### v0.46.0 32 | 33 | - Add support for `priority:` header. 34 | 35 | ### v0.33.0 36 | 37 | - Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`. 38 | - Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`. 39 | 40 | ### v0.31.0 41 | 42 | - Ensure chunks are flushed if required, when streaming. 43 | 44 | ### v0.30.0 45 | 46 | - [`Request[]` and `Response[]` Keyword Arguments](https://socketry.github.io/protocol-http/releases/index#request[]-and-response[]-keyword-arguments) 47 | - [Interim Response Handling](https://socketry.github.io/protocol-http/releases/index#interim-response-handling) 48 | 49 | ## See Also 50 | 51 | - [protocol-http1](https://github.com/socketry/protocol-http1) — HTTP/1 client/server implementation using this 52 | interface. 53 | - [protocol-http2](https://github.com/socketry/protocol-http2) — HTTP/2 client/server implementation using this 54 | interface. 55 | - [async-http](https://github.com/socketry/async-http) — Asynchronous HTTP client and server, supporting multiple HTTP 56 | protocols & TLS. 57 | - [async-websocket](https://github.com/socketry/async-websocket) — Asynchronous client and server WebSockets. 58 | 59 | ## Contributing 60 | 61 | We welcome contributions to this project. 62 | 63 | 1. Fork it. 64 | 2. Create your feature branch (`git checkout -b my-new-feature`). 65 | 3. Commit your changes (`git commit -am 'Add some feature'`). 66 | 4. Push to the branch (`git push origin my-new-feature`). 67 | 5. Create new Pull Request. 68 | 69 | ### Developer Certificate of Origin 70 | 71 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 72 | 73 | ### Community Guidelines 74 | 75 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /releases.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## v0.50.0 4 | 5 | - Drop support for Ruby v3.1. 6 | 7 | ## v0.48.0 8 | 9 | - Add support for parsing `accept`, `accept-charset`, `accept-encoding` and `accept-language` headers into structured values. 10 | 11 | ## v0.46.0 12 | 13 | - Add support for `priority:` header. 14 | 15 | ## v0.33.0 16 | 17 | - Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`. 18 | - Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`. 19 | 20 | ## v0.31.0 21 | 22 | - Ensure chunks are flushed if required, when streaming. 23 | 24 | ## v0.30.0 25 | 26 | ### `Request[]` and `Response[]` Keyword Arguments 27 | 28 | The `Request[]` and `Response[]` methods now support keyword arguments as a convenient way to set various positional arguments. 29 | 30 | ``` ruby 31 | # Request keyword arguments: 32 | client.get("/", headers: {"accept" => "text/html"}, authority: "example.com") 33 | 34 | # Response keyword arguments: 35 | def call(request) 36 | return Response[200, headers: {"content-Type" => "text/html"}, body: "Hello, World!"] 37 | ``` 38 | 39 | ### Interim Response Handling 40 | 41 | The `Request` class now exposes a `#interim_response` attribute which can be used to handle interim responses both on the client side and server side. 42 | 43 | On the client side, you can pass a callback using the `interim_response` keyword argument which will be invoked whenever an interim response is received: 44 | 45 | ``` ruby 46 | client = ... 47 | response = client.get("/index", interim_response: proc{|status, headers| ...}) 48 | ``` 49 | 50 | On the server side, you can send an interim response using the `#send_interim_response` method: 51 | 52 | ``` ruby 53 | def call(request) 54 | if request.headers["expect"] == "100-continue" 55 | # Send an interim response: 56 | request.send_interim_response(100) 57 | end 58 | 59 | # ... 60 | end 61 | ``` 62 | -------------------------------------------------------------------------------- /test/protocol/http/body/buffered.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2020-2023, by Bruno Sutic. 6 | 7 | require "protocol/http/body/buffered" 8 | require "protocol/http/body/a_readable_body" 9 | 10 | describe Protocol::HTTP::Body::Buffered do 11 | let(:source) {["Hello", "World"]} 12 | let(:body) {subject.wrap(source)} 13 | 14 | it_behaves_like Protocol::HTTP::Body::AReadableBody 15 | 16 | with ".wrap" do 17 | with "an instance of Protocol::HTTP::Body::Readable as a source" do 18 | let(:source) {Protocol::HTTP::Body::Readable.new} 19 | 20 | it "returns the body" do 21 | expect(body).to be == source 22 | end 23 | end 24 | 25 | with "an instance of an Array as a source" do 26 | let(:source) {["Hello", "World"]} 27 | 28 | it "returns instance initialized with the array" do 29 | expect(body).to be_a(subject) 30 | end 31 | end 32 | 33 | with "source that responds to #each" do 34 | let(:source) {["Hello", "World"].each} 35 | 36 | it "buffers the content into an array before initializing" do 37 | expect(body).to be_a(subject) 38 | expect(body.read).to be == "Hello" 39 | expect(body.read).to be == "World" 40 | end 41 | end 42 | 43 | with "an instance of a String as a source" do 44 | let(:source) {"Hello World"} 45 | 46 | it "returns instance initialized with the String" do 47 | expect(body).to be_a(subject) 48 | expect(body.read).to be == "Hello World" 49 | end 50 | end 51 | end 52 | 53 | with "#length" do 54 | it "returns sum of chunks' bytesize" do 55 | expect(body.length).to be == 10 56 | end 57 | end 58 | 59 | with "#empty?" do 60 | it "returns false when there are chunks left" do 61 | expect(body.empty?).to be == false 62 | body.read 63 | expect(body.empty?).to be == false 64 | end 65 | 66 | it "returns true when there are no chunks left" do 67 | body.read 68 | body.read 69 | expect(body.empty?).to be == true 70 | end 71 | 72 | it "returns false when rewinded" do 73 | body.read 74 | body.read 75 | body.rewind 76 | expect(body.empty?).to be == false 77 | end 78 | end 79 | 80 | with "#ready?" do 81 | it "is ready when chunks are available" do 82 | expect(body).to be(:ready?) 83 | end 84 | end 85 | 86 | with "#finish" do 87 | it "returns self" do 88 | expect(body.finish).to be == body 89 | end 90 | end 91 | 92 | with "#call" do 93 | let(:output) {Protocol::HTTP::Body::Buffered.new} 94 | let(:stream) {Protocol::HTTP::Body::Stream.new(nil, output)} 95 | 96 | it "can stream data" do 97 | body.call(stream) 98 | 99 | expect(output).not.to be(:empty?) 100 | expect(output.chunks).to be == source 101 | end 102 | end 103 | 104 | with "#read" do 105 | it "retrieves chunks of content" do 106 | expect(body.read).to be == "Hello" 107 | expect(body.read).to be == "World" 108 | expect(body.read).to be == nil 109 | end 110 | 111 | # with "large content" do 112 | # let(:content) {Array.new(5) {|i| "#{i}" * (1*1024*1024)}} 113 | 114 | # it "allocates expected amount of memory" do 115 | # expect do 116 | # subject.read until subject.empty? 117 | # end.to limit_allocations(size: 0) 118 | # end 119 | # end 120 | end 121 | 122 | with "#rewind" do 123 | it "is rewindable" do 124 | expect(body).to be(:rewindable?) 125 | end 126 | 127 | it "positions the cursor to the beginning" do 128 | expect(body.read).to be == "Hello" 129 | body.rewind 130 | expect(body.read).to be == "Hello" 131 | end 132 | end 133 | 134 | with "#buffered" do 135 | let(:buffered_body) {body.buffered} 136 | 137 | it "returns a buffered body" do 138 | expect(buffered_body).to be_a(subject) 139 | expect(buffered_body.read).to be == "Hello" 140 | expect(buffered_body.read).to be == "World" 141 | end 142 | 143 | it "doesn't affect the original body" do 144 | expect(buffered_body.join).to be == "HelloWorld" 145 | 146 | expect(buffered_body).to be(:empty?) 147 | expect(body).not.to be(:empty?) 148 | end 149 | end 150 | 151 | with "#each" do 152 | with "a block" do 153 | it "iterates over chunks" do 154 | result = [] 155 | body.each{|chunk| result << chunk} 156 | expect(result).to be == source 157 | end 158 | end 159 | 160 | with "no block" do 161 | it "returns an enumerator" do 162 | expect(body.each).to be_a(Enumerator) 163 | end 164 | 165 | it "can be chained with enumerator methods" do 166 | result = [] 167 | 168 | body.each.with_index do |chunk, index| 169 | if index.zero? 170 | result << chunk.upcase 171 | else 172 | result << chunk.downcase 173 | end 174 | end 175 | 176 | expect(result).to be == ["HELLO", "world"] 177 | end 178 | end 179 | end 180 | 181 | with "#clear" do 182 | it "clears all chunks and resets length" do 183 | body.clear 184 | expect(body.chunks).to be(:empty?) 185 | expect(body.read).to be == nil 186 | expect(body.length).to be == 0 187 | end 188 | end 189 | 190 | with "#inspect" do 191 | it "can be inspected" do 192 | expect(body.inspect).to be =~ /\d+ chunks, \d+ bytes/ 193 | end 194 | end 195 | 196 | with "#discard" do 197 | it "closes the body" do 198 | expect(body).to receive(:close) 199 | 200 | expect(body.discard).to be == nil 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /test/protocol/http/body/completable.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/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 | end 110 | -------------------------------------------------------------------------------- /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 | end 65 | -------------------------------------------------------------------------------- /test/protocol/http/body/digestable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, 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 | end 54 | -------------------------------------------------------------------------------- /test/protocol/http/body/file.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/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 file=(.*?) offset=\d+ remaining=\d+/ 69 | end 70 | end 71 | 72 | with "entire file" do 73 | it "should read entire file" do 74 | expect(body.read).to be == "Hello World" 75 | end 76 | 77 | it "should use binary encoding" do 78 | expect(::File).to receive(:open).with(path, ::File::RDONLY | ::File::BINARY) 79 | 80 | chunk = body.read 81 | 82 | expect(chunk.encoding).to be == Encoding::BINARY 83 | end 84 | 85 | with "#ready?" do 86 | it "should be ready" do 87 | expect(body).to be(:ready?) 88 | end 89 | end 90 | end 91 | 92 | with "partial file" do 93 | let(:body) {subject.open(path, 2...4)} 94 | 95 | it "should read specified range" do 96 | expect(body.read).to be == "ll" 97 | end 98 | end 99 | 100 | with "#call" do 101 | let(:output) {StringIO.new} 102 | 103 | it "can stream output" do 104 | body.call(output) 105 | 106 | expect(output.string).to be == "Hello World" 107 | end 108 | 109 | with "/dev/zero" do 110 | it "can stream partial output" do 111 | skip unless File.exist?("/dev/zero") 112 | 113 | body = subject.open("/dev/zero", 0...10) 114 | 115 | body.call(output) 116 | 117 | expect(output.string).to be == "\x00" * 10 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/protocol/http/body/file_spec.txt: -------------------------------------------------------------------------------- 1 | Hello World -------------------------------------------------------------------------------- /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 | let(:source) {Protocol::HTTP::Body::Buffered.wrap("!")} 50 | let(:body) {subject.for(source)} 51 | 52 | it "captures length and closes existing body" do 53 | expect(source).to receive(:close) 54 | 55 | expect(body).to have_attributes(length: be == 1) 56 | body.close 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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-2024, 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/body/reader_spec.txt: -------------------------------------------------------------------------------- 1 | thequickbrownfox -------------------------------------------------------------------------------- /test/protocol/http/body/rewindable.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/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 | end 108 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/protocol/http/body/writable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "protocol/http/body/writable" 7 | require "protocol/http/body/deflate" 8 | require "protocol/http/body/a_writable_body" 9 | 10 | describe Protocol::HTTP::Body::Writable do 11 | let(:body) {subject.new} 12 | 13 | it_behaves_like Protocol::HTTP::Body::AWritableBody 14 | 15 | with "#length" do 16 | it "should be unspecified by default" do 17 | expect(body.length).to be_nil 18 | end 19 | end 20 | 21 | with "#closed?" do 22 | it "should not be closed by default" do 23 | expect(body).not.to be(:closed?) 24 | end 25 | end 26 | 27 | with "#ready?" do 28 | it "should be ready if chunks are available" do 29 | expect(body).not.to be(:ready?) 30 | 31 | body.write("Hello") 32 | 33 | expect(body).to be(:ready?) 34 | end 35 | 36 | it "should be ready if closed" do 37 | body.close 38 | 39 | expect(body).to be(:ready?) 40 | end 41 | end 42 | 43 | with "#empty?" do 44 | it "should be empty if closed with no pending chunks" do 45 | expect(body).not.to be(:empty?) 46 | 47 | body.close_write 48 | 49 | expect(body).to be(:empty?) 50 | end 51 | 52 | it "should become empty when pending chunks are read" do 53 | body.write("Hello") 54 | 55 | body.close_write 56 | 57 | expect(body).not.to be(:empty?) 58 | body.read 59 | expect(body).to be(:empty?) 60 | end 61 | 62 | it "should not be empty if chunks are available" do 63 | body.write("Hello") 64 | expect(body).not.to be(:empty?) 65 | end 66 | end 67 | 68 | with "#write" do 69 | it "should write chunks" do 70 | body.write("Hello") 71 | body.write("World") 72 | 73 | expect(body.read).to be == "Hello" 74 | expect(body.read).to be == "World" 75 | end 76 | 77 | it "can't write to closed body" do 78 | body.close 79 | 80 | expect do 81 | body.write("Hello") 82 | end.to raise_exception(Protocol::HTTP::Body::Writable::Closed) 83 | end 84 | 85 | it "can write and read data" do 86 | 3.times do |i| 87 | body.write("Hello World #{i}") 88 | expect(body.read).to be == "Hello World #{i}" 89 | end 90 | end 91 | 92 | it "can buffer data in order" do 93 | 3.times do |i| 94 | body.write("Hello World #{i}") 95 | end 96 | 97 | 3.times do |i| 98 | expect(body.read).to be == "Hello World #{i}" 99 | end 100 | end 101 | end 102 | 103 | with "#join" do 104 | it "can join chunks" do 105 | 3.times do |i| 106 | body.write("#{i}") 107 | end 108 | 109 | body.close_write 110 | 111 | expect(body.join).to be == "012" 112 | end 113 | end 114 | 115 | with "#each" do 116 | it "can read all data in order" do 117 | 3.times do |i| 118 | body.write("Hello World #{i}") 119 | end 120 | 121 | body.close_write 122 | 123 | 3.times do |i| 124 | chunk = body.read 125 | expect(chunk).to be == "Hello World #{i}" 126 | end 127 | end 128 | 129 | it "can propagate failures" do 130 | body.write("Beep boop") # This will cause a failure. 131 | 132 | expect do 133 | body.each do |chunk| 134 | raise RuntimeError.new("It was too big!") 135 | end 136 | end.to raise_exception(RuntimeError, message: be =~ /big/) 137 | 138 | expect do 139 | body.write("Beep boop") # This will fail. 140 | end.to raise_exception(RuntimeError, message: be =~ /big/) 141 | end 142 | 143 | it "can propagate failures in nested bodies" do 144 | nested = ::Protocol::HTTP::Body::Deflate.for(body) 145 | 146 | body.write("Beep boop") # This will cause a failure. 147 | 148 | expect do 149 | nested.each do |chunk| 150 | raise RuntimeError.new("It was too big!") 151 | end 152 | end.to raise_exception(RuntimeError, message: be =~ /big/) 153 | 154 | expect do 155 | body.write("Beep boop") # This will fail. 156 | end.to raise_exception(RuntimeError, message: be =~ /big/) 157 | end 158 | 159 | it "will stop after finishing" do 160 | body.write("Hello World!") 161 | body.close_write 162 | 163 | expect(body).not.to be(:empty?) 164 | 165 | body.each do |chunk| 166 | expect(chunk).to be == "Hello World!" 167 | end 168 | 169 | expect(body).to be(:empty?) 170 | end 171 | end 172 | 173 | with "#output" do 174 | it "can be used to write data" do 175 | body.output do |output| 176 | output.write("Hello World!") 177 | end 178 | 179 | expect(body.output).to be(:closed?) 180 | 181 | expect(body.read).to be == "Hello World!" 182 | expect(body.read).to be_nil 183 | end 184 | 185 | it "can propagate errors" do 186 | expect do 187 | body.output do |output| 188 | raise "Oops!" 189 | end 190 | end.to raise_exception(RuntimeError, message: be =~ /Oops/) 191 | 192 | expect(body).to be(:closed?) 193 | 194 | expect do 195 | body.read 196 | end.to raise_exception(RuntimeError, message: be =~ /Oops/) 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /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 | app = ->(request){ 48 | Protocol::HTTP::Response[206, Protocol::HTTP::Headers["content-type" => "text/plain"], ["Hello World!"]] 49 | } 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){ 66 | Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain", "content-encoding" => "identity"], ["Hello World!"]] 67 | } 68 | end 69 | 70 | let(:client) {subject.new(app)} 71 | 72 | it "does not compress response" do 73 | response = client.get("/index", {"accept-encoding" => "gzip"}) 74 | 75 | expect(response).to be(:success?) 76 | expect(response.headers).to have_keys("content-encoding") 77 | expect(response.headers["content-encoding"]).to be == ["identity"] 78 | 79 | expect(response.read).to be == "Hello World!" 80 | end 81 | end 82 | 83 | with "nil body" do 84 | let(:app) do 85 | app = ->(request){ 86 | Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain"], nil] 87 | } 88 | end 89 | 90 | let(:client) {subject.new(app)} 91 | 92 | it "does not compress response" do 93 | response = client.get("/index", {"accept-encoding" => "gzip"}) 94 | 95 | expect(response).to be(:success?) 96 | expect(response.headers).not.to have_keys("content-encoding") 97 | 98 | expect(response.read).to be == nil 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /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.new(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 | end 87 | -------------------------------------------------------------------------------- /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.new(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.new(value).charsets}.to raise_exception(subject::ParseError) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /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.new(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.new(value).encodings}.to raise_exception(subject::ParseError) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /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.new(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.new(value).languages}.to raise_exception(subject::ParseError) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /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 | end 24 | -------------------------------------------------------------------------------- /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.new(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 | end 92 | -------------------------------------------------------------------------------- /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.new(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 | end 60 | -------------------------------------------------------------------------------- /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.new(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=123==; 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 == "123==", 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=123%3D%3D;secure" 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /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.new(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 | describe Protocol::HTTP::Headers do 48 | let(:headers) { 49 | subject[[ 50 | ["Date", "Wed, 21 Oct 2015 07:28:00 GMT"], 51 | ["Expires", "Wed, 21 Oct 2015 07:28:00 GMT"], 52 | ["Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"], 53 | ["If-Modified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"], 54 | ["If-Unmodified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"] 55 | ]] 56 | } 57 | 58 | it "should parse date headers" do 59 | # When you convert headers into a hash, the policy is applied (i.e. conversion to Date instances): 60 | headers.to_h.each do |key, value| 61 | expect(value).to be_a(Protocol::HTTP::Header::Date) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /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.new(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 | end 35 | -------------------------------------------------------------------------------- /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.new(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 | -------------------------------------------------------------------------------- /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.new(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 | end 23 | -------------------------------------------------------------------------------- /test/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 "protocol/http/header/priority" 7 | 8 | describe Protocol::HTTP::Header::Priority do 9 | let(:header) { subject.new(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 | end 83 | -------------------------------------------------------------------------------- /test/protocol/http/header/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/header/quoted_string" 7 | 8 | describe Protocol::HTTP::Header::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/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.new(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 | end 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /test/protocol/http/reference.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/reference" 7 | 8 | describe Protocol::HTTP::Reference do 9 | let(:reference) {subject.new} 10 | 11 | with "#base" do 12 | let(:reference) {subject.new("/foo/bar", "foo=bar", "baz", {x: 10})} 13 | 14 | it "returns reference with only the path" do 15 | expect(reference.base).to have_attributes( 16 | path: be == reference.path, 17 | parameters: be_nil, 18 | fragment: be_nil, 19 | ) 20 | end 21 | end 22 | 23 | with "#+" do 24 | let(:absolute) {subject["/foo/bar"]} 25 | let(:relative) {subject["foo/bar"]} 26 | let(:up) {subject["../baz"]} 27 | 28 | it "can add a relative path" do 29 | expect(reference + relative).to be == absolute 30 | end 31 | 32 | it "can add an absolute path" do 33 | expect(reference + absolute).to be == absolute 34 | end 35 | 36 | it "can add an absolute path" do 37 | expect(relative + absolute).to be == absolute 38 | end 39 | 40 | it "can remove relative parts" do 41 | expect(absolute + up).to be == subject["/baz"] 42 | end 43 | end 44 | 45 | with "#freeze" do 46 | it "can freeze reference" do 47 | expect(reference.freeze).to be_equal(reference) 48 | expect(reference).to be(:frozen?) 49 | end 50 | end 51 | 52 | with "#with" do 53 | it "can nest paths" do 54 | reference = subject.new("/foo") 55 | expect(reference.path).to be == "/foo" 56 | 57 | nested_resource = reference.with(path: "bar") 58 | expect(nested_resource.path).to be == "/foo/bar" 59 | end 60 | 61 | it "can update path" do 62 | copy = reference.with(path: "foo/bar.html") 63 | expect(copy.path).to be == "/foo/bar.html" 64 | end 65 | 66 | it "can append path components" do 67 | copy = reference.with(path: "foo/").with(path: "bar/") 68 | 69 | expect(copy.path).to be == "/foo/bar/" 70 | end 71 | 72 | it "can append empty path components" do 73 | copy = reference.with(path: "") 74 | 75 | expect(copy.path).to be == reference.path 76 | end 77 | 78 | it "can append parameters" do 79 | copy = reference.with(parameters: {x: 10}) 80 | 81 | expect(copy.parameters).to be == {x: 10} 82 | end 83 | 84 | it "can merge parameters" do 85 | copy = reference.with(parameters: {x: 10}).with(parameters: {y: 20}) 86 | 87 | expect(copy.parameters).to be == {x: 10, y: 20} 88 | end 89 | 90 | it "can copy parameters" do 91 | copy = reference.with(parameters: {x: 10}).with(path: "foo") 92 | 93 | expect(copy.parameters).to be == {x: 10} 94 | expect(copy.path).to be == "/foo" 95 | end 96 | 97 | it "can replace path with absolute path" do 98 | copy = reference.with(path: "foo").with(path: "/bar") 99 | 100 | expect(copy.path).to be == "/bar" 101 | end 102 | 103 | it "can replace path with relative path" do 104 | copy = reference.with(path: "foo").with(path: "../../bar") 105 | 106 | expect(copy.path).to be == "/bar" 107 | end 108 | 109 | with "#query" do 110 | let(:reference) {subject.new("foo/bar/baz.html", "x=10", nil, nil)} 111 | 112 | it "can replace query" do 113 | copy = reference.with(parameters: nil, merge: false) 114 | 115 | expect(copy.parameters).to be_nil 116 | expect(copy.query).to be_nil 117 | end 118 | end 119 | 120 | with "relative path" do 121 | let(:reference) {subject.new("foo/bar/baz.html", nil, nil, nil)} 122 | 123 | it "can compute new relative path" do 124 | copy = reference.with(path: "../index.html", pop: true) 125 | 126 | expect(copy.path).to be == "foo/index.html" 127 | end 128 | 129 | it "can compute relative path with more uplevels" do 130 | copy = reference.with(path: "../../../index.html", pop: true) 131 | 132 | expect(copy.path).to be == "../index.html" 133 | end 134 | end 135 | end 136 | 137 | with "empty query string" do 138 | let(:reference) {subject.new("/", "", nil, {})} 139 | 140 | it "it should not append query string" do 141 | expect(reference.to_s).not.to be(:include?, "?") 142 | end 143 | 144 | it "can add a relative path" do 145 | result = reference + subject["foo/bar"] 146 | 147 | expect(result.to_s).to be == "/foo/bar" 148 | end 149 | end 150 | 151 | with "empty fragment" do 152 | let(:reference) {subject.new("/", nil, "", nil)} 153 | 154 | it "it should not append query string" do 155 | expect(reference.to_s).not.to be(:include?, "#") 156 | end 157 | end 158 | 159 | describe Protocol::HTTP::Reference.parse("path with spaces/image.jpg") do 160 | it "encodes whitespace" do 161 | expect(subject.to_s).to be == "path%20with%20spaces/image.jpg" 162 | end 163 | end 164 | 165 | describe Protocol::HTTP::Reference.parse("path", array: [1, 2, 3]) do 166 | it "encodes array" do 167 | expect(subject.to_s).to be == "path?array[]=1&array[]=2&array[]=3" 168 | end 169 | end 170 | 171 | describe Protocol::HTTP::Reference.parse("path_with_underscores/image.jpg") do 172 | it "doesn't touch underscores" do 173 | expect(subject.to_s).to be == "path_with_underscores/image.jpg" 174 | end 175 | end 176 | 177 | describe Protocol::HTTP::Reference.parse("index", "my name" => "Bob Dole") do 178 | it "encodes query" do 179 | expect(subject.to_s).to be == "index?my%20name=Bob%20Dole" 180 | end 181 | end 182 | 183 | describe Protocol::HTTP::Reference.parse("index#All Your Base") do 184 | it "encodes fragment" do 185 | expect(subject.to_s).to be == "index\#All%20Your%20Base" 186 | end 187 | end 188 | 189 | describe Protocol::HTTP::Reference.parse("I/❤️/UNICODE", face: "😀") do 190 | it "encodes unicode" do 191 | expect(subject.to_s).to be == "I/%E2%9D%A4%EF%B8%8F/UNICODE?face=%F0%9F%98%80" 192 | end 193 | end 194 | 195 | describe Protocol::HTTP::Reference.parse("foo?bar=10&baz=20", yes: "no") do 196 | it "can use existing query parameters" do 197 | expect(subject.to_s).to be == "foo?bar=10&baz=20&yes=no" 198 | end 199 | end 200 | 201 | describe Protocol::HTTP::Reference.parse("foo#frag") do 202 | it "can use existing fragment" do 203 | expect(subject.fragment).to be == "frag" 204 | expect(subject.to_s).to be == "foo#frag" 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /test/protocol/http/request.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/request" 7 | 8 | require "json" 9 | 10 | describe Protocol::HTTP::Request do 11 | let(:headers) {Protocol::HTTP::Headers.new} 12 | let(:body) {nil} 13 | 14 | with ".[]" do 15 | let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello, World!")} 16 | let(:headers) {Protocol::HTTP::Headers[{"accept" => "text/html"}]} 17 | 18 | it "creates a new request" do 19 | request = subject["GET", "/index.html", headers] 20 | 21 | expect(request).to have_attributes( 22 | scheme: be_nil, 23 | authority: be_nil, 24 | method: be == "GET", 25 | path: be == "/index.html", 26 | version: be_nil, 27 | headers: be == headers, 28 | body: be_nil, 29 | protocol: be_nil 30 | ) 31 | end 32 | 33 | it "creates a new request with keyword arguments" do 34 | request = subject["GET", "/index.html", scheme: "http", authority: "localhost", headers: headers, body: body] 35 | 36 | expect(request).to have_attributes( 37 | scheme: be == "http", 38 | authority: be == "localhost", 39 | method: be == "GET", 40 | path: be == "/index.html", 41 | version: be_nil, 42 | headers: be == headers, 43 | body: be == body, 44 | protocol: be_nil 45 | ) 46 | end 47 | 48 | it "converts header hash to headers instance" do 49 | request = subject["GET", "/index.html", {"accept" => "text/html"}] 50 | 51 | expect(request).to have_attributes( 52 | headers: be == headers, 53 | ) 54 | end 55 | 56 | it "converts array body to buffered body" do 57 | request = subject["GET", "/index.html", headers: headers, body: ["Hello, World!"]] 58 | 59 | expect(request).to have_attributes( 60 | body: be_a(Protocol::HTTP::Body::Buffered) 61 | ) 62 | end 63 | 64 | it "can accept no arguments" do 65 | request = subject["GET"] 66 | 67 | expect(request).to have_attributes( 68 | method: be == "GET", 69 | path: be_nil, 70 | ) 71 | end 72 | 73 | it "converts path to string" do 74 | request = subject["GET", :index] 75 | 76 | expect(request).to have_attributes( 77 | method: be == "GET", 78 | path: be == "index", 79 | ) 80 | end 81 | end 82 | 83 | with "simple GET request" do 84 | let(:request) {subject.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)} 85 | 86 | it "should have attributes" do 87 | expect(request).to have_attributes( 88 | scheme: be == "http", 89 | authority: be == "localhost", 90 | method: be == "GET", 91 | path: be == "/index.html", 92 | version: be == "HTTP/1.0", 93 | headers: be == headers, 94 | body: be == body, 95 | protocol: be_nil, 96 | peer: be_nil, 97 | ) 98 | end 99 | 100 | with "#as_json" do 101 | it "generates a JSON representation" do 102 | expect(request.as_json).to be == { 103 | scheme: "http", 104 | authority: "localhost", 105 | method: "GET", 106 | path: "/index.html", 107 | version: "HTTP/1.0", 108 | headers: headers.as_json, 109 | body: nil, 110 | protocol: nil 111 | } 112 | end 113 | 114 | it "generates a JSON string" do 115 | expect(JSON.dump(request)).to be == request.to_json 116 | end 117 | end 118 | 119 | it "should not be HEAD" do 120 | expect(request).not.to be(:head?) 121 | end 122 | 123 | it "should not be CONNECT" do 124 | expect(request).not.to be(:connect?) 125 | end 126 | 127 | it "should be idempotent" do 128 | expect(request).to be(:idempotent?) 129 | end 130 | 131 | it "should have a string representation" do 132 | expect(request.to_s).to be == "http://localhost: GET /index.html HTTP/1.0" 133 | end 134 | 135 | it "can apply the request to a connection" do 136 | connection = proc{|request| request} 137 | 138 | expect(connection).to receive(:call).with(request) 139 | 140 | request.call(connection) 141 | end 142 | end 143 | 144 | with "interim response" do 145 | let(:request) {subject.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)} 146 | 147 | it "should call block" do 148 | request.on_interim_response do |status, headers| 149 | expect(status).to be == 100 150 | expect(headers).to be == {} 151 | end 152 | 153 | request.send_interim_response(100, {}) 154 | end 155 | 156 | it "calls multiple blocks" do 157 | sequence = [] 158 | 159 | request.on_interim_response do |status, headers| 160 | sequence << 1 161 | 162 | expect(status).to be == 100 163 | expect(headers).to be == {} 164 | end 165 | 166 | request.on_interim_response do |status, headers| 167 | sequence << 2 168 | 169 | expect(status).to be == 100 170 | expect(headers).to be == {} 171 | end 172 | 173 | request.send_interim_response(100, {}) 174 | 175 | expect(sequence).to be == [2, 1] 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /test/protocol/http/url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2022, by Herrick Fang. 6 | 7 | require "protocol/http/url" 8 | 9 | ValidParameters = Sus::Shared("valid parameters") do |parameters, query_string = nil| 10 | let(:encoded) {Protocol::HTTP::URL.encode(parameters)} 11 | 12 | if query_string 13 | it "can encode #{parameters.inspect}" do 14 | expect(encoded).to be == query_string 15 | end 16 | end 17 | 18 | let(:decoded) {Protocol::HTTP::URL.decode(encoded)} 19 | 20 | it "can round-trip #{parameters.inspect}" do 21 | expect(decoded).to be == parameters 22 | end 23 | end 24 | 25 | describe Protocol::HTTP::URL do 26 | it_behaves_like ValidParameters, {"foo" => "bar"}, "foo=bar" 27 | it_behaves_like ValidParameters, {"foo" => ["1", "2", "3"]}, "foo[]=1&foo[]=2&foo[]=3" 28 | 29 | it_behaves_like ValidParameters, {"foo" => {"bar" => "baz"}}, "foo[bar]=baz" 30 | it_behaves_like ValidParameters, {"foo" => [{"bar" => "baz"}]}, "foo[][bar]=baz" 31 | 32 | it_behaves_like ValidParameters, {"foo" => [{"bar" => "baz"}, {"bar" => "bob"}]} 33 | 34 | RoundTrippedParameters = Sus::Shared("round-tripped parameters") do 35 | let(:encoded) {Protocol::HTTP::URL.encode(parameters)} 36 | let(:decoded) {Protocol::HTTP::URL.decode(encoded, symbolize_keys: true)} 37 | 38 | it "can round-trip parameters" do 39 | expect(decoded).to be == parameters 40 | end 41 | end 42 | 43 | with "basic parameters" do 44 | let(:parameters) {{x: "10", y: "20"}} 45 | 46 | it_behaves_like RoundTrippedParameters 47 | end 48 | 49 | with "nested parameters" do 50 | let(:parameters) {{things: [{x: "10"}, {x: "20"}]}} 51 | 52 | it_behaves_like RoundTrippedParameters 53 | end 54 | 55 | with "nil values" do 56 | let(:parameters) {{x: nil}} 57 | 58 | it_behaves_like RoundTrippedParameters 59 | end 60 | 61 | with "nil values in arrays" do 62 | let(:parameters) {{x: ["1", nil, "2"]}} 63 | 64 | it_behaves_like RoundTrippedParameters 65 | end 66 | 67 | with ".decode" do 68 | it "fails on deeply nested parameters" do 69 | expect do 70 | Protocol::HTTP::URL.decode("a[b][c][d][e][f][g][h][i]=10") 71 | end.to raise_exception(ArgumentError, message: be =~ /Key length exceeded/) 72 | end 73 | 74 | it "fails with missing key" do 75 | expect do 76 | Protocol::HTTP::URL.decode("=foo") 77 | end.to raise_exception(ArgumentError, message: be =~ /Invalid key/) 78 | end 79 | 80 | it "fails with empty pairs" do 81 | expect(Protocol::HTTP::URL.decode("a=1&&b=2")).to be == {"a" => "1", "b" => "2"} 82 | expect(Protocol::HTTP::URL.decode("a&&b")).to be == {"a" => nil, "b" => nil} 83 | end 84 | end 85 | 86 | with ".unescape" do 87 | it "succeds with hex characters" do 88 | expect(Protocol::HTTP::URL.unescape("%3A")).to be == ":" 89 | end 90 | end 91 | end 92 | --------------------------------------------------------------------------------