├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── .mailmap ├── .rubocop.yml ├── async-http.gemspec ├── bake.rb ├── bake └── async │ ├── http.rb │ └── http │ └── h2spec.rb ├── config ├── external.yaml └── sus.rb ├── examples ├── compare │ ├── benchmark.rb │ └── gems.rb ├── delay │ └── client.rb ├── download │ └── chunked.rb ├── fetch │ ├── README.md │ ├── config.ru │ ├── gems.rb │ └── public │ │ ├── index.html │ │ └── stream.js ├── google │ ├── about.html │ ├── codeotaku.rb │ ├── gems.locked │ ├── gems.rb │ ├── multiple.rb │ ├── ruby.html │ └── search.rb ├── header-lowercase │ └── benchmark.rb ├── hello │ ├── config.ru │ ├── gems.locked │ ├── gems.rb │ └── readme.md ├── licenses │ ├── gemspect.rb │ └── list.rb ├── race │ ├── client.rb │ └── server.rb ├── request.rb ├── request │ └── http10.rb ├── stream │ └── stop.rb ├── trenni │ ├── Gemfile │ └── streaming.rb └── upload │ ├── client.rb │ ├── data.txt │ ├── server.rb │ └── upload.rb ├── fixtures └── async │ └── http │ └── a_protocol.rb ├── gems.rb ├── guides ├── getting-started │ └── readme.md ├── links.yaml └── testing │ └── readme.md ├── lib └── async │ ├── http.rb │ └── http │ ├── body.rb │ ├── body │ ├── hijack.rb │ ├── pipe.rb │ └── writable.rb │ ├── client.rb │ ├── endpoint.rb │ ├── internet.rb │ ├── internet │ └── instance.rb │ ├── middleware │ └── location_redirector.rb │ ├── mock.rb │ ├── mock │ └── endpoint.rb │ ├── protocol.rb │ ├── protocol │ ├── configurable.rb │ ├── defaulton.rb │ ├── http.rb │ ├── http1.rb │ ├── http1 │ │ ├── client.rb │ │ ├── connection.rb │ │ ├── finishable.rb │ │ ├── request.rb │ │ ├── response.rb │ │ └── server.rb │ ├── http10.rb │ ├── http11.rb │ ├── http2.rb │ ├── http2 │ │ ├── client.rb │ │ ├── connection.rb │ │ ├── input.rb │ │ ├── output.rb │ │ ├── request.rb │ │ ├── response.rb │ │ ├── server.rb │ │ └── stream.rb │ ├── https.rb │ ├── request.rb │ └── response.rb │ ├── proxy.rb │ ├── reference.rb │ ├── relative_location.rb │ ├── server.rb │ ├── statistics.rb │ └── version.rb ├── license.md ├── readme.md ├── release.cert ├── releases.md └── test ├── async └── http │ ├── body.rb │ ├── body │ ├── hijack.rb │ └── pipe.rb │ ├── client.rb │ ├── client │ └── google.rb │ ├── endpoint.rb │ ├── internet.rb │ ├── internet │ └── instance.rb │ ├── middleware │ └── location_redirector.rb │ ├── mock.rb │ ├── protocol │ ├── http.rb │ ├── http1.rb │ ├── http10.rb │ ├── http11.rb │ ├── http11 │ │ └── desync.rb │ ├── http2.rb │ └── https.rb │ ├── proxy.rb │ ├── retry.rb │ ├── server.rb │ ├── ssl.rb │ └── statistics.rb ├── protocol └── http │ └── body │ ├── stream.rb │ └── streamable.rb └── rack └── test.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 | TRACES_BACKEND: traces/backend/test 11 | METRICS_BACKEND: metrics/backend/test 12 | 13 | jobs: 14 | test: 15 | name: ${{matrix.ruby}} on ${{matrix.os}} 16 | runs-on: ${{matrix.os}}-latest 17 | continue-on-error: ${{matrix.experimental}} 18 | 19 | strategy: 20 | matrix: 21 | os: 22 | - ubuntu 23 | - macos 24 | 25 | ruby: 26 | - "3.2" 27 | - "3.3" 28 | - "3.4" 29 | 30 | experimental: [false] 31 | 32 | include: 33 | - os: ubuntu 34 | ruby: truffleruby 35 | experimental: true 36 | - os: ubuntu 37 | ruby: jruby 38 | experimental: true 39 | - os: ubuntu 40 | ruby: head 41 | experimental: true 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: ruby/setup-ruby@v1 46 | with: 47 | ruby-version: ${{matrix.ruby}} 48 | bundler-cache: true 49 | 50 | - name: Run tests 51 | timeout-minutes: 10 52 | run: bundle exec bake test 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Viacheslav Koval 2 | Sam Shadwell 3 | Thomas Morgan 4 | Hal Brodigan 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /async-http.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/async/http/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "async-http" 7 | spec.version = Async::HTTP::VERSION 8 | 9 | spec.summary = "A HTTP client and server library." 10 | spec.authors = ["Samuel Williams", "Brian Morearty", "Bruno Sutic", "Janko Marohnić", "Thomas Morgan", "Adam Daniels", "Igor Sidorov", "Anton Zhuravsky", "Cyril Roelandt", "Denis Talakevich", "Hal Brodigan", "Ian Ker-Seymer", "Jean Boussier", "Josh Huber", "Marco Concetto Rudilosso", "Olle Jonsson", "Orgad Shaneh", "Sam Shadwell", "Stefan Wrobel", "Tim Meusel", "Trevor Turk", "Viacheslav Koval", "dependabot[bot]"] 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/async-http" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/async-http/", 20 | "source_code_uri" => "https://github.com/socketry/async-http.git", 21 | } 22 | 23 | spec.files = Dir.glob(["{bake,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.required_ruby_version = ">= 3.2" 26 | 27 | spec.add_dependency "async", ">= 2.10.2" 28 | spec.add_dependency "async-pool", "~> 0.9" 29 | spec.add_dependency "io-endpoint", "~> 0.14" 30 | spec.add_dependency "io-stream", "~> 0.6" 31 | spec.add_dependency "metrics", "~> 0.12" 32 | spec.add_dependency "protocol-http", "~> 0.49" 33 | spec.add_dependency "protocol-http1", "~> 0.30" 34 | spec.add_dependency "protocol-http2", "~> 0.22" 35 | spec.add_dependency "traces", "~> 0.10" 36 | end 37 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bake/async/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | # Fetch the specified URL and print the response. 7 | # @param url [String] the URL to parse and fetch. 8 | # @param method [String] the HTTP method to use. 9 | def fetch(url, method:) 10 | require "async/http/internet" 11 | require "kernel/sync" 12 | 13 | terminal = Console::Terminal.for($stdout) 14 | terminal[:request] = terminal.style(:blue, nil, :bold) 15 | terminal[:response] = terminal.style(:green, nil, :bold) 16 | terminal[:length] = terminal.style(nil, nil, :bold) 17 | 18 | terminal[:key] = terminal.style(nil, nil, :bold) 19 | 20 | terminal[:chunk_0] = terminal.style(:blue) 21 | terminal[:chunk_1] = terminal.style(:cyan) 22 | 23 | align = 20 24 | 25 | format_body = proc do |body, terminal| 26 | if body 27 | if length = body.length 28 | terminal.print(:body, "body with length ", :length, length, "B") 29 | else 30 | terminal.print(:body, "body without length") 31 | end 32 | else 33 | terminal.print(:body, "no body") 34 | end 35 | end.curry 36 | 37 | Sync do 38 | internet = Async::HTTP::Internet.new 39 | 40 | response = internet.send(method.downcase.to_sym, url) 41 | 42 | terminal.print_line( 43 | :request, method.rjust(align), :reset, ": ", url 44 | ) 45 | 46 | terminal.print_line( 47 | :response, "version".rjust(align), :reset, ": ", response.version 48 | ) 49 | 50 | terminal.print_line( 51 | :response, "status".rjust(align), :reset, ": ", response.status, 52 | ) 53 | 54 | terminal.print_line( 55 | :response, "body".rjust(align), :reset, ": ", format_body[response.body], 56 | ) 57 | 58 | response.headers.each do |key, value| 59 | terminal.print_line( 60 | :key, key.rjust(align), :reset, ": ", :value, value.inspect 61 | ) 62 | end 63 | 64 | if body = response.body 65 | index = 0 66 | style = [:chunk_0, :chunk_1] 67 | response.body.each do |chunk| 68 | terminal.print(style[index % 2], chunk) 69 | index += 1 70 | end 71 | end 72 | 73 | response.finish 74 | 75 | if trailer = response.headers.trailer 76 | trailer.each do |key, value| 77 | terminal.print_line( 78 | :key, key.rjust(align), :reset, ": ", :value, value.inspect 79 | ) 80 | end 81 | end 82 | 83 | internet.close 84 | end 85 | end 86 | 87 | # GET the specified URL and print the response. 88 | def get(url) 89 | self.fetch(url, method: "GET") 90 | end 91 | 92 | # HEAD the specified URL and print the response. 93 | def head(url) 94 | self.fetch(url, method: "HEAD") 95 | end 96 | -------------------------------------------------------------------------------- /bake/async/http/h2spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | def build 7 | # Fetch the code: 8 | system "go get github.com/spf13/cobra" 9 | system "go get github.com/summerwind/h2spec" 10 | 11 | # This builds `h2spec` into the current directory 12 | system "go build ~/go/src/github.com/summerwind/h2spec/cmd/h2spec/h2spec.go" 13 | end 14 | 15 | def test 16 | server do 17 | system("./h2spec", "-p", "7272") 18 | end 19 | end 20 | 21 | private 22 | 23 | def server 24 | require "async" 25 | require "async/container" 26 | require "async/http/server" 27 | require "io/endpoint/host_endpoint" 28 | 29 | endpoint = IO::Endpoint.tcp("127.0.0.1", 7272) 30 | 31 | container = Async::Container.new 32 | 33 | Console.info(self){"Starting server..."} 34 | 35 | container.run(count: 1) do 36 | server = Async::HTTP::Server.for(endpoint, protocol: Async::HTTP::Protocol::HTTP2, scheme: "https") do |request| 37 | Protocol::HTTP::Response[200, {"content-type" => "text/plain"}, ["Hello World"]] 38 | end 39 | 40 | Async do 41 | server.run 42 | end 43 | end 44 | 45 | yield if block_given? 46 | ensure 47 | container&.stop 48 | end 49 | -------------------------------------------------------------------------------- /config/external.yaml: -------------------------------------------------------------------------------- 1 | falcon: 2 | url: https://github.com/socketry/falcon.git 3 | command: bundle exec bake test 4 | async-rest: 5 | url: https://github.com/socketry/async-rest.git 6 | command: bundle exec sus 7 | async-websocket: 8 | url: https://github.com/socketry/async-websocket.git 9 | command: bundle exec sus 10 | async-http-faraday: 11 | url: https://github.com/socketry/async-http-faraday.git 12 | command: bundle exec bake test 13 | # async-http-cache: 14 | # url: https://github.com/socketry/async-http-cache.git 15 | # command: bundle exec rspec 16 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | # Copyright, 2018, by Janko Marohnić. 6 | 7 | # ENV["CONSOLE_LEVEL"] ||= "fatal" 8 | 9 | require "covered/sus" 10 | include Covered::Sus 11 | 12 | require "traces" 13 | ENV["TRACES_BACKEND"] ||= "traces/backend/test" 14 | -------------------------------------------------------------------------------- /examples/compare/benchmark.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | require "benchmark" 8 | 9 | require "httpx" 10 | 11 | require "async" 12 | require "async/barrier" 13 | require "async/semaphore" 14 | require "async/http/internet" 15 | 16 | URL = "https://www.codeotaku.com/index" 17 | REPEATS = 10 18 | 19 | Benchmark.bmbm do |x| 20 | x.report("async-http") do 21 | Async do 22 | internet = Async::HTTP::Internet.new 23 | 24 | i = 0 25 | while i < REPEATS 26 | response = internet.get(URL) 27 | response.read 28 | 29 | i += 1 30 | end 31 | ensure 32 | internet&.close 33 | end 34 | end 35 | 36 | x.report("async-http (pipelined)") do 37 | Async do |task| 38 | internet = Async::HTTP::Internet.new 39 | semaphore = Async::Semaphore.new(100, parent: task) 40 | barrier = Async::Barrier.new(parent: semaphore) 41 | 42 | # Warm up the connection pool... 43 | response = internet.get(URL) 44 | response.read 45 | 46 | i = 0 47 | while i < REPEATS 48 | barrier.async do 49 | response = internet.get(URL) 50 | 51 | response.read 52 | end 53 | 54 | i += 1 55 | end 56 | 57 | barrier.wait 58 | ensure 59 | internet&.close 60 | end 61 | end 62 | 63 | x.report("httpx") do 64 | i = 0 65 | while i < REPEATS 66 | response = HTTPX.get(URL) 67 | 68 | response.read 69 | 70 | i += 1 71 | end 72 | end 73 | 74 | x.report("httpx (pipelined)") do 75 | urls = [URL] * REPEATS 76 | responses = HTTPX.get(*urls) 77 | 78 | responses.each do |response| 79 | response.read 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /examples/compare/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2023, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "benchmark-ips" 9 | 10 | gem "async" 11 | gem "async-http" 12 | 13 | gem "httpx" 14 | -------------------------------------------------------------------------------- /examples/delay/client.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 "net/http" 8 | require "async" 9 | require "async/http/internet" 10 | require "async/barrier" 11 | require "async/semaphore" 12 | 13 | N_TIMES = 1000 14 | 15 | Async do |task| 16 | internet = Async::HTTP::Internet.new 17 | barrier = Async::Barrier.new 18 | 19 | results = N_TIMES.times.map do |i| 20 | barrier.async do 21 | puts "Run #{i}" 22 | 23 | begin 24 | response = internet.get("https://httpbin.org/delay/0.5") 25 | ensure 26 | response&.finish 27 | end 28 | end 29 | end 30 | 31 | barrier.wait 32 | end 33 | -------------------------------------------------------------------------------- /examples/download/chunked.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2025, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/clock" 9 | require "async/barrier" 10 | require "async/semaphore" 11 | require_relative "../../lib/async/http/endpoint" 12 | require_relative "../../lib/async/http/client" 13 | 14 | Async do 15 | url = "https://static.openfoodfacts.org/data/en.openfoodfacts.org.products.csv" 16 | 17 | endpoint = Async::HTTP::Endpoint.parse(url) 18 | client = Async::HTTP::Client.new(endpoint) 19 | 20 | headers = {"user-agent" => "curl/7.69.1", "accept" => "*/*"} 21 | 22 | file = File.open("products.csv", "w") 23 | Console.info(self) {"Saving download to #{Dir.pwd}"} 24 | 25 | begin 26 | response = client.head(endpoint.path, headers) 27 | content_length = nil 28 | 29 | if response.success? 30 | unless response.headers["accept-ranges"].include?("bytes") 31 | raise "Does not advertise support for accept-ranges: bytes!" 32 | end 33 | 34 | unless content_length = response.body&.length 35 | raise "Could not determine length of response!" 36 | end 37 | end 38 | ensure 39 | response&.close 40 | end 41 | 42 | Console.info(self) {"Content length: #{content_length/(1024**2)}MiB"} 43 | 44 | parts = [] 45 | offset = 0 46 | chunk_size = 1024*1024 47 | 48 | start_time = Async::Clock.now 49 | amount = 0 50 | 51 | while offset < content_length 52 | byte_range_start = offset 53 | byte_range_end = [offset + chunk_size, content_length].min 54 | parts << (byte_range_start...byte_range_end) 55 | 56 | offset += chunk_size 57 | end 58 | 59 | Console.info(self) {"Breaking download into #{parts.size} parts..."} 60 | 61 | semaphore = Async::Semaphore.new(8) 62 | barrier = Async::Barrier.new(parent: semaphore) 63 | 64 | while !parts.empty? 65 | barrier.async do 66 | part = parts.shift 67 | 68 | Console.info(self) {"Issuing range request range: bytes=#{part.min}-#{part.max}"} 69 | 70 | response = client.get(endpoint.path, [ 71 | ["range", "bytes=#{part.min}-#{part.max-1}"], 72 | *headers 73 | ]) 74 | 75 | if response.success? 76 | Console.info(self) {"Got response: #{response}... writing data for #{part}."} 77 | written = file.pwrite(response.read, part.min) 78 | 79 | amount += written 80 | 81 | duration = Async::Clock.now - start_time 82 | Console.info(self) {"Rate: #{((amount.to_f/(1024**2))/duration).round(2)}MiB/s"} 83 | end 84 | end 85 | end 86 | 87 | barrier.wait 88 | ensure 89 | client&.close 90 | end 91 | -------------------------------------------------------------------------------- /examples/fetch/README.md: -------------------------------------------------------------------------------- 1 | # Fetch 2 | 3 | This was an experiment to see how browsers handle bi-directional streaming. 4 | -------------------------------------------------------------------------------- /examples/fetch/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | 5 | class Echo 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def call(env) 11 | request = Rack::Request.new(env) 12 | 13 | if request.path_info == "/echo" 14 | if output = request.body 15 | return [200, {}, output.body] 16 | else 17 | return [200, {}, ["Hello World?"]] 18 | end 19 | else 20 | return @app.call(env) 21 | end 22 | end 23 | end 24 | 25 | use Echo 26 | 27 | use Rack::Static, :urls => [""], :root => "public", :index => "index.html" 28 | 29 | run lambda{|env| [404, {}, []]} 30 | -------------------------------------------------------------------------------- /examples/fetch/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | gem "rack" 7 | gem "falcon" 8 | -------------------------------------------------------------------------------- /examples/fetch/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Streaming Fetch 5 | 6 | 7 |

Streams

8 | 9 | 10 | 11 |

Sent

12 | 13 |
    14 |
15 | 16 |

Received

17 | 18 |
    19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/fetch/public/stream.js: -------------------------------------------------------------------------------- 1 | const inputStream = new ReadableStream({ 2 | start(controller) { 3 | interval = setInterval(() => { 4 | let string = "Hello World!"; 5 | 6 | // Add the string to the stream 7 | controller.enqueue(string); 8 | 9 | // show it on the screen 10 | let listItem = document.createElement('li'); 11 | listItem.textContent = string; 12 | sent.appendChild(listItem); 13 | }, 10000); 14 | 15 | stopButton.addEventListener('click', function() { 16 | clearInterval(interval); 17 | controller.close(); 18 | }) 19 | }, 20 | pull(controller) { 21 | // We don't really need a pull in this example 22 | }, 23 | cancel() { 24 | // This is called if the reader cancels, 25 | // so we should stop generating strings 26 | clearInterval(interval); 27 | } 28 | }); 29 | 30 | fetch("/echo", {method: 'POST', body: inputStream}) 31 | .then(response => { 32 | const reader = response.body.getReader(); 33 | const decoder = new TextDecoder("utf-8"); 34 | 35 | function push() { 36 | reader.read().then(({done, value}) => { 37 | console.log("done:", done, "value:", value); 38 | const string = decoder.decode(value); 39 | 40 | // show it on the screen 41 | let listItem = document.createElement('li'); 42 | 43 | if (done) 44 | listItem.textContent = "" 45 | else 46 | listItem.textContent = string; 47 | 48 | received.appendChild(listItem); 49 | 50 | if (done) return; 51 | else push(); 52 | }); 53 | }; 54 | 55 | push(); 56 | }); 57 | -------------------------------------------------------------------------------- /examples/google/about.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-http/58c38c3109fe131c7c621d543f3ac37bba8b66b3/examples/google/about.html -------------------------------------------------------------------------------- /examples/google/codeotaku.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/clock" 9 | require "protocol/http/middleware" 10 | require_relative "../../lib/async/http" 11 | 12 | URL = "https://www.codeotaku.com/index" 13 | ENDPOINT = Async::HTTP::Endpoint.parse(URL) 14 | 15 | if count = ENV["COUNT"]&.to_i 16 | terms = terms.first(count) 17 | end 18 | 19 | Async do |task| 20 | client = Async::HTTP::Client.new(ENDPOINT) 21 | 22 | client.get(ENDPOINT.path).finish 23 | 24 | duration = Async::Clock.measure do 25 | 20.times.map do |i| 26 | task.async do 27 | response = client.get(ENDPOINT.path) 28 | response.read 29 | $stderr.write "(#{i})" 30 | end 31 | end.map(&:wait) 32 | end 33 | 34 | pp duration 35 | ensure 36 | client.close 37 | end 38 | -------------------------------------------------------------------------------- /examples/google/gems.locked: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | async (2.3.1) 5 | console (~> 1.10) 6 | io-event (~> 1.1) 7 | timers (~> 4.1) 8 | async-http (0.60.1) 9 | async (>= 1.25) 10 | async-io (>= 1.28) 11 | async-pool (>= 0.2) 12 | protocol-http (~> 0.24.0) 13 | protocol-http1 (~> 0.15.0) 14 | protocol-http2 (~> 0.15.0) 15 | traces (>= 0.8.0) 16 | async-io (1.34.3) 17 | async 18 | async-pool (0.3.12) 19 | async (>= 1.25) 20 | console (1.16.2) 21 | fiber-local 22 | fiber-local (1.0.0) 23 | io-event (1.1.6) 24 | protocol-hpack (1.4.2) 25 | protocol-http (0.24.7) 26 | protocol-http1 (0.15.1) 27 | protocol-http (~> 0.22) 28 | protocol-http2 (0.15.1) 29 | protocol-hpack (~> 1.4) 30 | protocol-http (~> 0.18) 31 | timers (4.3.5) 32 | traces (0.8.0) 33 | 34 | PLATFORMS 35 | x86_64-linux 36 | 37 | DEPENDENCIES 38 | async-http (~> 0.60.0) 39 | 40 | BUNDLED WITH 41 | 2.4.6 42 | -------------------------------------------------------------------------------- /examples/google/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "async-http", "~> 0.60.0" 9 | -------------------------------------------------------------------------------- /examples/google/multiple.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 "async" 8 | require "async/barrier" 9 | require "async/semaphore" 10 | require "async/http/internet" 11 | 12 | TOPICS = ["ruby", "python", "rust"] 13 | 14 | Async do 15 | internet = Async::HTTP::Internet.new 16 | barrier = Async::Barrier.new 17 | semaphore = Async::Semaphore.new(2, parent: barrier) 18 | 19 | # Spawn an asynchronous task for each topic: 20 | TOPICS.each do |topic| 21 | semaphore.async do 22 | response = internet.get "https://www.google.com/search?q=#{topic}" 23 | puts "Found #{topic}: #{response.read.scan(topic).size} times." 24 | end 25 | end 26 | 27 | # Ensure we wait for all requests to complete before continuing: 28 | barrier.wait 29 | ensure 30 | internet&.close 31 | end 32 | -------------------------------------------------------------------------------- /examples/google/ruby.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-http/58c38c3109fe131c7c621d543f3ac37bba8b66b3/examples/google/ruby.html -------------------------------------------------------------------------------- /examples/google/search.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 "async" 8 | require "async/clock" 9 | require "protocol/http/middleware" 10 | require_relative "../../lib/async/http" 11 | 12 | URL = "https://www.google.com/search" 13 | ENDPOINT = Async::HTTP::Endpoint.parse(URL) 14 | 15 | class Google < Protocol::HTTP::Middleware 16 | def search(term) 17 | Console.info(self) {"Searching for #{term}..."} 18 | 19 | self.get("/search?q=#{term}", {"user-agent" => "Hi Google!"}) 20 | end 21 | end 22 | 23 | terms = %w{thoughtful fear size payment lethal modern recognise face morning sulky mountainous contain science snow uncle skirt truthful door travel snails closed rotten halting creator teeny-tiny beautiful cherries unruly level follow strip team things suggest pretty warm end cannon bad pig consider airport strengthen youthful fog three walk furry pickle moaning fax book ruddy sigh plate cakes shame stem faulty bushes dislike train sleet one colour behavior bitter suit count loutish squeak learn watery orange idiotic seat wholesale omniscient nostalgic arithmetic instruct committee puffy program cream cake whistle rely encourage war flagrant amusing fluffy prick utter wacky occur daily son check} 24 | 25 | if count = ENV.fetch("COUNT", 20)&.to_i 26 | terms = terms.first(count) 27 | end 28 | 29 | Async do |task| 30 | client = Async::HTTP::Client.new(ENDPOINT) 31 | google = Google.new(client) 32 | 33 | google.search("null").finish 34 | 35 | duration = Async::Clock.measure do 36 | counts = terms.map do |term| 37 | task.async do 38 | response = google.search(term) 39 | [term, response.read.scan(term).count] 40 | end 41 | end.map(&:wait).to_h 42 | 43 | Console.info(self, name: "counts") {counts} 44 | end 45 | 46 | Console.info(self, name: "duration") {duration} 47 | ensure 48 | google.close 49 | end 50 | -------------------------------------------------------------------------------- /examples/header-lowercase/benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "benchmark/ips" 7 | 8 | class NormalizedHeaders 9 | def initialize(fields) 10 | @fields = fields 11 | end 12 | 13 | def [](key) 14 | @fields[key.downcase] 15 | end 16 | end 17 | 18 | class Headers 19 | def initialize(fields) 20 | @fields = fields 21 | end 22 | 23 | def [](key) 24 | @fields[key] 25 | end 26 | end 27 | 28 | FIELDS = { 29 | "content-type" => "text/html", 30 | "content-length" => "127889", 31 | "accept-ranges" => "bytes", 32 | "date" => "Tue, 14 Jul 2015 22:00:02 GMT", 33 | "via" => "1.1 varnish", 34 | "age" => "0", 35 | "connection" => "keep-alive", 36 | "x-served-by" => "cache-iad2125-IAD", 37 | } 38 | 39 | NORMALIZED_HEADERS = NormalizedHeaders.new(FIELDS) 40 | HEADERS = Headers.new(FIELDS) 41 | 42 | Benchmark.ips do |x| 43 | x.report("NormalizedHeaders[Content-Type]") { NORMALIZED_HEADERS["Content-Type"] } 44 | x.report("NormalizedHeaders[content-type]") { NORMALIZED_HEADERS["content-type"] } 45 | x.report("Headers") { HEADERS["content-type"] } 46 | 47 | x.compare! 48 | end 49 | -------------------------------------------------------------------------------- /examples/hello/config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env falcon --verbose serve -c 2 | # frozen_string_literal: true 3 | 4 | require "async" 5 | require "async/barrier" 6 | require "net/http" 7 | require "uri" 8 | 9 | run do |env| 10 | i = 1_000_000 11 | while i > 0 12 | i -= 1 13 | end 14 | 15 | [200, {}, ["Hello World!"]] 16 | end 17 | -------------------------------------------------------------------------------- /examples/hello/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | async-http (0.66.3) 5 | async (>= 2.10.2) 6 | async-pool (>= 0.6.1) 7 | io-endpoint (~> 0.10, >= 0.10.3) 8 | io-stream (~> 0.4) 9 | protocol-http (~> 0.26.0) 10 | protocol-http1 (~> 0.19.0) 11 | protocol-http2 (~> 0.17.0) 12 | traces (>= 0.10.0) 13 | 14 | GEM 15 | remote: https://rubygems.org/ 16 | specs: 17 | async (2.12.0) 18 | console (~> 1.25, >= 1.25.2) 19 | fiber-annotation 20 | io-event (~> 1.6) 21 | async-container (0.18.2) 22 | async (~> 2.10) 23 | async-http-cache (0.4.3) 24 | async-http (~> 0.56) 25 | async-pool (0.6.1) 26 | async (>= 1.25) 27 | async-service (0.12.0) 28 | async 29 | async-container (~> 0.16) 30 | console (1.25.2) 31 | fiber-annotation 32 | fiber-local (~> 1.1) 33 | json 34 | falcon (0.47.6) 35 | async 36 | async-container (~> 0.18) 37 | async-http (~> 0.66, >= 0.66.3) 38 | async-http-cache (~> 0.4.0) 39 | async-service (~> 0.10) 40 | bundler 41 | localhost (~> 1.1) 42 | openssl (~> 3.0) 43 | process-metrics (~> 0.2.0) 44 | protocol-rack (~> 0.5) 45 | samovar (~> 2.3) 46 | fiber-annotation (0.2.0) 47 | fiber-local (1.1.0) 48 | fiber-storage 49 | fiber-storage (0.1.1) 50 | io-endpoint (0.10.3) 51 | io-event (1.6.0) 52 | io-stream (0.4.0) 53 | json (2.7.2) 54 | localhost (1.3.1) 55 | mapping (1.1.1) 56 | openssl (3.2.0) 57 | process-metrics (0.2.1) 58 | console (~> 1.8) 59 | samovar (~> 2.1) 60 | protocol-hpack (1.4.3) 61 | protocol-http (0.26.5) 62 | protocol-http1 (0.19.1) 63 | protocol-http (~> 0.22) 64 | protocol-http2 (0.17.0) 65 | protocol-hpack (~> 1.4) 66 | protocol-http (~> 0.18) 67 | protocol-rack (0.5.1) 68 | protocol-http (~> 0.23) 69 | rack (>= 1.0) 70 | rack (3.0.11) 71 | samovar (2.3.0) 72 | console (~> 1.0) 73 | mapping (~> 1.0) 74 | traces (0.11.1) 75 | 76 | PLATFORMS 77 | arm64-darwin-23 78 | ruby 79 | 80 | DEPENDENCIES 81 | async-http! 82 | falcon 83 | 84 | BUNDLED WITH 85 | 2.5.9 86 | -------------------------------------------------------------------------------- /examples/hello/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-http", path: "../../" 9 | gem "falcon" 10 | -------------------------------------------------------------------------------- /examples/hello/readme.md: -------------------------------------------------------------------------------- 1 | # Hello Example 2 | 3 | ## Server 4 | 5 | ```bash 6 | $ bundle update 7 | $ bundle exec falcon serve --bind http://localhost:3000 8 | ``` 9 | 10 | ## Client 11 | 12 | ### HTTP/1 13 | 14 | ```bash 15 | $ curl -v http://localhost:3000 16 | * Host localhost:3000 was resolved. 17 | * IPv6: ::1 18 | * IPv4: 127.0.0.1 19 | * Trying [::1]:3000... 20 | * Connected to localhost (::1) port 3000 21 | > GET / HTTP/1.1 22 | > Host: localhost:3000 23 | > User-Agent: curl/8.7.1 24 | > Accept: */* 25 | > 26 | * Request completely sent off 27 | < HTTP/1.1 200 OK 28 | < vary: accept-encoding 29 | < content-length: 12 30 | < 31 | * Connection #0 to host localhost left intact 32 | Hello World!⏎ 33 | ``` 34 | 35 | ### HTTP/2 36 | 37 | ```bash 38 | $ curl -v --http2-prior-knowledge http://localhost:3000 39 | * Host localhost:3000 was resolved. 40 | * IPv6: ::1 41 | * IPv4: 127.0.0.1 42 | * Trying [::1]:3000... 43 | * Connected to localhost (::1) port 3000 44 | * [HTTP/2] [1] OPENED stream for http://localhost:3000/ 45 | * [HTTP/2] [1] [:method: GET] 46 | * [HTTP/2] [1] [:scheme: http] 47 | * [HTTP/2] [1] [:authority: localhost:3000] 48 | * [HTTP/2] [1] [:path: /] 49 | * [HTTP/2] [1] [user-agent: curl/8.7.1] 50 | * [HTTP/2] [1] [accept: */*] 51 | > GET / HTTP/2 52 | > Host: localhost:3000 53 | > User-Agent: curl/8.7.1 54 | > Accept: */* 55 | > 56 | * Request completely sent off 57 | < HTTP/2 200 58 | < content-length: 12 59 | < vary: accept-encoding 60 | < 61 | * Connection #0 to host localhost left intact 62 | Hello World!⏎ 63 | ``` 64 | -------------------------------------------------------------------------------- /examples/licenses/gemspect.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | require "csv" 8 | require "json" 9 | require "net/http" 10 | 11 | require "protocol/http/header/authorization" 12 | 13 | class RateLimitingError < StandardError; end 14 | 15 | @user = ENV["GITHUB_USER"] 16 | @token = ENV["GITHUB_TOKEN"] 17 | 18 | unless @user && @token 19 | fail "export GITHUB_USER and GITHUB_TOKEN!" 20 | end 21 | 22 | def fetch_github_license(homepage_uri) 23 | %r{github.com/(?.+?)/(?.+)} =~ homepage_uri 24 | return nil unless repo 25 | 26 | url = URI.parse("https://api.github.com/repos/#{owner}/#{repo}/license") 27 | request = Net::HTTP::Get.new(url) 28 | 29 | request["user-agent"] = "fetch-github-licenses" 30 | request["authorization"] = Protocol::HTTP::Header::Authorization.basic(@user, @token) 31 | 32 | response = Net::HTTP.start(url.hostname) do |http| 33 | http.request(request) 34 | end 35 | 36 | case response 37 | when Net::HTTPOK 38 | JSON.parse(response.body).dig("license", "spdx_id") 39 | when Net::HTTPNotFound, Net::HTTPMovedPermanently, Net::HTTPForbidden 40 | nil 41 | else 42 | raise response.body 43 | end 44 | end 45 | 46 | def fetch_rubygem_license(name, version) 47 | url = URI.parse("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json") 48 | response = Net::HTTP.get_response(url) 49 | 50 | case response 51 | when Net::HTTPOK 52 | body = JSON.parse(response.body) 53 | [name, body.dig("licenses", 0) || fetch_github_license(body["homepage_uri"])] 54 | when Net::HTTPNotFound 55 | [name, nil] # from a non rubygems remote 56 | when Net::HTTPTooManyRequests 57 | raise RateLimitingError 58 | else 59 | raise response.body 60 | end 61 | rescue RateLimitingError 62 | sleep 1 63 | 64 | retry 65 | end 66 | 67 | threads = ARGF.map do |line| 68 | if line == "GEM\n" .. line.chomp.empty? 69 | /\A\s{4}(?[a-z].+?) \((?.+)\)\n\z/ =~ line 70 | 71 | Thread.new { fetch_rubygem_license(name, version) } if name 72 | end 73 | end.compact 74 | 75 | puts CSV.generate { |csv| threads.each { csv << _1.value } } 76 | -------------------------------------------------------------------------------- /examples/licenses/list.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2025, by Samuel Williams. 6 | 7 | require "csv" 8 | require "json" 9 | require "async/http/internet" 10 | 11 | class RateLimitingError < StandardError; end 12 | 13 | @internet = Async::HTTP::Internet.new 14 | 15 | @user = ENV["GITHUB_USER"] 16 | @token = ENV["GITHUB_TOKEN"] 17 | 18 | unless @user && @token 19 | fail "export GITHUB_USER and GITHUB_TOKEN!" 20 | end 21 | 22 | GITHUB_HEADERS = { 23 | "user-agent" => "fetch-github-licenses", 24 | "authorization" => Protocol::HTTP::Header::Authorization.basic(@user, @token) 25 | } 26 | 27 | RUBYGEMS_HEADERS = { 28 | "user-agent" => "fetch-github-licenses" 29 | } 30 | 31 | def fetch_github_license(homepage_uri) 32 | %r{github.com/(?.+?)/(?.+)} =~ homepage_uri 33 | return nil unless repo 34 | 35 | response = @internet.get("https://api.github.com/repos/#{owner}/#{repo}/license", GITHUB_HEADERS) 36 | 37 | case response.status 38 | when 200 39 | return JSON.parse(response.read).dig("license", "spdx_id") 40 | when 404 41 | return nil 42 | else 43 | raise response.read 44 | end 45 | ensure 46 | response.finish 47 | end 48 | 49 | def fetch_rubygem_license(name, version) 50 | response = @internet.get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json", RUBYGEMS_HEADERS) 51 | 52 | case response.status 53 | when 200 54 | body = JSON.parse(response.read) 55 | [name, body.dig("licenses", 0) || fetch_github_license(body["homepage_uri"])] 56 | when 404 57 | [name, nil] # from a non rubygems remote 58 | when 429 59 | raise RateLimitingError 60 | else 61 | raise response.read 62 | end 63 | rescue RateLimitingError 64 | response.finish 65 | 66 | Console.warn(name) {"Rate limited..."} 67 | Async::Task.current.sleep(1.0) 68 | 69 | retry 70 | ensure 71 | response.finish 72 | end 73 | 74 | Sync do |parent| 75 | output = CSV.new($stdout) 76 | 77 | tasks = ARGF.map do |line| 78 | if line == "GEM\n" .. line.chomp.empty? 79 | /\A\s{4}(?[a-z].+?) \((?.+)\)\n\z/ =~ line 80 | 81 | parent.async do 82 | fetch_rubygem_license(name, version) 83 | end if name 84 | end 85 | end.compact 86 | 87 | tasks.each do |task| 88 | output << task.wait 89 | end 90 | 91 | @internet.instance_variable_get(:@clients).each do |name, client| 92 | puts client.pool 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /examples/race/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2021-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require_relative "../../lib/async/http/internet" 9 | 10 | Console.logger.fatal! 11 | 12 | Async do |task| 13 | internet = Async::HTTP::Internet.new 14 | tasks = [] 15 | 16 | 100.times do 17 | tasks << task.async { 18 | loop do 19 | response = internet.get("http://127.0.0.1:8080/something/special") 20 | r = response.body.join 21 | if r.include?("nothing") 22 | p ["something", r] 23 | end 24 | end 25 | } 26 | end 27 | 28 | 100.times do 29 | tasks << task.async { 30 | loop do 31 | response = internet.get("http://127.0.0.1:8080/nothing/to/worry") 32 | r = response.body.join 33 | if r.include?("something") 34 | p ["nothing", r] 35 | end 36 | end 37 | } 38 | end 39 | 40 | tasks.each do |t| 41 | sleep 0.1 42 | t.stop 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /examples/race/server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2021-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/server" 9 | require "async/http/endpoint" 10 | require "async/http/protocol/response" 11 | 12 | endpoint = Async::HTTP::Endpoint.parse("http://127.0.0.1:8080") 13 | 14 | app = lambda do |request| 15 | Protocol::HTTP::Response[200, {}, [request.path[1..-1]]] 16 | end 17 | 18 | server = Async::HTTP::Server.new(app, endpoint) 19 | 20 | Async do |task| 21 | server.run 22 | end 23 | -------------------------------------------------------------------------------- /examples/request.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2018-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/client" 9 | require "async/http/endpoint" 10 | 11 | # Console.logger.level = Logger::DEBUG 12 | 13 | Async do |task| 14 | endpoint = Async::HTTP::Endpoint.parse("https://www.google.com") 15 | 16 | client = Async::HTTP::Client.new(endpoint) 17 | 18 | headers = { 19 | "accept" => "text/html", 20 | } 21 | 22 | request = Protocol::HTTP::Request.new(client.scheme, "www.google.com", "GET", "/search?q=cats", headers) 23 | 24 | puts "Sending request..." 25 | response = client.call(request) 26 | 27 | puts "Reading response status=#{response.status}..." 28 | 29 | if body = response.body 30 | while chunk = body.read 31 | puts chunk.size 32 | end 33 | end 34 | 35 | response.close 36 | 37 | puts "Finish reading response." 38 | end 39 | -------------------------------------------------------------------------------- /examples/request/http10.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require_relative "../../lib/async/http/endpoint" 9 | require "../../lib/async/http/client" 10 | 11 | Async do 12 | endpoint = Async::HTTP::Endpoint.parse("https://programming.dojo.net.nz", protocol: Async::HTTP::Protocol::HTTP10) 13 | client = Async::HTTP::Client.new(endpoint) 14 | 15 | response = client.get("programming.dojo.net.nz") 16 | puts response, response.read 17 | end 18 | -------------------------------------------------------------------------------- /examples/stream/stop.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2025, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/internet" 9 | 10 | Async do |parent| 11 | internet = Async::HTTP::Internet.new 12 | connection = nil 13 | 14 | child = parent.async do 15 | response = internet.get("https://utopia-falcon-heroku.herokuapp.com/beer/index") 16 | connection = response.connection 17 | 18 | response.each do |chunk| 19 | Console.info(response) {chunk} 20 | end 21 | ensure 22 | Console.info(response) {"Closing response..."} 23 | response&.close 24 | end 25 | 26 | parent.sleep(5) 27 | 28 | Console.info(parent) {"Killing #{child}..."} 29 | child.stop 30 | ensure 31 | internet&.close 32 | end 33 | -------------------------------------------------------------------------------- /examples/trenni/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "trenni" 6 | gem "async-http" 7 | -------------------------------------------------------------------------------- /examples/trenni/streaming.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "trenni/template" 7 | 8 | require "async" 9 | require "async/http/body/writable" 10 | 11 | # The template, using inline text. The sleep could be anything - database query, HTTP request, redis, etc. 12 | buffer = Trenni::Buffer.new(<<-EOF) 13 | The "\#{self[:count]} bottles of \#{self[:drink]} on the wall" song! 14 | 15 | 16 | \#{index} bottles of \#{self[:drink]} on the wall, 17 | \#{index} bottles of \#{self[:drink]}, 18 | take one down, and pass it around, 19 | \#{index - 1} bottles of \#{self[:drink]} on the wall. 20 | 21 | 22 | 23 | EOF 24 | 25 | template = Trenni::Template.new(buffer) 26 | 27 | Async do 28 | body = Async::HTTP::Body::Writable.new 29 | 30 | generator = Async do 31 | template.to_string({count: 100, drink: "coffee"}, body) 32 | end 33 | 34 | while chunk = body.read 35 | $stdout.write chunk 36 | end 37 | 38 | generator.wait 39 | end.wait 40 | -------------------------------------------------------------------------------- /examples/upload/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2018-2024, by Samuel Williams. 6 | # Copyright, 2020, by Bruno Sutic. 7 | 8 | $LOAD_PATH.unshift File.expand_path("../../lib", __dir__) 9 | 10 | require "async" 11 | require "protocol/http/body/file" 12 | require "async/http/client" 13 | require "async/http/endpoint" 14 | 15 | class Delayed < ::Protocol::HTTP::Body::Wrapper 16 | def initialize(body, delay = 0.01) 17 | super(body) 18 | 19 | @delay = delay 20 | end 21 | 22 | def ready? 23 | false 24 | end 25 | 26 | def read 27 | sleep(@delay) 28 | 29 | return super 30 | end 31 | end 32 | 33 | Async do 34 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:9222") 35 | client = Async::HTTP::Client.new(endpoint, protocol: Async::HTTP::Protocol::HTTP2) 36 | 37 | headers = [ 38 | ["accept", "text/plain"], 39 | ] 40 | 41 | body = Delayed.new(Protocol::HTTP::Body::File.open(File.join(__dir__, "data.txt"), block_size: 32)) 42 | 43 | response = client.post(endpoint.path, headers, body) 44 | 45 | puts response.status 46 | 47 | # response.read -> string 48 | # response.each {|chunk| ...} 49 | # response.close (forcefully ignore data) 50 | # body = response.finish (read and buffer response) 51 | # response.save("echo.txt") 52 | 53 | response.each do |chunk| 54 | puts chunk.inspect 55 | end 56 | 57 | ensure 58 | client.close if client 59 | end 60 | 61 | puts "Done." 62 | -------------------------------------------------------------------------------- /examples/upload/data.txt: -------------------------------------------------------------------------------- 1 | The Parable of the Two Programmers 2 | 3 | Neil W. Rickert 4 | 5 | Once upon a time, unbeknownst to each other, the "Automated Accounting Applications Association" and the "Consolidated Computerized Capital Corporation" decided that they needed the identical program to perform a certain service. 6 | 7 | Automated hired a programmer-analyst, Alan, to solve their problem. 8 | 9 | Meanwhile, Consolidated decided to ask a newly-hired entry-level programmer, Charles, to tackle the job, to see if he was as good as he pretended. 10 | 11 | Alan, having had experience in difficult programming projects, decided to use the PQR structured design methodology. With this in mind he asked his department manager to assign another three programmers as a programming team. Then the team went to work, churning out preliminary reports and problem analyses. 12 | 13 | Back at Consolidated, Charles spent some time thinking about the problem. His fellow employees noticed that Charles often sat with his feet on the desk, drinking coffee. He was occasionally seen at his computer terminal, but his office mate could tell from the rhythmic striking of keys that he was actually playing Space Invaders. 14 | 15 | By now, the team at Automated was starting to write code. The programmers were spending about half their time writing and compiling code, and the rest of their time in conference, discussing the interfaces between the various modules. 16 | 17 | His office mate noticed that Charles had finally given up on Space Invaders. Instead he now divided his time between drinking coffee with his feet on the table, and scribbling on little scraps of paper. His scribbling didn't seem to be Tic-Tac-Toe, but it didn't exactly make much sense, either. 18 | 19 | Two months have gone by. The team at Automated finally releases an implementation timetable. In another two months they will have a test version of the program. Then a two month period of testing and enhancing should yield a completed version. 20 | 21 | The manager of Charles has by now tired of seeing him goof off. He decides to confront him. But as he walks into Charles' office, he is surprised to see Charles busy entering code at his terminal. He decides to postpone the confrontation, so makes some small talk and then leaves. However, he begins to keep a closer watch on Charles, so that when the opportunity presents itself he can confront him. Not looking forward to an unpleasant conversation, he is pleased to notice that Charles seems to be busy most of the time. He has even been seen to delay his lunch, and to stay after work two or three days a week. 22 | 23 | At the end of three months, Charles announces he has completed the project. He submits a 500-line program. The program appears to be clearly written, and when tested it does everything required in the specifications. In fact, it even has a few additional convenience features which might significantly improve the usability of the program. The program is put into test, and except for one quickly corrected oversight, performs well. 24 | 25 | The team at Automated has by now completed two of the four major modules required for their program. These modules are now undergoing testing while the other modules are completed. 26 | 27 | After another three weeks, Alan announces that the preliminary version is ready one week ahead of schedule. He supplies a list of the deficiencies that he expects to correct. The program is placed under test. The users find a number of bugs and deficiencies other than those listed. As Alan explains, this is no surprise. After all, this is a preliminary version in which bugs were expected. 28 | 29 | After about two more months, the team has completed its production version of the program. It consists of about 2,500 lines of code. When tested, it seems to satisfy most of the original specifications. It has omitted one or two features, and is very fussy about the format of its input data. However, the company decides to install the program. They can always train their data-entry staff to enter data in the strict format required. The program is handed over to some maintenance programmers to eventually incorporate the missing features. 30 | 31 | Sequel 32 | 33 | At first Charles' supervisor was impressed. But as he read through the source code, he realized that the project was really much simpler than he had originally thought. It now seemed apparent that this was not much of a challenge even for a beginning programmer. 34 | 35 | Charles did produce about five lines of code per day. This is perhaps a little above average. However, considering the simplicity of the program, it was nothing exceptional. Also, his supervisor remembered his two months of goofing off. 36 | 37 | At his next salary review Charles was given a raise which was about half the inflation over the period. He was not given a promotion. After about a year he became discouraged and left Consolidated. 38 | 39 | At Automated, Alan was complimented for completing his project on schedule. His supervisor looked over the program. Within a few minutes of thumbing through he saw that the company standards about structured programming were being observed. He quickly gave up attempting to read the program; however, it seemed quite incomprehensible. He realized by now that the project was really much more complex than he had originally assumed, and he congratulated Alan again on his achievement. 40 | 41 | The team had produced over three lines of code per programmer per day. This was about average, but considering the complexity of the problem, could be considered to be exceptional. Alan was given a hefty pay raise, and promoted to Systems Analyst as a reward for his achievement. -------------------------------------------------------------------------------- /examples/upload/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2020, by Bruno Sutic. 6 | 7 | $LOAD_PATH.unshift File.expand_path("../../lib", __dir__) 8 | 9 | require "logger" 10 | 11 | require "async" 12 | require "async/http/server" 13 | require "async/http/endpoint" 14 | 15 | protocol = Async::HTTP::Protocol::HTTP2 16 | endpoint = Async::HTTP::Endpoint.parse("http://127.0.0.1:9222", reuse_port: true) 17 | 18 | Console.logger.level = Logger::DEBUG 19 | 20 | Async do 21 | server = Async::HTTP::Server.for(endpoint, protocol: protocol) do |request| 22 | Protocol::HTTP::Response[200, {}, request.body] 23 | end 24 | 25 | server.run 26 | end 27 | -------------------------------------------------------------------------------- /examples/upload/upload.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2018-2024, by Samuel Williams. 6 | # Copyright, 2020, by Bruno Sutic. 7 | 8 | require "async" 9 | require "protocol/http/body/file" 10 | require "async/http/internet" 11 | 12 | Async do 13 | internet = Async::HTTP::Internet.new 14 | 15 | headers = [ 16 | ["accept", "text/plain"], 17 | ] 18 | 19 | body = Protocol::HTTP::Body::File.open(File.join(__dir__, "data.txt")) 20 | 21 | response = internet.post("https://utopia-falcon-heroku.herokuapp.com/echo/index", headers, body) 22 | 23 | # response.read -> string 24 | # response.each {|chunk| ...} 25 | # response.close (forcefully ignore data) 26 | # body = response.finish (read and buffer response) 27 | response.save("echo.txt") 28 | 29 | ensure 30 | internet.close 31 | end 32 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | # gem "async", path: "../async" 11 | # gem "async-io", path: "../async-io" 12 | # gem "io-endpoint", path: "../io-endpoint" 13 | # gem "io-stream", path: "../io-stream" 14 | # gem "openssl", git: "https://github.com/ruby/openssl.git" 15 | # gem "traces", path: "../traces" 16 | # gem "sus-fixtures-async-http", path: "../sus-fixtures-async-http" 17 | 18 | # gem "protocol-http", path: "../protocol-http" 19 | # gem "protocol-http1", path: "../protocol-http1" 20 | # gem "protocol-http2", path: "../protocol-http2" 21 | # gem "protocol-hpack", path: "../protocol-hpack" 22 | 23 | group :maintenance, optional: true do 24 | gem "bake-modernize" 25 | gem "bake-gem" 26 | 27 | gem "utopia-project" 28 | gem "bake-releases" 29 | end 30 | 31 | group :test do 32 | gem "sus" 33 | gem "covered" 34 | gem "decode" 35 | gem "rubocop" 36 | 37 | gem "sus-fixtures-async" 38 | gem "sus-fixtures-async-http", "~> 0.8" 39 | gem "sus-fixtures-openssl" 40 | 41 | gem "bake-test" 42 | gem "bake-test-external" 43 | 44 | gem "async-container", "~> 0.14" 45 | 46 | gem "localhost" 47 | gem "rack-test" 48 | end 49 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to get started with `Async::HTTP`. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add async-http 11 | ~~~ 12 | 13 | ## Core Concepts 14 | 15 | - {ruby Async::HTTP::Client} is the main class for making HTTP requests. 16 | - {ruby Async::HTTP::Internet} provides a simple interface for making requests to any server "on the internet". 17 | - {ruby Async::HTTP::Server} is the main class for handling HTTP requests. 18 | - {ruby Async::HTTP::Endpoint} can parse HTTP URLs in order to create a client or server. 19 | - [`protocol-http`](https://github.com/socketry/protocol-http) provides the abstract HTTP protocol interfaces. 20 | 21 | ## Usage 22 | 23 | ### Making a Request 24 | 25 | To make a request, use {ruby Async::HTTP::Internet} and call the appropriate method: 26 | 27 | ~~~ ruby 28 | require 'async/http/internet/instance' 29 | 30 | Sync do 31 | Async::HTTP::Internet.get("https://httpbin.org/get") do |response| 32 | puts response.read 33 | end 34 | end 35 | ~~~ 36 | 37 | The following methods are supported: 38 | 39 | ~~~ ruby 40 | Async::HTTP::Internet.methods(false) 41 | # => [:patch, :options, :connect, :post, :get, :delete, :head, :trace, :put] 42 | ~~~ 43 | 44 | Using a block will automatically close the response when the block completes. If you want to keep the response open, you can manage it manually: 45 | 46 | ~~~ ruby 47 | require 'async/http/internet/instance' 48 | 49 | Sync do 50 | response = Async::HTTP::Internet.get("https://httpbin.org/get") 51 | puts response.read 52 | ensure 53 | response&.close 54 | end 55 | ~~~ 56 | 57 | As responses are streamed, you must ensure it is closed when you are finished with it. 58 | 59 | #### Persistence 60 | 61 | By default, {ruby Async::HTTP::Internet} will create a {ruby Async::HTTP::Client} for each remote host you communicate with, and will keep those connections open for as long as possible. This is useful for reducing the latency of subsequent requests to the same host. When you exit the event loop, the connections will be closed automatically. 62 | 63 | ### Downloading a File 64 | 65 | ~~~ ruby 66 | require 'async/http/internet/instance' 67 | 68 | Sync do 69 | # Issue a GET request to Google: 70 | response = Async::HTTP::Internet.get("https://www.google.com/search?q=kittens") 71 | 72 | # Save the response body to a local file: 73 | response.save("/tmp/search.html") 74 | ensure 75 | response&.close 76 | end 77 | ~~~ 78 | 79 | ### Posting Data 80 | 81 | To post data, use the `post` method: 82 | 83 | ~~~ ruby 84 | require 'async/http/internet/instance' 85 | 86 | data = {'life' => 42} 87 | 88 | Sync do 89 | # Prepare the request: 90 | headers = [['accept', 'application/json']] 91 | body = JSON.dump(data) 92 | 93 | # Issues a POST request: 94 | response = Async::HTTP::Internet.post("https://httpbin.org/anything", headers, body) 95 | 96 | # Save the response body to a local file: 97 | pp JSON.parse(response.read) 98 | ensure 99 | response&.close 100 | end 101 | ~~~ 102 | 103 | For more complex scenarios, including HTTP APIs, consider using [async-rest](https://github.com/socketry/async-rest) instead. 104 | 105 | ### Timeouts 106 | 107 | To set a timeout for a request, use the `Task#with_timeout` method: 108 | 109 | ~~~ ruby 110 | require 'async/http/internet/instance' 111 | 112 | Sync do |task| 113 | # Request will timeout after 2 seconds 114 | task.with_timeout(2) do 115 | response = Async::HTTP::Internet.get "https://httpbin.org/delay/10" 116 | ensure 117 | response&.close 118 | end 119 | rescue Async::TimeoutError 120 | puts "The request timed out" 121 | end 122 | ~~~ 123 | 124 | ### Making a Server 125 | 126 | To create a server, use an instance of {ruby Async::HTTP::Server}: 127 | 128 | ~~~ ruby 129 | require 'async/http' 130 | 131 | endpoint = Async::HTTP::Endpoint.parse('http://localhost:9292') 132 | 133 | Sync do |task| 134 | Async(transient: true) do 135 | server = Async::HTTP::Server.for(endpoint) do |request| 136 | ::Protocol::HTTP::Response[200, {}, ["Hello World"]] 137 | end 138 | 139 | server.run 140 | end 141 | 142 | client = Async::HTTP::Client.new(endpoint) 143 | response = client.get("/") 144 | puts response.read 145 | ensure 146 | response&.close 147 | end 148 | ~~~ 149 | -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 0 3 | testing: 4 | order: 1 5 | -------------------------------------------------------------------------------- /guides/testing/readme.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | This guide explains how to use `Async::HTTP` clients and servers in your tests. 4 | 5 | In general, you should avoid making real HTTP requests in your tests. Instead, you should use a mock server or a fake client. 6 | 7 | ## Mocking HTTP Responses 8 | 9 | The mocking feature of `Async::HTTP` uses a real server running in a separate task, and routes all requests to it. This allows you to intercept requests and return custom responses, but still use the real HTTP client. 10 | 11 | In order to enable this feature, you must create an instance of {ruby Async::HTTP::Mock::Endpoint} which will handle the requests. 12 | 13 | ~~~ ruby 14 | require 'async/http' 15 | require 'async/http/mock' 16 | 17 | mock_endpoint = Async::HTTP::Mock::Endpoint.new 18 | 19 | Sync do 20 | # Start a background server: 21 | server_task = Async(transient: true) do 22 | mock_endpoint.run do |request| 23 | # Respond to the request: 24 | ::Protocol::HTTP::Response[200, {}, ["Hello, World"]] 25 | end 26 | end 27 | 28 | endpoint = Async::HTTP::Endpoint.parse("https://www.google.com") 29 | mocked_endpoint = mock_endpoint.wrap(endpoint) 30 | client = Async::HTTP::Client.new(mocked_endpoint) 31 | 32 | response = client.get("/") 33 | puts response.read 34 | # => "Hello, World" 35 | end 36 | ~~~ 37 | 38 | ## Transparent Mocking 39 | 40 | Using your test framework's mocking capabilities, you can easily replace the `Async::HTTP::Client#new` with a method that returns a client with a mocked endpoint. 41 | 42 | ### Sus Integration 43 | 44 | ~~~ ruby 45 | require 'async/http' 46 | require 'async/http/mock' 47 | require 'sus/fixtures/async/reactor_context' 48 | 49 | include Sus::Fixtures::Async::ReactorContext 50 | 51 | let(:mock_endpoint) {Async::HTTP::Mock::Endpoint.new} 52 | 53 | def before 54 | super 55 | 56 | # Mock the HTTP client: 57 | mock(Async::HTTP::Client) do |mock| 58 | mock.wrap(:new) do |original, endpoint| 59 | original.call(mock_endpoint.wrap(endpoint)) 60 | end 61 | end 62 | 63 | # Run the mock server: 64 | Async(transient: true) do 65 | mock_endpoint.run do |request| 66 | ::Protocol::HTTP::Response[200, {}, ["Hello, World"]] 67 | end 68 | end 69 | end 70 | 71 | it "should perform a web request" do 72 | client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("https://www.google.com")) 73 | response = client.get("/") 74 | # The response is mocked: 75 | expect(response.read).to be == "Hello, World" 76 | end 77 | ~~~ 78 | -------------------------------------------------------------------------------- /lib/async/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require_relative "http/version" 7 | 8 | require_relative "http/client" 9 | require_relative "http/server" 10 | 11 | require_relative "http/internet" 12 | 13 | require_relative "http/endpoint" 14 | -------------------------------------------------------------------------------- /lib/async/http/body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "protocol/http/body/buffered" 7 | require_relative "body/writable" 8 | 9 | module Async 10 | module HTTP 11 | module Body 12 | include ::Protocol::HTTP::Body 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/async/http/body/hijack.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/readable" 7 | require "protocol/http/body/stream" 8 | 9 | require_relative "writable" 10 | 11 | module Async 12 | module HTTP 13 | module Body 14 | # A body which is designed for hijacked server responses - a response which uses a block to read and write the request and response bodies respectively. 15 | class Hijack < ::Protocol::HTTP::Body::Readable 16 | def self.response(request, status, headers, &block) 17 | ::Protocol::HTTP::Response[status, headers, self.wrap(request, &block)] 18 | end 19 | 20 | def self.wrap(request = nil, &block) 21 | self.new(block, request&.body) 22 | end 23 | 24 | def initialize(block, input = nil) 25 | @block = block 26 | @input = input 27 | 28 | @task = nil 29 | @stream = nil 30 | @output = nil 31 | end 32 | 33 | # We prefer streaming directly as it's the lowest overhead. 34 | def stream? 35 | true 36 | end 37 | 38 | def call(stream) 39 | @block.call(stream) 40 | end 41 | 42 | attr :input 43 | 44 | # Has the producer called #finish and has the reader consumed the nil token? 45 | def empty? 46 | @output&.empty? 47 | end 48 | 49 | def ready? 50 | @output&.ready? 51 | end 52 | 53 | # Read the next available chunk. 54 | def read 55 | unless @output 56 | @output = Writable.new 57 | @stream = ::Protocol::HTTP::Body::Stream.new(@input, @output) 58 | 59 | @task = Task.current.async do |task| 60 | task.annotate "Streaming hijacked body." 61 | 62 | @block.call(@stream) 63 | end 64 | end 65 | 66 | return @output.read 67 | end 68 | 69 | def inspect 70 | "\#<#{self.class} #{@block.inspect}>" 71 | end 72 | 73 | def to_s 74 | "" 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/async/http/body/pipe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | # Copyright, 2020, by Bruno Sutic. 6 | 7 | require_relative "writable" 8 | 9 | module Async 10 | module HTTP 11 | module Body 12 | class Pipe 13 | # If the input stream is closed first, it's likely the output stream will also be closed. 14 | def initialize(input, output = Writable.new, task: Task.current) 15 | @input = input 16 | @output = output 17 | 18 | head, tail = ::Socket.pair(Socket::AF_UNIX, Socket::SOCK_STREAM) 19 | 20 | @head = ::IO::Stream(head) 21 | @tail = tail 22 | 23 | @reader = nil 24 | @writer = nil 25 | 26 | task.async(transient: true, &self.method(:reader)) 27 | task.async(transient: true, &self.method(:writer)) 28 | end 29 | 30 | def to_io 31 | @tail 32 | end 33 | 34 | def close 35 | @reader&.stop 36 | @writer&.stop 37 | 38 | @tail.close 39 | end 40 | 41 | private 42 | 43 | # Read from the @input stream and write to the head of the pipe. 44 | def reader(task) 45 | @reader = task 46 | 47 | task.annotate "#{self.class} reader." 48 | 49 | while chunk = @input.read 50 | @head.write(chunk) 51 | @head.flush 52 | end 53 | 54 | @head.close_write 55 | rescue => error 56 | raise 57 | ensure 58 | @input.close(error) 59 | 60 | close_head if @writer&.finished? 61 | end 62 | 63 | # Read from the head of the pipe and write to the @output stream. 64 | # If the @tail is closed, this will cause chunk to be nil, which in turn will call `@output.close` and `@head.close` 65 | def writer(task) 66 | @writer = task 67 | 68 | task.annotate "#{self.class} writer." 69 | 70 | while chunk = @head.read_partial 71 | @output.write(chunk) 72 | end 73 | rescue => error 74 | raise 75 | ensure 76 | @output.close_write(error) 77 | 78 | close_head if @reader&.finished? 79 | end 80 | 81 | def close_head 82 | @head.close 83 | 84 | # Both tasks are done, don't keep references: 85 | @reader = nil 86 | @writer = nil 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/async/http/body/writable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "protocol/http/body/writable" 7 | require "async/queue" 8 | 9 | module Async 10 | module HTTP 11 | module Body 12 | Writable = ::Protocol::HTTP::Body::Writable 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/async/http/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | # Copyright, 2022, by Ian Ker-Seymer. 6 | 7 | require "io/endpoint" 8 | 9 | require "async/pool/controller" 10 | 11 | require "protocol/http/body/completable" 12 | require "protocol/http/methods" 13 | 14 | require "traces/provider" 15 | 16 | require_relative "protocol" 17 | 18 | module Async 19 | module HTTP 20 | DEFAULT_RETRIES = 3 21 | 22 | class Client < ::Protocol::HTTP::Methods 23 | # Provides a robust interface to a server. 24 | # * If there are no connections, it will create one. 25 | # * If there are already connections, it will reuse it. 26 | # * If a request fails, it will retry it up to N times if it was idempotent. 27 | # The client object will never become unusable. It internally manages persistent connections (or non-persistent connections if that's required). 28 | # @param endpoint [Endpoint] the endpoint to connnect to. 29 | # @param protocol [Protocol::HTTP1 | Protocol::HTTP2 | Protocol::HTTPS] the protocol to use. 30 | # @param scheme [String] The default scheme to set to requests. 31 | # @param authority [String] The default authority to set to requests. 32 | def initialize(endpoint, protocol: endpoint.protocol, scheme: endpoint.scheme, authority: endpoint.authority, retries: DEFAULT_RETRIES, **options) 33 | @endpoint = endpoint 34 | @protocol = protocol 35 | 36 | @retries = retries 37 | @pool = make_pool(**options) 38 | 39 | @scheme = scheme 40 | @authority = authority 41 | end 42 | 43 | def as_json(...) 44 | { 45 | endpoint: @endpoint.to_s, 46 | protocol: @protocol, 47 | retries: @retries, 48 | scheme: @scheme, 49 | authority: @authority, 50 | } 51 | end 52 | 53 | def to_json(...) 54 | as_json.to_json(...) 55 | end 56 | 57 | attr :endpoint 58 | attr :protocol 59 | 60 | attr :retries 61 | attr :pool 62 | 63 | attr :scheme 64 | attr :authority 65 | 66 | def secure? 67 | @endpoint.secure? 68 | end 69 | 70 | def self.open(*arguments, **options, &block) 71 | client = self.new(*arguments, **options) 72 | 73 | return client unless block_given? 74 | 75 | begin 76 | yield client 77 | ensure 78 | client.close 79 | end 80 | end 81 | 82 | def close 83 | while @pool.busy? 84 | Console.warn(self) {"Waiting for #{@protocol} pool to drain: #{@pool}"} 85 | @pool.wait 86 | end 87 | 88 | @pool.close 89 | end 90 | 91 | def call(request) 92 | request.scheme ||= self.scheme 93 | request.authority ||= self.authority 94 | 95 | attempt = 0 96 | 97 | # We may retry the request if it is possible to do so. https://tools.ietf.org/html/draft-nottingham-httpbis-retry-01 is a good guide for how retrying requests should work. 98 | begin 99 | attempt += 1 100 | 101 | # As we cache pool, it's possible these pool go bad (e.g. closed by remote host). In this case, we need to try again. It's up to the caller to impose a timeout on this. If this is the last attempt, we force a new connection. 102 | connection = @pool.acquire 103 | 104 | response = make_response(request, connection, attempt) 105 | 106 | # This signals that the ensure block below should not try to release the connection, because it's bound into the response which will be returned: 107 | connection = nil 108 | return response 109 | rescue Protocol::RequestFailed 110 | # This is a specific case where the entire request wasn't sent before a failure occurred. So, we can even resend non-idempotent requests. 111 | if connection 112 | @pool.release(connection) 113 | connection = nil 114 | end 115 | 116 | if attempt < @retries 117 | retry 118 | else 119 | raise 120 | end 121 | rescue SocketError, IOError, EOFError, Errno::ECONNRESET, Errno::EPIPE 122 | if connection 123 | @pool.release(connection) 124 | connection = nil 125 | end 126 | 127 | if request.idempotent? and attempt < @retries 128 | retry 129 | else 130 | raise 131 | end 132 | ensure 133 | if connection 134 | @pool.release(connection) 135 | end 136 | end 137 | end 138 | 139 | def inspect 140 | "#<#{self.class} authority=#{@authority.inspect}>" 141 | end 142 | 143 | protected 144 | 145 | def make_response(request, connection, attempt) 146 | response = request.call(connection) 147 | 148 | response.pool = @pool 149 | 150 | return response 151 | end 152 | 153 | def assign_default_tags(tags) 154 | tags[:endpoint] = @endpoint.to_s 155 | tags[:protocol] = @protocol.to_s 156 | end 157 | 158 | def make_pool(**options) 159 | if connection_limit = options.delete(:connection_limit) 160 | warn "The connection_limit: option is deprecated, please use limit: instead.", uplevel: 2 161 | options[:limit] = connection_limit 162 | end 163 | 164 | self.assign_default_tags(options[:tags] ||= {}) 165 | 166 | Async::Pool::Controller.wrap(**options) do 167 | Console.debug(self) {"Making connection to #{@endpoint.inspect}"} 168 | 169 | @protocol.client(@endpoint.connect) 170 | end 171 | end 172 | 173 | Traces::Provider(self) do 174 | def call(request) 175 | attributes = { 176 | 'http.method': request.method, 177 | 'http.authority': request.authority || self.authority, 178 | 'http.scheme': request.scheme || self.scheme, 179 | 'http.path': request.path, 180 | } 181 | 182 | if protocol = request.protocol 183 | attributes["http.protocol"] = protocol 184 | end 185 | 186 | if length = request.body&.length 187 | attributes["http.request.length"] = length 188 | end 189 | 190 | Traces.trace("async.http.client.call", attributes: attributes) do |span| 191 | if context = Traces.trace_context 192 | request.headers["traceparent"] = context.to_s 193 | # request.headers['tracestate'] = context.state 194 | end 195 | 196 | super.tap do |response| 197 | if version = response&.version 198 | span["http.version"] = version 199 | end 200 | 201 | if status = response&.status 202 | span["http.status_code"] = status 203 | end 204 | 205 | if length = response.body&.length 206 | span["http.response.length"] = length 207 | end 208 | end 209 | end 210 | end 211 | 212 | def make_response(request, connection, attempt) 213 | attributes = { 214 | attempt: attempt, 215 | } 216 | 217 | Traces.trace("async.http.client.make_response", attributes: attributes) do 218 | super 219 | end 220 | end 221 | end 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /lib/async/http/internet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2024, by Igor Sidorov. 6 | 7 | require_relative "client" 8 | require_relative "endpoint" 9 | 10 | require "protocol/http/middleware" 11 | require "protocol/http/body/buffered" 12 | require "protocol/http/accept_encoding" 13 | 14 | module Async 15 | module HTTP 16 | class Internet 17 | def initialize(**options) 18 | @clients = Hash.new 19 | @options = options 20 | end 21 | 22 | # A cache of clients. 23 | # @attribute [Hash(URI, Client)] 24 | attr :clients 25 | 26 | def client_for(endpoint) 27 | key = host_key(endpoint) 28 | 29 | @clients.fetch(key) do 30 | @clients[key] = self.make_client(endpoint) 31 | end 32 | end 33 | 34 | # Make a request to the internet with the given `method` and `url`. 35 | # 36 | # If you provide non-frozen headers, they may be mutated. 37 | # 38 | # @parameter method [String] The request method, e.g. `GET`. 39 | # @parameter url [String] The URL to request, e.g. `https://www.codeotaku.com`. 40 | # @parameter headers [Hash | Protocol::HTTP::Headers] The headers to send with the request. 41 | # @parameter body [String | Protocol::HTTP::Body] The body to send with the request. 42 | def call(verb, url, *arguments, **options, &block) 43 | endpoint = Endpoint[url] 44 | client = self.client_for(endpoint) 45 | 46 | options[:authority] ||= endpoint.authority 47 | options[:scheme] ||= endpoint.scheme 48 | 49 | request = ::Protocol::HTTP::Request[verb, endpoint.path, *arguments, **options] 50 | 51 | response = client.call(request) 52 | 53 | return response unless block_given? 54 | 55 | begin 56 | yield response 57 | ensure 58 | response.close 59 | end 60 | end 61 | 62 | def close 63 | # The order of operations here is to avoid a race condition between iterating over clients (#close may yield) and creating new clients. 64 | clients = @clients.values 65 | @clients.clear 66 | 67 | clients.each(&:close) 68 | end 69 | 70 | ::Protocol::HTTP::Methods.each do |name, verb| 71 | define_method(verb.downcase) do |url, *arguments, **options, &block| 72 | self.call(verb, url, *arguments, **options, &block) 73 | end 74 | end 75 | 76 | protected 77 | 78 | def make_client(endpoint) 79 | ::Protocol::HTTP::AcceptEncoding.new( 80 | Client.new(endpoint, **@options) 81 | ) 82 | end 83 | 84 | def host_key(endpoint) 85 | url = endpoint.url.dup 86 | 87 | url.path = "" 88 | url.fragment = nil 89 | url.query = nil 90 | 91 | return url 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/async/http/internet/instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require_relative "../internet" 7 | 8 | ::Thread.attr_accessor :async_http_internet_instance 9 | 10 | module Async 11 | module HTTP 12 | class Internet 13 | # The global instance of the internet. 14 | def self.instance 15 | ::Thread.current.async_http_internet_instance ||= self.new 16 | end 17 | 18 | class << self 19 | ::Protocol::HTTP::Methods.each do |name, verb| 20 | define_method(verb.downcase) do |url, *arguments, **options, &block| 21 | self.instance.call(verb, url, *arguments, **options, &block) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/async/http/middleware/location_redirector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "../reference" 7 | 8 | require "protocol/http/middleware" 9 | require "protocol/http/body/rewindable" 10 | 11 | module Async 12 | module HTTP 13 | module Middleware 14 | # A client wrapper which transparently handles redirects to a given maximum number of hops. 15 | # 16 | # The default implementation will only follow relative locations (i.e. those without a scheme) and will switch to GET if the original request was not a GET. 17 | # 18 | # The best reference for these semantics is defined by the [Fetch specification](https://fetch.spec.whatwg.org/#http-redirect-fetch). 19 | # 20 | # | Redirect using GET | Permanent | Temporary | 21 | # |:-----------------------------------------:|:---------:|:---------:| 22 | # | Allowed | 301 | 302 | 23 | # | Preserve original method | 308 | 307 | 24 | # 25 | # For the specific details of the redirect handling, see: 26 | # - 301 Moved Permanently. 27 | # - 302 Found. 28 | # - 307 Temporary Redirect. 30 | # 31 | class LocationRedirector < ::Protocol::HTTP::Middleware 32 | class TooManyRedirects < StandardError 33 | end 34 | 35 | # Header keys which should be deleted when changing a request from a POST to a GET as defined by . 36 | PROHIBITED_GET_HEADERS = [ 37 | "content-encoding", 38 | "content-language", 39 | "content-location", 40 | "content-type", 41 | ] 42 | 43 | # maximum_hops is the max number of redirects. Set to 0 to allow 1 request with no redirects. 44 | def initialize(app, maximum_hops = 3) 45 | super(app) 46 | 47 | @maximum_hops = maximum_hops 48 | end 49 | 50 | # The maximum number of hops which will limit the number of redirects until an error is thrown. 51 | attr :maximum_hops 52 | 53 | def redirect_with_get?(request, response) 54 | # We only want to switch to GET if the request method is something other than get, e.g. POST. 55 | if request.method != GET 56 | # According to the RFC, we should only switch to GET if the response is a 301 or 302: 57 | return response.status == 301 || response.status == 302 58 | end 59 | end 60 | 61 | # Handle a redirect to a relative location. 62 | # 63 | # @parameter request [Protocol::HTTP::Request] The original request, which you can modify if you want to handle the redirect. 64 | # @parameter location [String] The relative location to redirect to. 65 | # @returns [Boolean] True if the redirect was handled, false if it was not. 66 | def handle_redirect(request, location) 67 | uri = URI.parse(location) 68 | 69 | if uri.absolute? 70 | return false 71 | end 72 | 73 | # Update the path of the request: 74 | request.path = Reference[request.path] + location 75 | 76 | # Follow the redirect: 77 | return true 78 | end 79 | 80 | def call(request) 81 | # We don't want to follow redirects for HEAD requests: 82 | return super if request.head? 83 | 84 | body = ::Protocol::HTTP::Body::Rewindable.wrap(request) 85 | hops = 0 86 | 87 | while hops <= @maximum_hops 88 | response = super(request) 89 | 90 | if response.redirection? 91 | hops += 1 92 | 93 | # Get the redirect location: 94 | unless location = response.headers["location"] 95 | return response 96 | end 97 | 98 | response.finish 99 | 100 | unless handle_redirect(request, location) 101 | return response 102 | end 103 | 104 | # Ensure the request (body) is finished and set to nil before we manipulate the request: 105 | request.finish 106 | 107 | if request.method == GET or response.preserve_method? 108 | # We (might) need to rewind the body so that it can be submitted again: 109 | body&.rewind 110 | request.body = body 111 | else 112 | # We are changing the method to GET: 113 | request.method = GET 114 | 115 | # We will no longer be submitting the body: 116 | body = nil 117 | 118 | # Remove any headers which are not allowed in a GET request: 119 | PROHIBITED_GET_HEADERS.each do |header| 120 | request.headers.delete(header) 121 | end 122 | end 123 | else 124 | return response 125 | end 126 | end 127 | 128 | raise TooManyRedirects, "Redirected #{hops} times, exceeded maximum!" 129 | end 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/async/http/mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "mock/endpoint" 7 | -------------------------------------------------------------------------------- /lib/async/http/mock/endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "../protocol" 7 | 8 | require "async/queue" 9 | 10 | module Async 11 | module HTTP 12 | module Mock 13 | # This is an endpoint which bridges a client with a local server. 14 | class Endpoint 15 | def initialize(protocol = Protocol::HTTP2, scheme = "http", authority = "localhost", queue: Queue.new) 16 | @protocol = protocol 17 | @scheme = scheme 18 | @authority = authority 19 | 20 | @queue = queue 21 | end 22 | 23 | attr :protocol 24 | attr :scheme 25 | attr :authority 26 | 27 | # Processing incoming connections 28 | # @yield [::HTTP::Protocol::Request] the requests as they come in. 29 | def run(parent: Task.current, &block) 30 | while peer = @queue.dequeue 31 | server = @protocol.server(peer) 32 | 33 | parent.async do 34 | server.each(&block) 35 | end 36 | end 37 | end 38 | 39 | def connect 40 | local, remote = ::Socket.pair(Socket::AF_UNIX, Socket::SOCK_STREAM) 41 | 42 | @queue.enqueue(remote) 43 | 44 | return local 45 | end 46 | 47 | def wrap(endpoint) 48 | self.class.new(@protocol, endpoint.scheme, endpoint.authority, queue: @queue) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/async/http/protocol.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require_relative "protocol/http1" 7 | require_relative "protocol/https" 8 | 9 | module Async 10 | module HTTP 11 | # A protocol specifies a way in which to communicate with a remote peer. 12 | module Protocol 13 | # A protocol must implement the following interface: 14 | # class Protocol 15 | # def client(stream) -> Connection 16 | # def server(stream) -> Connection 17 | # end 18 | 19 | # A connection must implement the following interface: 20 | # class Connection 21 | # def concurrency -> can invoke call 1 or more times simultaneously. 22 | # def reusable? -> can be used again/persistent connection. 23 | 24 | # def viable? -> Boolean 25 | 26 | # def call(request) -> Response 27 | # def each -> (yield(request) -> Response) 28 | # end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/async/http/protocol/configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | module Async 7 | module HTTP 8 | module Protocol 9 | class Configured 10 | def initialize(protocol, **options) 11 | @protocol = protocol 12 | @options = options 13 | end 14 | 15 | # @attribute [Protocol] The underlying protocol. 16 | attr :protocol 17 | 18 | # @attribute [Hash] The options to pass to the protocol. 19 | attr :options 20 | 21 | def client(peer, **options) 22 | options = @options.merge(options) 23 | @protocol.client(peer, **options) 24 | end 25 | 26 | def server(peer, **options) 27 | options = @options.merge(options) 28 | @protocol.server(peer, **options) 29 | end 30 | 31 | def names 32 | @protocol.names 33 | end 34 | end 35 | 36 | module Configurable 37 | def new(**options) 38 | Configured.new(self, **options) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/async/http/protocol/defaulton.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | module Async 7 | module HTTP 8 | module Protocol 9 | # This module provides a default instance of the protocol, which can be used to create clients and servers. The name is a play on "Default" + "Singleton". 10 | module Defaulton 11 | def self.extended(base) 12 | base.instance_variable_set(:@default, base.new) 13 | end 14 | 15 | attr_accessor :default 16 | 17 | # Create a client for an outbound connection, using the default instance. 18 | def client(peer, **options) 19 | default.client(peer, **options) 20 | end 21 | 22 | # Create a server for an inbound connection, using the default instance. 23 | def server(peer, **options) 24 | default.server(peer, **options) 25 | end 26 | 27 | # @returns [Array] The names of the supported protocol, used for Application Layer Protocol Negotiation (ALPN), using the default instance. 28 | def names 29 | default.names 30 | end 31 | end 32 | 33 | private_constant :Defaulton 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Thomas Morgan. 5 | # Copyright, 2024-2025, by Samuel Williams. 6 | 7 | require_relative "defaulton" 8 | 9 | require_relative "http1" 10 | require_relative "http2" 11 | 12 | module Async 13 | module HTTP 14 | module Protocol 15 | # HTTP is an http:// server that auto-selects HTTP/1.1 or HTTP/2 by detecting the HTTP/2 connection preface. 16 | class HTTP 17 | HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" 18 | HTTP2_PREFACE_SIZE = HTTP2_PREFACE.bytesize 19 | 20 | # Create a new HTTP protocol instance. 21 | # 22 | # @parameter http1 [HTTP1] The HTTP/1 protocol instance. 23 | # @parameter http2 [HTTP2] The HTTP/2 protocol instance. 24 | def initialize(http1: HTTP1, http2: HTTP2) 25 | @http1 = http1 26 | @http2 = http2 27 | end 28 | 29 | # Determine if the inbound connection is HTTP/1 or HTTP/2. 30 | # 31 | # @parameter stream [IO::Stream] The stream to detect the protocol for. 32 | # @returns [Class] The protocol class to use. 33 | def protocol_for(stream) 34 | # Detect HTTP/2 connection preface 35 | # https://www.rfc-editor.org/rfc/rfc9113.html#section-3.4 36 | preface = stream.peek do |read_buffer| 37 | if read_buffer.bytesize >= HTTP2_PREFACE_SIZE 38 | break read_buffer[0, HTTP2_PREFACE_SIZE] 39 | elsif read_buffer.bytesize > 0 40 | # If partial read_buffer already doesn't match, no need to wait for more bytes. 41 | break read_buffer unless HTTP2_PREFACE[read_buffer] 42 | end 43 | end 44 | 45 | if preface == HTTP2_PREFACE 46 | @http2 47 | else 48 | @http1 49 | end 50 | end 51 | 52 | # Create a client for an outbound connection. Defaults to HTTP/1 for plaintext connections. 53 | # 54 | # @parameter peer [IO] The peer to communicate with. 55 | # @parameter options [Hash] Options to pass to the protocol, keyed by protocol class. 56 | def client(peer, **options) 57 | options = options[@http1] || {} 58 | 59 | return @http1.client(peer, **options) 60 | end 61 | 62 | # Create a server for an inbound connection. Able to detect HTTP1 and HTTP2. 63 | # 64 | # @parameter peer [IO] The peer to communicate with. 65 | # @parameter options [Hash] Options to pass to the protocol, keyed by protocol class. 66 | def server(peer, **options) 67 | stream = IO::Stream(peer) 68 | protocol = protocol_for(stream) 69 | options = options[protocol] || {} 70 | 71 | return protocol.server(stream, **options) 72 | end 73 | 74 | extend Defaulton 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | # Copyright, 2024, by Thomas Morgan. 6 | 7 | require_relative "configurable" 8 | 9 | require_relative "http1/client" 10 | require_relative "http1/server" 11 | 12 | require "io/stream" 13 | 14 | module Async 15 | module HTTP 16 | module Protocol 17 | module HTTP1 18 | extend Configurable 19 | 20 | VERSION = "HTTP/1.1" 21 | 22 | # @returns [Boolean] Whether the protocol supports bidirectional communication. 23 | def self.bidirectional? 24 | true 25 | end 26 | 27 | # @returns [Boolean] Whether the protocol supports trailers. 28 | def self.trailer? 29 | true 30 | end 31 | 32 | # Create a client for an outbound connection. 33 | # 34 | # @parameter peer [IO] The peer to communicate with. 35 | # @parameter options [Hash] Options to pass to the client instance. 36 | def self.client(peer, **options) 37 | stream = ::IO::Stream(peer) 38 | 39 | return HTTP1::Client.new(stream, VERSION, **options) 40 | end 41 | 42 | # Create a server for an inbound connection. 43 | # 44 | # @parameter peer [IO] The peer to communicate with. 45 | # @parameter options [Hash] Options to pass to the server instance. 46 | def self.server(peer, **options) 47 | stream = ::IO::Stream(peer) 48 | 49 | return HTTP1::Server.new(stream, VERSION, **options) 50 | end 51 | 52 | # @returns [Array] The names of the supported protocol. 53 | def self.names 54 | ["http/1.1", "http/1.0"] 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http1/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require_relative "connection" 7 | 8 | require "traces/provider" 9 | 10 | module Async 11 | module HTTP 12 | module Protocol 13 | module HTTP1 14 | class Client < Connection 15 | def initialize(...) 16 | super 17 | 18 | @pool = nil 19 | end 20 | 21 | attr_accessor :pool 22 | 23 | def closed(error = nil) 24 | super 25 | 26 | if pool = @pool 27 | @pool = nil 28 | # If the connection is not reusable, this will retire it from the connection pool and invoke `#close`. 29 | pool.release(self) 30 | end 31 | end 32 | 33 | # Used by the client to send requests to the remote server. 34 | def call(request, task: Task.current) 35 | # Mark the start of the trailers: 36 | trailer = request.headers.trailer! 37 | 38 | # We carefully interpret https://tools.ietf.org/html/rfc7230#section-6.3.1 to implement this correctly. 39 | begin 40 | target = request.path 41 | authority = request.authority 42 | 43 | # If we are using a CONNECT request, we need to use the authority as the target: 44 | if request.connect? 45 | target = authority 46 | authority = nil 47 | end 48 | 49 | write_request(authority, request.method, target, @version, request.headers) 50 | rescue 51 | # If we fail to fully write the request and body, we can retry this request. 52 | raise RequestFailed 53 | end 54 | 55 | if request.body? 56 | body = request.body 57 | 58 | if protocol = request.protocol 59 | # This is a very tricky apect of handling HTTP/1 upgrade connections. In theory, this approach is a bit inefficient, because we spin up a task just to handle writing to the underlying stream when we could be writing to the stream directly. But we need to maintain some level of compatibility with HTTP/2. Additionally, we don't know if the upgrade request will be accepted, so starting to write the body at this point needs to be handled with care. 60 | task.async(annotation: "Upgrading request...") do 61 | # If this fails, this connection will be closed. 62 | write_upgrade_body(protocol, body) 63 | rescue => error 64 | self.close(error) 65 | end 66 | elsif request.connect? 67 | task.async(annotation: "Tunnneling request...") do 68 | write_tunnel_body(@version, body) 69 | rescue => error 70 | self.close(error) 71 | end 72 | else 73 | task.async(annotation: "Streaming request...") do 74 | # Once we start writing the body, we can't recover if the request fails. That's because the body might be generated dynamically, streaming, etc. 75 | write_body(@version, body, false, trailer) 76 | rescue => error 77 | self.close(error) 78 | end 79 | end 80 | elsif protocol = request.protocol 81 | write_upgrade_body(protocol) 82 | else 83 | write_body(@version, request.body, false, trailer) 84 | end 85 | 86 | return Response.read(self, request) 87 | rescue => error 88 | self.close(error) 89 | raise 90 | end 91 | 92 | Traces::Provider(self) do 93 | def write_request(...) 94 | Traces.trace("async.http.protocol.http1.client.write_request") do 95 | super 96 | end 97 | end 98 | 99 | def read_response(...) 100 | Traces.trace("async.http.protocol.http1.client.read_response") do 101 | super 102 | end 103 | end 104 | end 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http1/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require_relative "request" 7 | require_relative "response" 8 | 9 | require "protocol/http1" 10 | require "protocol/http/peer" 11 | 12 | module Async 13 | module HTTP 14 | module Protocol 15 | module HTTP1 16 | class Connection < ::Protocol::HTTP1::Connection 17 | def initialize(stream, version, **options) 18 | super(stream, **options) 19 | 20 | # On the client side, we need to send the HTTP version with the initial request. On the server side, there are some scenarios (bad request) where we don't know the request version. In those cases, we use this value, which is either hard coded based on the protocol being used, OR could be negotiated during the connection setup (e.g. ALPN). 21 | @version = version 22 | end 23 | 24 | def to_s 25 | "\#<#{self.class} negotiated #{@version}, #{@state}>" 26 | end 27 | 28 | def as_json(...) 29 | to_s 30 | end 31 | 32 | def to_json(...) 33 | as_json.to_json(...) 34 | end 35 | 36 | attr :version 37 | 38 | def http1? 39 | true 40 | end 41 | 42 | def http2? 43 | false 44 | end 45 | 46 | def peer 47 | @peer ||= ::Protocol::HTTP::Peer.for(@stream.io) 48 | end 49 | 50 | attr :count 51 | 52 | def concurrency 53 | 1 54 | end 55 | 56 | # Can we use this connection to make requests? 57 | def viable? 58 | self.idle? && @stream&.readable? 59 | end 60 | 61 | def reusable? 62 | @persistent && @stream && !@stream.closed? 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http1/finishable.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/wrapper" 7 | require "async/variable" 8 | 9 | module Async 10 | module HTTP 11 | module Protocol 12 | module HTTP1 13 | # Keeps track of whether a body is being read, and if so, waits for it to be closed. 14 | class Finishable < ::Protocol::HTTP::Body::Wrapper 15 | def initialize(body) 16 | super(body) 17 | 18 | @closed = Async::Variable.new 19 | @error = nil 20 | 21 | @reading = false 22 | end 23 | 24 | def reading? 25 | @reading 26 | end 27 | 28 | def read 29 | @reading = true 30 | 31 | super 32 | end 33 | 34 | def close(error = nil) 35 | super 36 | 37 | unless @closed.resolved? 38 | @error = error 39 | @closed.value = true 40 | end 41 | end 42 | 43 | def wait(persistent = true) 44 | if @reading 45 | @closed.wait 46 | elsif persistent 47 | # If the connection can be reused, let's gracefully discard the body: 48 | self.discard 49 | else 50 | # Else, we don't care about the body, so we can close it immediately: 51 | self.close 52 | end 53 | end 54 | 55 | def inspect 56 | "#<#{self.class} closed=#{@closed} error=#{@error}> | #{super}" 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http1/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require_relative "../request" 7 | 8 | module Async 9 | module HTTP 10 | module Protocol 11 | module HTTP1 12 | class Request < Protocol::Request 13 | def self.valid_path?(target) 14 | if target.start_with?("/") 15 | return true 16 | elsif target == "*" 17 | return true 18 | else 19 | return false 20 | end 21 | end 22 | 23 | URI_PATTERN = %r{\A(?[^:/]+)://(?[^/]+)(?.*)\z} 24 | 25 | def self.read(connection) 26 | connection.read_request do |authority, method, target, version, headers, body| 27 | if method == ::Protocol::HTTP::Methods::CONNECT 28 | # We put the target into the authority field for CONNECT requests, as per HTTP/2 semantics. 29 | self.new(connection, nil, target, method, nil, version, headers, body) 30 | elsif valid_path?(target) 31 | # This is a valid request. 32 | self.new(connection, nil, authority, method, target, version, headers, body) 33 | elsif match = target.match(URI_PATTERN) 34 | # We map the incoming absolute URI target to the scheme, authority, and path fields of the request. 35 | self.new(connection, match[:scheme], match[:authority], method, match[:path], version, headers, body) 36 | else 37 | # This is an invalid request. 38 | raise ::Protocol::HTTP1::BadRequest.new("Invalid request target: #{target}") 39 | end 40 | end 41 | end 42 | 43 | UPGRADE = "upgrade" 44 | 45 | def initialize(connection, scheme, authority, method, path, version, headers, body) 46 | @connection = connection 47 | 48 | # HTTP/1 requests with an upgrade header (which can contain zero or more values) are extracted into the protocol field of the request, and we expect a response to select one of those protocols with a status code of 101 Switching Protocols. 49 | protocol = headers.delete("upgrade") 50 | 51 | super(scheme, authority, method, path, version, headers, body, protocol, self.public_method(:write_interim_response)) 52 | end 53 | 54 | def connection 55 | @connection 56 | end 57 | 58 | def hijack? 59 | true 60 | end 61 | 62 | def hijack! 63 | @connection.hijack! 64 | end 65 | 66 | def write_interim_response(status, headers = nil) 67 | @connection.write_interim_response(@version, status, headers) 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http1/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2023, by Josh Huber. 6 | 7 | require_relative "../response" 8 | 9 | module Async 10 | module HTTP 11 | module Protocol 12 | module HTTP1 13 | class Response < Protocol::Response 14 | def self.read(connection, request) 15 | while parts = connection.read_response(request.method) 16 | response = self.new(connection, *parts) 17 | 18 | if response.final? 19 | return response 20 | else 21 | request.send_interim_response(response.status, response.headers) 22 | end 23 | end 24 | end 25 | 26 | UPGRADE = "upgrade" 27 | 28 | # @attribute [String] The HTTP response line reason. 29 | attr :reason 30 | 31 | # @parameter reason [String] HTTP response line reason phrase. 32 | def initialize(connection, version, status, reason, headers, body) 33 | @connection = connection 34 | @reason = reason 35 | 36 | # Technically, there should never be more than one value for the upgrade header, but we'll just take the first one to avoid complexity. 37 | protocol = headers.delete(UPGRADE)&.first 38 | 39 | super(version, status, headers, body, protocol) 40 | end 41 | 42 | def pool=(pool) 43 | if @connection.idle? or @connection.closed? 44 | pool.release(@connection) 45 | else 46 | @connection.pool = pool 47 | end 48 | end 49 | 50 | def connection 51 | @connection 52 | end 53 | 54 | def hijack? 55 | @body.nil? 56 | end 57 | 58 | def hijack! 59 | @connection.hijack! 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http1/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | # Copyright, 2020, by Igor Sidorov. 6 | # Copyright, 2023, by Thomas Morgan. 7 | # Copyright, 2024, by Anton Zhuravsky. 8 | # Copyright, 2025, by Jean Boussier. 9 | 10 | require_relative "connection" 11 | require_relative "finishable" 12 | 13 | require "console/event/failure" 14 | 15 | module Async 16 | module HTTP 17 | module Protocol 18 | module HTTP1 19 | class Server < Connection 20 | def initialize(...) 21 | super 22 | 23 | @ready = Async::Notification.new 24 | end 25 | 26 | def closed(error = nil) 27 | super 28 | 29 | @ready.signal 30 | end 31 | 32 | def fail_request(status) 33 | @persistent = false 34 | write_response(@version, status, {}) 35 | write_body(@version, nil) 36 | rescue => error 37 | # At this point, there is very little we can do to recover: 38 | Console.debug(self, "Failed to write failure response!", error) 39 | end 40 | 41 | def next_request 42 | if closed? 43 | return nil 44 | elsif !idle? 45 | @ready.wait 46 | end 47 | 48 | # Read an incoming request: 49 | return unless request = Request.read(self) 50 | 51 | unless persistent?(request.version, request.method, request.headers) 52 | @persistent = false 53 | end 54 | 55 | return request 56 | rescue ::Protocol::HTTP1::BadRequest 57 | fail_request(400) 58 | # Conceivably we could retry here, but we don't really know how bad the error is, so it's better to just fail: 59 | raise 60 | end 61 | 62 | # Server loop. 63 | def each(task: Task.current) 64 | task.annotate("Reading #{self.version} requests for #{self.class}.") 65 | 66 | while request = next_request 67 | if body = request.body 68 | finishable = Finishable.new(body) 69 | request.body = finishable 70 | end 71 | 72 | response = yield(request, self) 73 | version = request.version 74 | body = response&.body 75 | 76 | if hijacked? 77 | body&.close 78 | return 79 | end 80 | 81 | task.defer_stop do 82 | # If a response was generated, send it: 83 | if response 84 | trailer = response.headers.trailer! 85 | 86 | # Some operations in this method are long running, that is, it's expected that `body.call(stream)` could literally run indefinitely. In order to facilitate garbage collection, we want to nullify as many local variables before calling the streaming body. This ensures that the garbage collection can clean up as much state as possible during the long running operation, so we don't retain objects that are no longer needed. 87 | 88 | if body and protocol = response.protocol 89 | # We force a 101 response if the protocol is upgraded - HTTP/2 CONNECT will return 200 for success, but this won't be understood by HTTP/1 clients: 90 | write_response(@version, 101, response.headers) 91 | 92 | # At this point, the request body is hijacked, so we don't want to call #finish below. 93 | request = nil 94 | response = nil 95 | 96 | if body.stream? 97 | return body.call(write_upgrade_body(protocol)) 98 | else 99 | write_upgrade_body(protocol, body) 100 | end 101 | elsif response.status == 101 102 | # This code path is to support legacy behavior where the response status is set to 101, but the protocol is not upgraded. This may not be a valid use case, but it is supported for compatibility. We expect the response headers to contain the `upgrade` header. 103 | write_response(@version, response.status, response.headers) 104 | 105 | # Same as above: 106 | request = nil 107 | response = nil 108 | 109 | if body.stream? 110 | return body.call(write_tunnel_body(version)) 111 | else 112 | write_tunnel_body(version, body) 113 | end 114 | else 115 | write_response(@version, response.status, response.headers) 116 | 117 | if request.connect? and response.success? 118 | # Same as above: 119 | request = nil 120 | response = nil 121 | 122 | if body.stream? 123 | return body.call(write_tunnel_body(version)) 124 | else 125 | write_tunnel_body(version, body) 126 | end 127 | else 128 | head = request.head? 129 | 130 | # Same as above: 131 | request = nil 132 | response = nil 133 | 134 | write_body(version, body, head, trailer) 135 | end 136 | end 137 | 138 | # We are done with the body: 139 | body = nil 140 | else 141 | # If the request failed to generate a response, it was an internal server error: 142 | write_response(@version, 500, {}) 143 | write_body(version, nil) 144 | 145 | request&.finish 146 | end 147 | 148 | if finishable 149 | finishable.wait(@persistent) 150 | else 151 | # Do not remove this line or you will unleash the gods of concurrency hell. 152 | task.yield 153 | end 154 | rescue => error 155 | raise 156 | ensure 157 | body&.close(error) 158 | end 159 | end 160 | end 161 | end 162 | end 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http10.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | # Copyright, 2024, by Thomas Morgan. 6 | 7 | require_relative "http1" 8 | 9 | module Async 10 | module HTTP 11 | module Protocol 12 | module HTTP10 13 | extend Configurable 14 | 15 | VERSION = "HTTP/1.0" 16 | 17 | # @returns [Boolean] Whether the protocol supports bidirectional communication. 18 | def self.bidirectional? 19 | false 20 | end 21 | 22 | # @returns [Boolean] Whether the protocol supports trailers. 23 | def self.trailer? 24 | false 25 | end 26 | 27 | # Create a client for an outbound connection. 28 | # 29 | # @parameter peer [IO] The peer to communicate with. 30 | # @parameter options [Hash] Options to pass to the client instance. 31 | def self.client(peer, **options) 32 | stream = ::IO::Stream(peer) 33 | 34 | return HTTP1::Client.new(stream, VERSION, **options) 35 | end 36 | 37 | # Create a server for an inbound connection. 38 | # 39 | # @parameter peer [IO] The peer to communicate with. 40 | # @parameter options [Hash] Options to pass to the server instance. 41 | def self.server(peer, **options) 42 | stream = ::IO::Stream(peer) 43 | 44 | return HTTP1::Server.new(stream, VERSION, **options) 45 | end 46 | 47 | # @returns [Array] The names of the supported protocol. 48 | def self.names 49 | ["http/1.0"] 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http11.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | # Copyright, 2018, by Janko Marohnić. 6 | # Copyright, 2024, by Thomas Morgan. 7 | 8 | require_relative "http1" 9 | 10 | module Async 11 | module HTTP 12 | module Protocol 13 | module HTTP11 14 | extend Configurable 15 | 16 | VERSION = "HTTP/1.1" 17 | 18 | # @returns [Boolean] Whether the protocol supports bidirectional communication. 19 | def self.bidirectional? 20 | true 21 | end 22 | 23 | # @returns [Boolean] Whether the protocol supports trailers. 24 | def self.trailer? 25 | true 26 | end 27 | 28 | # Create a client for an outbound connection. 29 | # 30 | # @parameter peer [IO] The peer to communicate with. 31 | # @parameter options [Hash] Options to pass to the client instance. 32 | def self.client(peer, **options) 33 | stream = ::IO::Stream(peer) 34 | 35 | return HTTP1::Client.new(stream, VERSION, **options) 36 | end 37 | 38 | # Create a server for an inbound connection. 39 | # 40 | # @parameter peer [IO] The peer to communicate with. 41 | # @parameter options [Hash] Options to pass to the server instance. 42 | def self.server(peer, **options) 43 | stream = ::IO::Stream(peer) 44 | 45 | return HTTP1::Server.new(stream, VERSION, **options) 46 | end 47 | 48 | # @returns [Array] The names of the supported protocol. 49 | def self.names 50 | ["http/1.1"] 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | # Copyright, 2024, by Thomas Morgan. 6 | 7 | require_relative "configurable" 8 | 9 | require_relative "http2/client" 10 | require_relative "http2/server" 11 | 12 | require "io/stream" 13 | 14 | module Async 15 | module HTTP 16 | module Protocol 17 | module HTTP2 18 | extend Configurable 19 | 20 | VERSION = "HTTP/2" 21 | 22 | # @returns [Boolean] Whether the protocol supports bidirectional communication. 23 | def self.bidirectional? 24 | true 25 | end 26 | 27 | # @returns [Boolean] Whether the protocol supports trailers. 28 | def self.trailer? 29 | true 30 | end 31 | 32 | # The default settings for the client. 33 | CLIENT_SETTINGS = { 34 | ::Protocol::HTTP2::Settings::ENABLE_PUSH => 0, 35 | ::Protocol::HTTP2::Settings::MAXIMUM_FRAME_SIZE => 0x100000, 36 | ::Protocol::HTTP2::Settings::INITIAL_WINDOW_SIZE => 0x800000, 37 | ::Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES => 1, 38 | } 39 | 40 | # The default settings for the server. 41 | SERVER_SETTINGS = { 42 | # We choose a lower maximum concurrent streams to avoid overloading a single connection/thread. 43 | ::Protocol::HTTP2::Settings::MAXIMUM_CONCURRENT_STREAMS => 128, 44 | ::Protocol::HTTP2::Settings::MAXIMUM_FRAME_SIZE => 0x100000, 45 | ::Protocol::HTTP2::Settings::INITIAL_WINDOW_SIZE => 0x800000, 46 | ::Protocol::HTTP2::Settings::ENABLE_CONNECT_PROTOCOL => 1, 47 | ::Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES => 1, 48 | } 49 | 50 | # Create a client for an outbound connection. 51 | # 52 | # @parameter peer [IO] The peer to communicate with. 53 | # @parameter options [Hash] Options to pass to the client instance. 54 | def self.client(peer, settings: CLIENT_SETTINGS) 55 | stream = ::IO::Stream(peer) 56 | client = Client.new(stream) 57 | 58 | client.send_connection_preface(settings) 59 | client.start_connection 60 | 61 | return client 62 | end 63 | 64 | # Create a server for an inbound connection. 65 | # 66 | # @parameter peer [IO] The peer to communicate with. 67 | # @parameter options [Hash] Options to pass to the server instance. 68 | def self.server(peer, settings: SERVER_SETTINGS) 69 | stream = ::IO::Stream(peer) 70 | server = Server.new(stream) 71 | 72 | server.read_connection_preface(settings) 73 | server.start_connection 74 | 75 | return server 76 | end 77 | 78 | # @returns [Array] The names of the supported protocol. 79 | def self.names 80 | ["h2"] 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require_relative "connection" 7 | require_relative "response" 8 | 9 | require "traces/provider" 10 | require "protocol/http2/client" 11 | 12 | module Async 13 | module HTTP 14 | module Protocol 15 | module HTTP2 16 | class Client < ::Protocol::HTTP2::Client 17 | include Connection 18 | 19 | def initialize(stream) 20 | @stream = stream 21 | 22 | framer = ::Protocol::HTTP2::Framer.new(@stream) 23 | 24 | super(framer) 25 | end 26 | 27 | def create_response 28 | Response::Stream.create(self, self.next_stream_id).response 29 | end 30 | 31 | # Used by the client to send requests to the remote server. 32 | def call(request) 33 | raise ::Protocol::HTTP2::Error, "Connection closed!" if self.closed? 34 | 35 | response = create_response 36 | write_request(response, request) 37 | read_response(response) 38 | 39 | return response 40 | end 41 | 42 | def write_request(response, request) 43 | response.send_request(request) 44 | end 45 | 46 | def read_response(response) 47 | response.wait 48 | end 49 | 50 | Traces::Provider(self) do 51 | def write_request(...) 52 | Traces.trace("async.http.protocol.http2.client.write_request") do 53 | super 54 | end 55 | end 56 | 57 | def read_response(...) 58 | Traces.trace("async.http.protocol.http2.client.read_response") do 59 | super 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | # Copyright, 2020, by Bruno Sutic. 6 | # Copyright, 2025, by Jean Boussier. 7 | 8 | require_relative "stream" 9 | 10 | require "protocol/http/peer" 11 | require "async/semaphore" 12 | 13 | module Async 14 | module HTTP 15 | module Protocol 16 | module HTTP2 17 | HTTPS = "https".freeze 18 | SCHEME = ":scheme".freeze 19 | METHOD = ":method".freeze 20 | PATH = ":path".freeze 21 | AUTHORITY = ":authority".freeze 22 | STATUS = ":status".freeze 23 | PROTOCOL = ":protocol".freeze 24 | 25 | CONTENT_LENGTH = "content-length".freeze 26 | CONNECTION = "connection".freeze 27 | TRAILER = "trailer".freeze 28 | 29 | module Connection 30 | def initialize(...) 31 | super 32 | 33 | @reader = nil 34 | 35 | # Writing multiple frames at the same time can cause odd problems if frames are only partially written. So we use a semaphore to ensure frames are written in their entirety. 36 | @write_frame_guard = Async::Semaphore.new(1) 37 | end 38 | 39 | def synchronize(&block) 40 | @write_frame_guard.acquire(&block) 41 | end 42 | 43 | def to_s 44 | "\#<#{self.class} #{@streams.count} active streams>" 45 | end 46 | 47 | def as_json(...) 48 | to_s 49 | end 50 | 51 | def to_json(...) 52 | as_json.to_json(...) 53 | end 54 | 55 | attr :stream 56 | 57 | def http1? 58 | false 59 | end 60 | 61 | def http2? 62 | true 63 | end 64 | 65 | def start_connection 66 | @reader || read_in_background 67 | end 68 | 69 | def close(error = nil) 70 | # Ensure the reader task is stopped. 71 | if @reader 72 | reader = @reader 73 | @reader = nil 74 | reader.stop 75 | end 76 | 77 | super 78 | end 79 | 80 | def read_in_background(parent: Task.current) 81 | raise RuntimeError, "Connection is closed!" if closed? 82 | 83 | parent.async(transient: true) do |task| 84 | @reader = task 85 | 86 | task.annotate("#{version} reading data for #{self.class}.") 87 | 88 | # We don't need to defer stop here as this is already a transient task (ignores stop): 89 | begin 90 | while !self.closed? 91 | self.consume_window 92 | self.read_frame 93 | end 94 | rescue Async::Stop, ::IO::TimeoutError, ::Protocol::HTTP2::GoawayError => error 95 | # Error is raised if a response is actively reading from the 96 | # connection. The connection is silently closed if GOAWAY is 97 | # received outside the request/response cycle. 98 | rescue SocketError, IOError, EOFError, Errno::ECONNRESET, Errno::EPIPE 99 | # Ignore. 100 | rescue => error 101 | # Every other error. 102 | ensure 103 | # Don't call #close twice. 104 | if @reader 105 | @reader = nil 106 | 107 | self.close(error) 108 | end 109 | end 110 | end 111 | end 112 | 113 | attr :promises 114 | 115 | def peer 116 | @peer ||= ::Protocol::HTTP::Peer.for(@stream.io) 117 | end 118 | 119 | attr :count 120 | 121 | def concurrency 122 | self.maximum_concurrent_streams 123 | end 124 | 125 | # Can we use this connection to make requests? 126 | def viable? 127 | @stream&.readable? 128 | end 129 | 130 | def reusable? 131 | !self.closed? 132 | end 133 | 134 | def version 135 | VERSION 136 | end 137 | end 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2/input.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/writable" 7 | 8 | module Async 9 | module HTTP 10 | module Protocol 11 | module HTTP2 12 | # A writable body which requests window updates when data is read from it. 13 | class Input < ::Protocol::HTTP::Body::Writable 14 | def initialize(stream, length) 15 | super(length) 16 | 17 | @stream = stream 18 | @remaining = length 19 | end 20 | 21 | def read 22 | if chunk = super 23 | # If we read a chunk fron the stream, we want to extend the window if required so more data will be provided. 24 | @stream.request_window_update 25 | end 26 | 27 | # We track the expected length and check we got what we were expecting. 28 | if @remaining 29 | if chunk 30 | @remaining -= chunk.bytesize 31 | elsif @remaining > 0 32 | raise EOFError, "Expected #{self.length} bytes, #{@remaining} bytes short!" 33 | elsif @remaining < 0 34 | raise EOFError, "Expected #{self.length} bytes, #{@remaining} bytes over!" 35 | end 36 | end 37 | 38 | return chunk 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2/output.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/stream" 7 | 8 | module Async 9 | module HTTP 10 | module Protocol 11 | module HTTP2 12 | class Output 13 | def initialize(stream, body, trailer = nil) 14 | @stream = stream 15 | @body = body 16 | @trailer = trailer 17 | 18 | @task = nil 19 | 20 | @guard = ::Mutex.new 21 | @window_updated = ::ConditionVariable.new 22 | end 23 | 24 | attr :trailer 25 | 26 | def start(parent: Task.current) 27 | raise "Task already started!" if @task 28 | 29 | if @body.stream? 30 | @task = parent.async(&self.method(:stream)) 31 | else 32 | @task = parent.async(&self.method(:passthrough)) 33 | end 34 | end 35 | 36 | def window_updated(size) 37 | @guard.synchronize do 38 | @window_updated.signal 39 | end 40 | 41 | return true 42 | end 43 | 44 | def write(chunk) 45 | until chunk.empty? 46 | maximum_size = @stream.available_frame_size 47 | 48 | # We try to avoid synchronization if possible: 49 | if maximum_size <= 0 50 | @guard.synchronize do 51 | maximum_size = @stream.available_frame_size 52 | 53 | while maximum_size <= 0 54 | @window_updated.wait(@guard) 55 | 56 | maximum_size = @stream.available_frame_size 57 | end 58 | end 59 | end 60 | 61 | break unless chunk = send_data(chunk, maximum_size) 62 | end 63 | end 64 | 65 | def close_write(error = nil) 66 | if stream = @stream 67 | @stream = nil 68 | stream.finish_output(error) 69 | end 70 | end 71 | 72 | # This method should only be called from within the context of the output task. 73 | def close(error = nil) 74 | close_write(error) 75 | stop(error) 76 | end 77 | 78 | # This method should only be called from within the context of the HTTP/2 stream. 79 | def stop(error) 80 | if task = @task 81 | @task = nil 82 | task.stop(error) 83 | end 84 | end 85 | 86 | private 87 | 88 | def stream(task) 89 | task.annotate("Streaming #{@body} to #{@stream}.") 90 | 91 | input = @stream.wait_for_input 92 | stream = ::Protocol::HTTP::Body::Stream.new(input, self) 93 | 94 | @body.call(stream) 95 | rescue => error 96 | self.close(error) 97 | raise 98 | end 99 | 100 | # Reads chunks from the given body and writes them to the stream as fast as possible. 101 | def passthrough(task) 102 | task.annotate("Writing #{@body} to #{@stream}.") 103 | 104 | while chunk = @body&.read 105 | self.write(chunk) 106 | # TODO this reduces memory usage? 107 | # chunk.clear unless chunk.frozen? 108 | # GC.start 109 | end 110 | rescue => error 111 | raise 112 | ensure 113 | # Ensure the body we are reading from is fully closed: 114 | if body = @body 115 | @body = nil 116 | body.close(error) 117 | end 118 | 119 | # Ensure the output of this body is closed: 120 | self.close_write(error) 121 | end 122 | 123 | # Send `maximum_size` bytes of data using the specified `stream`. If the buffer has no more chunks, `END_STREAM` will be sent on the final chunk. 124 | # @param maximum_size [Integer] send up to this many bytes of data. 125 | # @param stream [Stream] the stream to use for sending data frames. 126 | # @return [String, nil] any data that could not be written. 127 | def send_data(chunk, maximum_size) 128 | if chunk.bytesize <= maximum_size 129 | @stream.send_data(chunk, maximum_size: maximum_size) 130 | else 131 | @stream.send_data(chunk.byteslice(0, maximum_size), maximum_size: maximum_size) 132 | 133 | # The window was not big enough to send all the data, so we save it for next time: 134 | return chunk.byteslice(maximum_size, chunk.bytesize - maximum_size) 135 | end 136 | 137 | return nil 138 | end 139 | end 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require_relative "../request" 7 | require_relative "stream" 8 | 9 | module Async 10 | module HTTP 11 | module Protocol 12 | module HTTP2 13 | # Typically used on the server side to represent an incoming request, and write the response. 14 | class Request < Protocol::Request 15 | class Stream < HTTP2::Stream 16 | def initialize(*) 17 | super 18 | 19 | @enqueued = false 20 | @request = Request.new(self) 21 | end 22 | 23 | attr :request 24 | 25 | def receive_initial_headers(headers, end_stream) 26 | @headers = ::Protocol::HTTP::Headers.new 27 | 28 | headers.each do |key, value| 29 | if key == SCHEME 30 | raise ::Protocol::HTTP2::HeaderError, "Request scheme already specified!" if @request.scheme 31 | 32 | @request.scheme = value 33 | elsif key == AUTHORITY 34 | raise ::Protocol::HTTP2::HeaderError, "Request authority already specified!" if @request.authority 35 | 36 | @request.authority = value 37 | elsif key == METHOD 38 | raise ::Protocol::HTTP2::HeaderError, "Request method already specified!" if @request.method 39 | 40 | @request.method = value 41 | elsif key == PATH 42 | raise ::Protocol::HTTP2::HeaderError, "Request path is empty!" if value.empty? 43 | raise ::Protocol::HTTP2::HeaderError, "Request path already specified!" if @request.path 44 | 45 | @request.path = value 46 | elsif key == PROTOCOL 47 | raise ::Protocol::HTTP2::HeaderError, "Request protocol already specified!" if @request.protocol 48 | 49 | @request.protocol = value 50 | elsif key == CONTENT_LENGTH 51 | raise ::Protocol::HTTP2::HeaderError, "Request content length already specified!" if @length 52 | 53 | @length = Integer(value) 54 | elsif key == CONNECTION 55 | raise ::Protocol::HTTP2::HeaderError, "Connection header is not allowed!" 56 | elsif key.start_with? ":" 57 | raise ::Protocol::HTTP2::HeaderError, "Invalid pseudo-header #{key}!" 58 | elsif key =~ /[A-Z]/ 59 | raise ::Protocol::HTTP2::HeaderError, "Invalid characters in header #{key}!" 60 | else 61 | add_header(key, value) 62 | end 63 | end 64 | 65 | @request.headers = @headers 66 | 67 | unless @request.valid? 68 | raise ::Protocol::HTTP2::HeaderError, "Request is missing required headers!" 69 | else 70 | # We only construct the input/body if data is coming. 71 | unless end_stream 72 | @request.body = prepare_input(@length) 73 | end 74 | 75 | # We are ready for processing: 76 | @connection.requests.enqueue(@request) 77 | end 78 | 79 | return headers 80 | end 81 | 82 | def closed(error) 83 | @request = nil 84 | 85 | super 86 | end 87 | end 88 | 89 | def initialize(stream) 90 | super(nil, nil, nil, nil, VERSION, nil, nil, nil, self.public_method(:write_interim_response)) 91 | 92 | @stream = stream 93 | end 94 | 95 | attr :stream 96 | 97 | def connection 98 | @stream.connection 99 | end 100 | 101 | def valid? 102 | @scheme and @method and (@path or @method == ::Protocol::HTTP::Methods::CONNECT) 103 | end 104 | 105 | def hijack? 106 | false 107 | end 108 | 109 | NO_RESPONSE = [ 110 | [STATUS, "500"], 111 | ] 112 | 113 | def send_response(response) 114 | if response.nil? 115 | return @stream.send_headers(NO_RESPONSE, ::Protocol::HTTP2::END_STREAM) 116 | end 117 | 118 | protocol_headers = [ 119 | [STATUS, response.status], 120 | ] 121 | 122 | if length = response.body&.length 123 | protocol_headers << [CONTENT_LENGTH, length] 124 | end 125 | 126 | headers = ::Protocol::HTTP::Headers::Merged.new(protocol_headers, response.headers) 127 | 128 | if body = response.body and !self.head? 129 | # This function informs the headers object that any subsequent headers are going to be trailer. Therefore, it must be called *before* sending the headers, to avoid any race conditions. 130 | trailer = response.headers.trailer! 131 | 132 | @stream.send_headers(headers) 133 | 134 | @stream.send_body(body, trailer) 135 | else 136 | # Ensure the response body is closed if we are ending the stream: 137 | response.close 138 | 139 | @stream.send_headers(headers, ::Protocol::HTTP2::END_STREAM) 140 | end 141 | end 142 | 143 | def write_interim_response(status, headers = nil) 144 | interim_response_headers = [ 145 | [STATUS, status] 146 | ] 147 | 148 | if headers 149 | interim_response_headers = ::Protocol::HTTP::Headers::Merged.new(interim_response_headers, headers) 150 | end 151 | 152 | @stream.send_headers(interim_response_headers) 153 | end 154 | end 155 | end 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require_relative "connection" 7 | require_relative "request" 8 | 9 | require "protocol/http2/server" 10 | 11 | module Async 12 | module HTTP 13 | module Protocol 14 | module HTTP2 15 | class Server < ::Protocol::HTTP2::Server 16 | include Connection 17 | 18 | def initialize(stream) 19 | # Used by some generic methods in Connetion: 20 | @stream = stream 21 | 22 | framer = ::Protocol::HTTP2::Framer.new(stream) 23 | 24 | super(framer) 25 | 26 | @requests = Async::Queue.new 27 | end 28 | 29 | attr :requests 30 | 31 | def accept_stream(stream_id) 32 | super do 33 | Request::Stream.create(self, stream_id) 34 | end 35 | end 36 | 37 | def close(error = nil) 38 | if @requests 39 | # Stop the request loop: 40 | @requests.enqueue(nil) 41 | @requests = nil 42 | end 43 | 44 | super 45 | end 46 | 47 | def each(task: Task.current) 48 | task.annotate("Reading #{version} requests for #{self.class}.") 49 | 50 | # It's possible the connection has died before we get here... 51 | @requests&.async do |task, request| 52 | task.annotate("Incoming request: #{request.method} #{request.path.inspect}.") 53 | 54 | task.defer_stop do 55 | response = yield(request) 56 | rescue 57 | # We need to close the stream if the user code blows up while generating a response: 58 | request.stream.send_reset_stream(::Protocol::HTTP2::INTERNAL_ERROR) 59 | 60 | raise 61 | else 62 | request.send_response(response) 63 | end 64 | end 65 | 66 | # Maybe we should add some synchronisation here - i.e. only exit once all requests are finished. 67 | end 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/async/http/protocol/http2/stream.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | # Copyright, 2022, by Marco Concetto Rudilosso. 6 | # Copyright, 2023, by Thomas Morgan. 7 | 8 | require "protocol/http2/stream" 9 | 10 | require_relative "input" 11 | require_relative "output" 12 | 13 | module Async 14 | module HTTP 15 | module Protocol 16 | module HTTP2 17 | class Stream < ::Protocol::HTTP2::Stream 18 | def initialize(*) 19 | super 20 | 21 | @headers = nil 22 | 23 | @pool = nil 24 | 25 | # Input buffer, reading request body, or response body (receive_data): 26 | @length = nil 27 | @input = nil 28 | 29 | # Output buffer, writing request body or response body (window_updated): 30 | @output = nil 31 | end 32 | 33 | attr_accessor :headers 34 | 35 | attr_accessor :pool 36 | 37 | attr :input 38 | 39 | def add_header(key, value) 40 | if key == CONNECTION 41 | raise ::Protocol::HTTP2::HeaderError, "Connection header is not allowed!" 42 | elsif key.start_with? ":" 43 | raise ::Protocol::HTTP2::HeaderError, "Invalid pseudo-header #{key}!" 44 | elsif key =~ /[A-Z]/ 45 | raise ::Protocol::HTTP2::HeaderError, "Invalid upper-case characters in header #{key}!" 46 | else 47 | @headers.add(key, value) 48 | end 49 | end 50 | 51 | def receive_trailing_headers(headers, end_stream) 52 | headers.each do |key, value| 53 | add_header(key, value) 54 | end 55 | end 56 | 57 | def process_headers(frame) 58 | if @headers and frame.end_stream? 59 | self.receive_trailing_headers(super, frame.end_stream?) 60 | else 61 | self.receive_initial_headers(super, frame.end_stream?) 62 | end 63 | 64 | if @input and frame.end_stream? 65 | @input.close_write 66 | end 67 | rescue ::Protocol::HTTP2::HeaderError => error 68 | Console.debug(self, "Error while processing headers!", error) 69 | 70 | send_reset_stream(error.code) 71 | end 72 | 73 | def wait_for_input 74 | return @input 75 | end 76 | 77 | # Prepare the input stream which will be used for incoming data frames. 78 | # @return [Input] the input body. 79 | def prepare_input(length) 80 | if @input.nil? 81 | @input = Input.new(self, length) 82 | else 83 | raise ArgumentError, "Input body already prepared!" 84 | end 85 | end 86 | 87 | def update_local_window(frame) 88 | consume_local_window(frame) 89 | 90 | # This is done on demand in `Input#read`: 91 | # request_window_update 92 | end 93 | 94 | def process_data(frame) 95 | data = frame.unpack 96 | 97 | if @input 98 | unless data.empty? 99 | @input.write(data) 100 | end 101 | 102 | if frame.end_stream? 103 | @input.close_write 104 | end 105 | end 106 | 107 | return data 108 | rescue ::Protocol::HTTP2::ProtocolError 109 | raise 110 | rescue # Anything else... 111 | send_reset_stream(::Protocol::HTTP2::Error::INTERNAL_ERROR) 112 | end 113 | 114 | # Set the body and begin sending it. 115 | def send_body(body, trailer = nil) 116 | @output = Output.new(self, body, trailer) 117 | 118 | @output.start 119 | end 120 | 121 | # Called when the output terminates normally. 122 | def finish_output(error = nil) 123 | return if self.closed? 124 | 125 | trailer = @output&.trailer 126 | 127 | @output = nil 128 | 129 | if error 130 | send_reset_stream(::Protocol::HTTP2::Error::INTERNAL_ERROR) 131 | else 132 | # Write trailer? 133 | if trailer&.any? 134 | send_headers(trailer, ::Protocol::HTTP2::END_STREAM) 135 | else 136 | send_data(nil, ::Protocol::HTTP2::END_STREAM) 137 | end 138 | end 139 | end 140 | 141 | def window_updated(size) 142 | super 143 | 144 | @output&.window_updated(size) 145 | 146 | return true 147 | end 148 | 149 | # When the stream transitions to the closed state, this method is called. There are roughly two ways this can happen: 150 | # - A frame is received which causes this stream to enter the closed state. This method will be invoked from the background reader task. 151 | # - A frame is sent which causes this stream to enter the closed state. This method will be invoked from that task. 152 | # While the input stream is relatively straight forward, the output stream can trigger the second case above 153 | def closed(error) 154 | super 155 | 156 | if input = @input 157 | @input = nil 158 | input.close_write(error) 159 | end 160 | 161 | if output = @output 162 | @output = nil 163 | output.stop(error) 164 | end 165 | 166 | if pool = @pool and @connection 167 | pool.release(@connection) 168 | end 169 | 170 | return self 171 | end 172 | end 173 | end 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/async/http/protocol/https.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | # Copyright, 2019, by Brian Morearty. 6 | 7 | require_relative "defaulton" 8 | 9 | require_relative "http10" 10 | require_relative "http11" 11 | require_relative "http2" 12 | 13 | module Async 14 | module HTTP 15 | module Protocol 16 | # A server that supports both HTTP1.0 and HTTP1.1 semantics by detecting the version of the request. 17 | class HTTPS 18 | # The protocol classes for each supported protocol. 19 | HANDLERS = { 20 | "h2" => HTTP2, 21 | "http/1.1" => HTTP11, 22 | "http/1.0" => HTTP10, 23 | nil => HTTP11, 24 | } 25 | 26 | def initialize(handlers = HANDLERS, **options) 27 | @handlers = handlers 28 | @options = options 29 | end 30 | 31 | def add(name, protocol, **options) 32 | @handlers[name] = protocol 33 | @options[protocol] = options 34 | end 35 | 36 | # Determine the protocol of the peer and return the appropriate protocol class. 37 | # 38 | # Use TLS Application Layer Protocol Negotiation (ALPN) to determine the protocol. 39 | # 40 | # @parameter peer [IO] The peer to communicate with. 41 | # @returns [Class] The protocol class to use. 42 | def protocol_for(peer) 43 | # alpn_protocol is only available if openssl v1.0.2+ 44 | name = peer.alpn_protocol 45 | 46 | Console.debug(self) {"Negotiating protocol #{name.inspect}..."} 47 | 48 | if protocol = HANDLERS[name] 49 | return protocol 50 | else 51 | raise ArgumentError, "Could not determine protocol for connection (#{name.inspect})." 52 | end 53 | end 54 | 55 | # Create a client for an outbound connection. 56 | # 57 | # @parameter peer [IO] The peer to communicate with. 58 | # @parameter options [Hash] Options to pass to the client instance. 59 | def client(peer, **options) 60 | protocol = protocol_for(peer) 61 | options = options[protocol] || {} 62 | 63 | protocol.client(peer, **options) 64 | end 65 | 66 | # Create a server for an inbound connection. 67 | # 68 | # @parameter peer [IO] The peer to communicate with. 69 | # @parameter options [Hash] Options to pass to the server instance. 70 | def server(peer, **options) 71 | protocol = protocol_for(peer) 72 | options = options[protocol] || {} 73 | 74 | protocol.server(peer, **options) 75 | end 76 | 77 | # @returns [Array] The names of the supported protocol, used for Application Layer Protocol Negotiation (ALPN). 78 | def names 79 | @handlers.keys.compact 80 | end 81 | 82 | extend Defaulton 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/async/http/protocol/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require "protocol/http/request" 7 | require "protocol/http/headers" 8 | 9 | require_relative "../body/writable" 10 | 11 | module Async 12 | module HTTP 13 | module Protocol 14 | # Failed to send the request. The request body has NOT been consumed (i.e. #read) and you should retry the request. 15 | class RequestFailed < StandardError 16 | end 17 | 18 | # This is generated by server protocols. 19 | class Request < ::Protocol::HTTP::Request 20 | def connection 21 | nil 22 | end 23 | 24 | def hijack? 25 | false 26 | end 27 | 28 | def write_interim_response(status, headers = nil) 29 | end 30 | 31 | def peer 32 | self.connection&.peer 33 | end 34 | 35 | def remote_address 36 | self.peer&.address 37 | end 38 | 39 | def inspect 40 | "#<#{self.class}:0x#{self.object_id.to_s(16)} method=#{method} path=#{path} version=#{version}>" 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/async/http/protocol/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require "protocol/http/response" 7 | 8 | require_relative "../body/writable" 9 | 10 | module Async 11 | module HTTP 12 | module Protocol 13 | # This is generated by client protocols. 14 | class Response < ::Protocol::HTTP::Response 15 | def connection 16 | nil 17 | end 18 | 19 | def hijack? 20 | false 21 | end 22 | 23 | def peer 24 | self.connection&.peer 25 | end 26 | 27 | def remote_address 28 | self.peer&.remote_address 29 | end 30 | 31 | def inspect 32 | "#<#{self.class}:0x#{self.object_id.to_s(16)} status=#{status}>" 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/async/http/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require_relative "client" 7 | require_relative "endpoint" 8 | 9 | require_relative "body/pipe" 10 | 11 | module Async 12 | module HTTP 13 | # Wraps a client, address and headers required to initiate a connectio to a remote host using the CONNECT verb. 14 | # Behaves like a TCP endpoint for the purposes of connecting to a remote host. 15 | class Proxy 16 | class ConnectFailure < StandardError 17 | def initialize(response) 18 | super "Failed to connect: #{response.status}" 19 | @response = response 20 | end 21 | 22 | attr :response 23 | end 24 | 25 | module Client 26 | def proxy(endpoint, headers = nil) 27 | Proxy.new(self, endpoint.authority(false), headers) 28 | end 29 | 30 | # Create a client that will proxy requests through the current client. 31 | def proxied_client(endpoint, headers = nil) 32 | proxy = self.proxy(endpoint, headers) 33 | 34 | return self.class.new(proxy.wrap_endpoint(endpoint)) 35 | end 36 | 37 | def proxied_endpoint(endpoint, headers = nil) 38 | proxy = self.proxy(endpoint, headers) 39 | 40 | return proxy.wrap_endpoint(endpoint) 41 | end 42 | end 43 | 44 | # Prepare and endpoint which can establish a TCP connection to the remote system. 45 | # @param client [Async::HTTP::Client] the client which will be used as a proxy server. 46 | # @param host [String] the hostname or address to connect to. 47 | # @param port [String] the port number to connect to. 48 | # @param headers [Array] an optional list of headers to use when establishing the connection. 49 | # @see IO::Endpoint#tcp 50 | def self.tcp(client, host, port, headers = nil) 51 | self.new(client, "#{host}:#{port}", headers) 52 | end 53 | 54 | # Construct a endpoint that will use the given client as a proxy for HTTP requests. 55 | # @param client [Async::HTTP::Client] the client which will be used as a proxy server. 56 | # @param endpoint [Async::HTTP::Endpoint] the endpoint to connect to. 57 | # @param headers [Array] an optional list of headers to use when establishing the connection. 58 | def self.endpoint(client, endpoint, headers = nil) 59 | proxy = self.new(client, endpoint.authority(false), headers) 60 | 61 | return proxy.endpoint(endpoint.url) 62 | end 63 | 64 | # @param client [Async::HTTP::Client] the client which will be used as a proxy server. 65 | # @param address [String] the address to connect to. 66 | # @param headers [Array] an optional list of headers to use when establishing the connection. 67 | def initialize(client, address, headers = nil) 68 | @client = client 69 | @address = address 70 | @headers = ::Protocol::HTTP::Headers[headers].freeze 71 | end 72 | 73 | attr :client 74 | 75 | # Close the underlying client connection. 76 | def close 77 | @client.close 78 | end 79 | 80 | # Establish a TCP connection to the specified host. 81 | # @return [Socket] a connected bi-directional socket. 82 | def connect(&block) 83 | input = Body::Writable.new 84 | 85 | response = @client.connect(authority: @address, headers: @headers, body: input) 86 | 87 | if response.success? 88 | pipe = Body::Pipe.new(response.body, input) 89 | 90 | return pipe.to_io unless block_given? 91 | 92 | begin 93 | yield pipe.to_io 94 | ensure 95 | pipe.close 96 | end 97 | else 98 | # This ensures we don't leave a response dangling: 99 | input.close 100 | response.close 101 | 102 | raise ConnectFailure, response 103 | end 104 | end 105 | 106 | # @return [Async::HTTP::Endpoint] an endpoint that connects via the specified proxy. 107 | def wrap_endpoint(endpoint) 108 | Endpoint.new(endpoint.url, self, **endpoint.options) 109 | end 110 | end 111 | 112 | Client.prepend(Proxy::Client) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/async/http/reference.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "protocol/http/reference" 7 | 8 | module Async 9 | module HTTP 10 | Reference = ::Protocol::HTTP::Reference 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/async/http/relative_location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2019-2020, by Brian Morearty. 6 | 7 | require_relative "middleware/location_redirector" 8 | 9 | warn "`Async::HTTP::RelativeLocation` is deprecated and will be removed in the next release. Please use `Async::HTTP::Middleware::LocationRedirector` instead.", uplevel: 1 10 | 11 | module Async 12 | module HTTP 13 | module Middleware 14 | RelativeLocation = Middleware::LocationRedirector 15 | TooManyRedirects = RelativeLocation::TooManyRedirects 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/async/http/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | # Copyright, 2019, by Brian Morearty. 6 | 7 | require "async" 8 | require "io/endpoint" 9 | require "protocol/http/middleware" 10 | require "traces/provider" 11 | 12 | require_relative "protocol" 13 | 14 | module Async 15 | module HTTP 16 | class Server < ::Protocol::HTTP::Middleware 17 | def self.for(*arguments, **options, &block) 18 | self.new(block, *arguments, **options) 19 | end 20 | 21 | def initialize(app, endpoint, protocol: endpoint.protocol, scheme: endpoint.scheme) 22 | super(app) 23 | 24 | @endpoint = endpoint 25 | @protocol = protocol 26 | @scheme = scheme 27 | end 28 | 29 | def as_json(...) 30 | { 31 | endpoint: @endpoint.to_s, 32 | protocol: @protocol, 33 | scheme: @scheme, 34 | } 35 | end 36 | 37 | def to_json(...) 38 | as_json.to_json(...) 39 | end 40 | 41 | attr :endpoint 42 | attr :protocol 43 | attr :scheme 44 | 45 | def accept(peer, address, task: Task.current) 46 | connection = @protocol.server(peer) 47 | 48 | Console.debug(self) {"Incoming connnection from #{address.inspect} to #{@protocol}"} 49 | 50 | connection.each do |request| 51 | # We set the default scheme unless it was otherwise specified. 52 | # https://tools.ietf.org/html/rfc7230#section-5.5 53 | request.scheme ||= self.scheme 54 | 55 | # Console.debug(self) {"Incoming request from #{address.inspect}: #{request.method} #{request.path}"} 56 | 57 | # If this returns nil, we assume that the connection has been hijacked. 58 | self.call(request) 59 | end 60 | ensure 61 | connection&.close 62 | end 63 | 64 | # @returns [Async::Task] The task that is running the server. 65 | def run 66 | Async do |task| 67 | @endpoint.accept(&self.method(:accept)) 68 | 69 | # Wait for all children to finish: 70 | task.children.each(&:wait) 71 | end 72 | end 73 | 74 | Traces::Provider(self) do 75 | def call(request) 76 | if trace_parent = request.headers["traceparent"] 77 | Traces.trace_context = Traces::Context.parse(trace_parent.join, request.headers["tracestate"], remote: true) 78 | end 79 | 80 | attributes = { 81 | 'http.version': request.version, 82 | 'http.method': request.method, 83 | 'http.authority': request.authority, 84 | 'http.scheme': request.scheme, 85 | 'http.path': request.path, 86 | 'http.user_agent': request.headers["user-agent"], 87 | } 88 | 89 | if length = request.body&.length 90 | attributes["http.request.length"] = length 91 | end 92 | 93 | if protocol = request.protocol 94 | attributes["http.protocol"] = protocol 95 | end 96 | 97 | Traces.trace("async.http.server.call", resource: "#{request.method} #{request.path}", attributes: attributes) do |span| 98 | super.tap do |response| 99 | if status = response&.status 100 | span["http.status_code"] = status 101 | end 102 | 103 | if length = response&.body&.length 104 | span["http.response.length"] = length 105 | end 106 | end 107 | end 108 | end 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/async/http/statistics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "protocol/http/body/wrapper" 7 | 8 | require "async/clock" 9 | 10 | module Async 11 | module HTTP 12 | class Statistics 13 | def self.start 14 | self.new(Clock.now) 15 | end 16 | 17 | def initialize(start_time) 18 | @start_time = start_time 19 | end 20 | 21 | def wrap(response, &block) 22 | if response and response.body 23 | response.body = Body::Statistics.new(@start_time, response.body, block) 24 | end 25 | 26 | return response 27 | end 28 | end 29 | 30 | module Body 31 | # Invokes a callback once the body has finished reading. 32 | class Statistics < ::Protocol::HTTP::Body::Wrapper 33 | def initialize(start_time, body, callback) 34 | super(body) 35 | 36 | @sent = 0 37 | 38 | @start_time = start_time 39 | @first_chunk_time = nil 40 | @end_time = nil 41 | 42 | @callback = callback 43 | end 44 | 45 | attr :start_time 46 | attr :first_chunk_time 47 | attr :end_time 48 | 49 | attr :sent 50 | 51 | def total_duration 52 | if @end_time 53 | @end_time - @start_time 54 | end 55 | end 56 | 57 | def first_chunk_duration 58 | if @first_chunk_time 59 | @first_chunk_time - @start_time 60 | end 61 | end 62 | 63 | def close(error = nil) 64 | complete_statistics(error) 65 | 66 | super 67 | end 68 | 69 | def read 70 | chunk = super 71 | 72 | @first_chunk_time ||= Clock.now 73 | 74 | if chunk 75 | @sent += chunk.bytesize 76 | end 77 | 78 | return chunk 79 | end 80 | 81 | def to_s 82 | parts = ["sent #{@sent} bytes"] 83 | 84 | if duration = self.total_duration 85 | parts << "took #{format_duration(duration)} in total" 86 | end 87 | 88 | if duration = self.first_chunk_duration 89 | parts << "took #{format_duration(duration)} until first chunk" 90 | end 91 | 92 | return parts.join("; ") 93 | end 94 | 95 | def inspect 96 | "#{super} | \#<#{self.class} #{self.to_s}>" 97 | end 98 | 99 | private 100 | 101 | def complete_statistics(error = nil) 102 | @end_time = Clock.now 103 | 104 | @callback.call(self, error) if @callback 105 | end 106 | 107 | def format_duration(seconds) 108 | if seconds < 1.0 109 | return "#{(seconds * 1000.0).round(2)}ms" 110 | else 111 | return "#{seconds.round(1)}s" 112 | end 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/async/http/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | module Async 7 | module HTTP 8 | VERSION = "0.89.0" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2017-2025, by Samuel Williams. 4 | Copyright, 2018, by Viacheslav Koval. 5 | Copyright, 2018, by Janko Marohnić. 6 | Copyright, 2019, by Denis Talakevich. 7 | Copyright, 2019-2020, by Brian Morearty. 8 | Copyright, 2019, by Cyril Roelandt. 9 | Copyright, 2020, by Stefan Wrobel. 10 | Copyright, 2020-2024, by Igor Sidorov. 11 | Copyright, 2020, by Bruno Sutic. 12 | Copyright, 2020, by Sam Shadwell. 13 | Copyright, 2020, by Orgad Shaneh. 14 | Copyright, 2021, by Trevor Turk. 15 | Copyright, 2021, by Olle Jonsson. 16 | Copyright, 2021-2022, by Adam Daniels. 17 | Copyright, 2022, by Ian Ker-Seymer. 18 | Copyright, 2022, by Marco Concetto Rudilosso. 19 | Copyright, 2022, by Tim Meusel. 20 | Copyright, 2023-2024, by Thomas Morgan. 21 | Copyright, 2023, by dependabot[bot]. 22 | Copyright, 2023, by Josh Huber. 23 | Copyright, 2024, by Anton Zhuravsky. 24 | Copyright, 2024, by Hal Brodigan. 25 | Copyright, 2025, by Jean Boussier. 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining a copy 28 | of this software and associated documentation files (the "Software"), to deal 29 | in the Software without restriction, including without limitation the rights 30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | copies of the Software, and to permit persons to whom the Software is 32 | furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in all 35 | copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 43 | SOFTWARE. 44 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Async::HTTP 2 | 3 | An asynchronous client and server implementation of HTTP/1.0, HTTP/1.1 and HTTP/2 including TLS. Support for streaming requests and responses. Built on top of [async](https://github.com/socketry/async) and [async-io](https://github.com/socketry/async-io). [falcon](https://github.com/socketry/falcon) provides a rack-compatible server. 4 | 5 | [![Development Status](https://github.com/socketry/async-http/workflows/Test/badge.svg)](https://github.com/socketry/async-http/actions?workflow=Test) 6 | 7 | ## Usage 8 | 9 | Please see the [project documentation](https://socketry.github.io/async-http/) for more details. 10 | 11 | - [Getting Started](https://socketry.github.io/async-http/guides/getting-started/index) - This guide explains how to get started with `Async::HTTP`. 12 | 13 | - [Testing](https://socketry.github.io/async-http/guides/testing/index) - This guide explains how to use `Async::HTTP` clients and servers in your tests. 14 | 15 | ## Releases 16 | 17 | Please see the [project releases](https://socketry.github.io/async-http/releases/index) for all releases. 18 | 19 | ### v0.88.0 20 | 21 | - [Support custom protocols with options](https://socketry.github.io/async-http/releases/index#support-custom-protocols-with-options) 22 | 23 | ### v0.87.0 24 | 25 | - [Unify HTTP/1 and HTTP/2 `CONNECT` semantics](https://socketry.github.io/async-http/releases/index#unify-http/1-and-http/2-connect-semantics) 26 | 27 | ### v0.86.0 28 | 29 | - Add support for HTTP/2 `NO_RFC7540_PRIORITIES`. See for more details. 30 | 31 | ### v0.84.0 32 | 33 | - Minor consistency fixes to `Async::HTTP::Internet` singleton methods. 34 | 35 | ### v0.82.0 36 | 37 | - `protocol-http1` introduces a line length limit for request line, response line, header lines and chunk length lines. 38 | 39 | ### v0.81.0 40 | 41 | - Expose `protocol` and `endpoint` as tags to `async-pool` for improved instrumentation. 42 | 43 | ### v0.77.0 44 | 45 | - Improved HTTP/1 connection handling. 46 | - The input stream is no longer closed when the output stream is closed. 47 | 48 | ### v0.76.0 49 | 50 | - `Async::HTTP::Body::Writable` is moved to `Protocol::HTTP::Body::Writable`. 51 | - Remove `Async::HTTP::Body::Delayed` with no replacement. 52 | - Remove `Async::HTTP::Body::Slowloris` with no replacement. 53 | 54 | ### v0.75.0 55 | 56 | - Better handling of HTTP/1 \<-\> HTTP/2 proxying, specifically upgrade/CONNECT requests. 57 | 58 | ### v0.74.0 59 | 60 | - [`Async::HTTP::Internet` accepts keyword arguments](https://socketry.github.io/async-http/releases/index#async::http::internet-accepts-keyword-arguments) 61 | 62 | ## See Also 63 | 64 | - [benchmark-http](https://github.com/socketry/benchmark-http) — A benchmarking tool to report on web server concurrency. 65 | - [falcon](https://github.com/socketry/falcon) — A rack compatible server built on top of `async-http`. 66 | - [async-websocket](https://github.com/socketry/async-websocket) — Asynchronous client and server websockets. 67 | - [async-rest](https://github.com/socketry/async-rest) — A RESTful resource layer built on top of `async-http`. 68 | - [async-http-faraday](https://github.com/socketry/async-http-faraday) — A faraday adapter to use `async-http`. 69 | 70 | ## Contributing 71 | 72 | We welcome contributions to this project. 73 | 74 | 1. Fork it. 75 | 2. Create your feature branch (`git checkout -b my-new-feature`). 76 | 3. Commit your changes (`git commit -am 'Add some feature'`). 77 | 4. Push to the branch (`git push origin my-new-feature`). 78 | 5. Create new Pull Request. 79 | 80 | ### Developer Certificate of Origin 81 | 82 | 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. 83 | 84 | ### Community Guidelines 85 | 86 | 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. 87 | -------------------------------------------------------------------------------- /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.88.0 4 | 5 | ### Support custom protocols with options 6 | 7 | {ruby Async::HTTP::Protocol} contains classes for specific protocols, e.g. {ruby Async::HTTP::Protocol::HTTP1} and {ruby Async::HTTP::Protocol::HTTP2}. It also contains classes for aggregating protocols, e.g. {ruby Async::HTTP::Protocol::HTTP} and {ruby Async::HTTP::Protocol::HTTPS}. They serve as factories for creating client and server instances. 8 | 9 | These classes are now configurable with various options, which are passed as keyword arguments to the relevant connection classes. For example, to configure an HTTP/1.1 protocol without keep-alive: 10 | 11 | ``` ruby 12 | protocol = Async::HTTP::Protocol::HTTP1.new(persistent: false, maximum_line_length: 32) 13 | endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292", protocol: protocol) 14 | server = Async::HTTP::Server.for(endpoint) do |request| 15 | Protocol::HTTP::Response[200, {}, ["Hello, world"]] 16 | end.run 17 | ``` 18 | 19 | Making a request to the server will now close the connection after the response is received: 20 | 21 | > curl -v http://localhost:9292 22 | * Host localhost:9292 was resolved. 23 | * IPv6: ::1 24 | * IPv4: 127.0.0.1 25 | * Trying [::1]:9292... 26 | * Connected to localhost (::1) port 9292 27 | * using HTTP/1.x 28 | > GET / HTTP/1.1 29 | > Host: localhost:9292 30 | > User-Agent: curl/8.12.1 31 | > Accept: */* 32 | > 33 | * Request completely sent off 34 | < HTTP/1.1 200 OK 35 | < connection: close 36 | < content-length: 12 37 | < 38 | * shutting down connection #0 39 | Hello, world 40 | 41 | In addition, any line longer than 32 bytes will be rejected: 42 | 43 | curl -v http://localhost:9292/012345678901234567890123456789012 44 | * Host localhost:9292 was resolved. 45 | * IPv6: ::1 46 | * IPv4: 127.0.0.1 47 | * Trying [::1]:9292... 48 | * Connected to localhost (::1) port 9292 49 | * using HTTP/1.x 50 | > GET /012345678901234567890123456789012 HTTP/1.1 51 | > Host: localhost:9292 52 | > User-Agent: curl/8.12.1 53 | > Accept: */* 54 | > 55 | * Request completely sent off 56 | * Empty reply from server 57 | * shutting down connection #0 58 | curl: (52) Empty reply from server 59 | 60 | ## v0.87.0 61 | 62 | ### Unify HTTP/1 and HTTP/2 `CONNECT` semantics 63 | 64 | HTTP/1 has a request line "target" which takes different forms depending on the kind of request. For `CONNECT` requests, the target is the authority (host and port) of the target server, e.g. 65 | 66 | CONNECT example.com:443 HTTP/1.1 67 | 68 | In HTTP/2, the `CONNECT` method uses the `:authority` pseudo-header to specify the target, e.g. 69 | 70 | ``` http 71 | [HEADERS FRAME] 72 | :method: connect 73 | :authority: example.com:443 74 | ``` 75 | 76 | In HTTP/1, the `Request#path` attribute was previously used to store the target, and this was incorrectly mapped to the `:path` pseudo-header in HTTP/2. This has been corrected, and the `Request#authority` attribute is now used to store the target for both HTTP/1 and HTTP/2, and mapped accordingly. Thus, to make a `CONNECT` request, you should set the `Request#authority` attribute, e.g. 77 | 78 | ``` ruby 79 | response = client.connect(authority: "example.com:443") 80 | ``` 81 | 82 | For HTTP/1, the authority is mapped back to the request line target, and for HTTP/2, it is mapped to the `:authority` pseudo-header. 83 | 84 | ## v0.86.0 85 | 86 | - Add support for HTTP/2 `NO_RFC7540_PRIORITIES`. See for more details. 87 | 88 | ## v0.84.0 89 | 90 | - Minor consistency fixes to `Async::HTTP::Internet` singleton methods. 91 | 92 | ## v0.82.0 93 | 94 | - `protocol-http1` introduces a line length limit for request line, response line, header lines and chunk length lines. 95 | 96 | ## v0.81.0 97 | 98 | - Expose `protocol` and `endpoint` as tags to `async-pool` for improved instrumentation. 99 | 100 | ## v0.77.0 101 | 102 | - Improved HTTP/1 connection handling. 103 | - The input stream is no longer closed when the output stream is closed. 104 | 105 | ## v0.76.0 106 | 107 | - `Async::HTTP::Body::Writable` is moved to `Protocol::HTTP::Body::Writable`. 108 | - Remove `Async::HTTP::Body::Delayed` with no replacement. 109 | - Remove `Async::HTTP::Body::Slowloris` with no replacement. 110 | 111 | ## v0.75.0 112 | 113 | - Better handling of HTTP/1 \<-\> HTTP/2 proxying, specifically upgrade/CONNECT requests. 114 | 115 | ## v0.74.0 116 | 117 | ### `Async::HTTP::Internet` accepts keyword arguments 118 | 119 | `Async::HTTP::Internet` now accepts keyword arguments for making a request, e.g. 120 | 121 | ``` ruby 122 | internet = Async::HTTP::Internet.instance 123 | 124 | # This will let you override the authority (HTTP/1.1 host header, HTTP/2 :authority header): 125 | internet.get("https://proxy.local", authority: "example.com") 126 | 127 | # This will let you override the scheme: 128 | internet.get("https://example.com", scheme: "http") 129 | ``` 130 | 131 | ## v0.73.0 132 | 133 | ### Update support for `interim_response` 134 | 135 | `Protocol::HTTP::Request` now supports an `interim_response` callback, which will be called with the interim response status and headers. This works on both the client and the server: 136 | 137 | ``` ruby 138 | # Server side: 139 | def call(request) 140 | if request.headers['expect'].include?('100-continue') 141 | request.send_interim_response(100) 142 | end 143 | 144 | # ... 145 | end 146 | 147 | # Client side: 148 | body = Async::HTTP::Body::Writable.new 149 | 150 | interim_repsonse = proc do |status, headers| 151 | if status == 100 152 | # Continue sending the body... 153 | body.write("Hello, world!") 154 | body.close 155 | end 156 | end 157 | 158 | Async::HTTP::Internet.instance.post("https://example.com", body, interim_response: interim_response) do |response| 159 | unless response.success? 160 | body.close 161 | end 162 | end 163 | ``` 164 | -------------------------------------------------------------------------------- /test/async/http/body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async/http/body" 7 | 8 | require "sus/fixtures/async" 9 | require "sus/fixtures/openssl" 10 | require "sus/fixtures/async/http" 11 | require "localhost/authority" 12 | require "io/endpoint/ssl_endpoint" 13 | 14 | ABody = Sus::Shared("a body") do 15 | with "echo server" do 16 | let(:app) do 17 | Protocol::HTTP::Middleware.for do |request| 18 | input = request.body 19 | output = Async::HTTP::Body::Writable.new 20 | 21 | Async::Task.current.async do |task| 22 | input.each do |chunk| 23 | output.write(chunk.reverse) 24 | end 25 | 26 | output.close_write 27 | end 28 | 29 | Protocol::HTTP::Response[200, [], output] 30 | end 31 | end 32 | 33 | it "can stream requests" do 34 | output = Async::HTTP::Body::Writable.new 35 | 36 | reactor.async do |task| 37 | output.write("Hello World!") 38 | output.close_write 39 | end 40 | 41 | response = client.post("/", {}, output) 42 | 43 | expect(response).to be(:success?) 44 | expect(response.read).to be == "!dlroW olleH" 45 | end 46 | end 47 | 48 | with "streaming server" do 49 | let(:notification) {Async::Notification.new} 50 | 51 | let(:app) do 52 | Protocol::HTTP::Middleware.for do |request| 53 | body = Async::HTTP::Body::Writable.new 54 | 55 | Async::Task.current.async do |task| 56 | 10.times do |i| 57 | body.write("#{i}") 58 | notification.wait 59 | end 60 | 61 | body.close_write 62 | end 63 | 64 | Protocol::HTTP::Response[200, {}, body] 65 | end 66 | end 67 | 68 | it "can stream response" do 69 | response = client.get("/") 70 | 71 | expect(response).to be(:success?) 72 | 73 | j = 0 74 | # This validates interleaving 75 | response.body.each do |line| 76 | expect(line.to_i).to be == j 77 | j += 1 78 | 79 | notification.signal 80 | end 81 | end 82 | end 83 | end 84 | 85 | describe Async::HTTP::Protocol::HTTP1 do 86 | include Sus::Fixtures::Async::HTTP::ServerContext 87 | 88 | it_behaves_like ABody 89 | end 90 | 91 | describe Async::HTTP::Protocol::HTTPS do 92 | include Sus::Fixtures::Async::HTTP::ServerContext 93 | include Sus::Fixtures::OpenSSL::ValidCertificateContext 94 | 95 | let(:authority) {Localhost::Authority.new} 96 | 97 | let(:server_context) {authority.server_context} 98 | let(:client_context) {authority.client_context} 99 | 100 | def make_server_endpoint(bound_endpoint) 101 | ::IO::Endpoint::SSLEndpoint.new(super, ssl_context: server_context) 102 | end 103 | 104 | def make_client_endpoint(bound_endpoint) 105 | ::IO::Endpoint::SSLEndpoint.new(super, ssl_context: client_context) 106 | end 107 | 108 | it_behaves_like ABody 109 | end 110 | -------------------------------------------------------------------------------- /test/async/http/body/hijack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "async/http/body/hijack" 7 | 8 | require "sus/fixtures/async" 9 | 10 | describe Async::HTTP::Body::Hijack do 11 | include Sus::Fixtures::Async::ReactorContext 12 | 13 | let(:body) do 14 | subject.wrap do |stream| 15 | 3.times do 16 | stream.write(content) 17 | end 18 | stream.close_write 19 | end 20 | end 21 | 22 | let(:content) {"Hello World!"} 23 | 24 | with "#call" do 25 | let(:stream) {Async::HTTP::Body::Writable.new} 26 | 27 | it "should generate body using direct invocation" do 28 | body.call(stream) 29 | 30 | 3.times do 31 | expect(stream.read).to be == content 32 | end 33 | 34 | expect(stream.read).to be_nil 35 | expect(stream).to be(:empty?) 36 | end 37 | 38 | it "should generate body using stream" do 39 | 3.times do 40 | expect(body.read).to be == content 41 | end 42 | 43 | expect(body.read).to be_nil 44 | 45 | expect(body).to be(:empty?) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/async/http/body/pipe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020, by Bruno Sutic. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/http/body/pipe" 9 | require "async/http/body/writable" 10 | 11 | require "sus/fixtures/async" 12 | 13 | describe Async::HTTP::Body::Pipe do 14 | let(:input) {Async::HTTP::Body::Writable.new} 15 | let(:pipe) {subject.new(input)} 16 | 17 | let(:data) {"Hello World!"} 18 | 19 | with "#to_io" do 20 | include Sus::Fixtures::Async::ReactorContext 21 | 22 | let(:input_write_duration) {0} 23 | let(:io) {pipe.to_io} 24 | 25 | def before 26 | super 27 | 28 | # input writer task 29 | Async do |task| 30 | first, second = data.split(" ") 31 | input.write("#{first} ") 32 | sleep(input_write_duration) if input_write_duration > 0 33 | input.write(second) 34 | input.close_write 35 | end 36 | end 37 | 38 | after do 39 | io.close 40 | end 41 | 42 | it "returns an io socket" do 43 | expect(io).to be_a(::Socket) 44 | expect(io.read).to be == data 45 | end 46 | 47 | with "blocking reads" do 48 | let(:input_write_duration) {0.01} 49 | 50 | it "returns an io socket" do 51 | expect(io.read).to be == data 52 | end 53 | end 54 | end 55 | 56 | with "reactor going out of scope" do 57 | it "finishes" do 58 | # ensures pipe background tasks are transient 59 | Async{pipe} 60 | end 61 | 62 | with "closed pipe" do 63 | it "finishes" do 64 | Async{pipe.close} 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/async/http/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | 6 | require "async/http/server" 7 | require "async/http/client" 8 | require "async/reactor" 9 | 10 | require "async/http/endpoint" 11 | require "protocol/http/accept_encoding" 12 | 13 | require "sus/fixtures/async" 14 | require "sus/fixtures/async/http" 15 | 16 | describe Async::HTTP::Client do 17 | with "basic server" do 18 | include Sus::Fixtures::Async::HTTP::ServerContext 19 | 20 | it "client can get resource" do 21 | response = client.get("/") 22 | response.read 23 | expect(response).to be(:success?) 24 | end 25 | 26 | with "client" do 27 | with "#as_json" do 28 | it "generates a JSON representation" do 29 | expect(client.as_json).to be == { 30 | endpoint: client.endpoint.to_s, 31 | protocol: client.protocol, 32 | retries: client.retries, 33 | scheme: endpoint.scheme, 34 | authority: endpoint.authority, 35 | } 36 | end 37 | 38 | it "generates a JSON string" do 39 | expect(JSON.dump(client)).to be == client.to_json 40 | end 41 | end 42 | end 43 | 44 | with "server" do 45 | with "#as_json" do 46 | it "generates a JSON representation" do 47 | expect(server.as_json).to be == { 48 | endpoint: server.endpoint.to_s, 49 | protocol: server.protocol, 50 | scheme: server.scheme, 51 | } 52 | end 53 | 54 | it "generates a JSON string" do 55 | expect(JSON.dump(server)).to be == server.to_json 56 | end 57 | end 58 | end 59 | end 60 | 61 | with "non-existant host" do 62 | include Sus::Fixtures::Async::ReactorContext 63 | 64 | let(:endpoint) {Async::HTTP::Endpoint.parse("http://the.future")} 65 | let(:client) {Async::HTTP::Client.new(endpoint)} 66 | 67 | it "should fail to connect" do 68 | expect do 69 | client.get("/") 70 | end.to raise_exception(SocketError, message: be =~ /not known/) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/async/http/client/google.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async/http/client" 7 | require "async/http/endpoint" 8 | 9 | require "protocol/http/accept_encoding" 10 | 11 | require "sus/fixtures/async" 12 | 13 | describe Async::HTTP::Client do 14 | include Sus::Fixtures::Async::ReactorContext 15 | 16 | let(:endpoint) {Async::HTTP::Endpoint.parse("https://www.google.com")} 17 | let(:client) {Async::HTTP::Client.new(endpoint)} 18 | 19 | it "should specify a hostname" do 20 | expect(endpoint.hostname).to be == "www.google.com" 21 | expect(client.authority).to be == "www.google.com" 22 | end 23 | 24 | it "can fetch remote resource" do 25 | response = client.get("/", {"accept" => "*/*"}) 26 | 27 | response.finish 28 | 29 | expect(response).not.to be(:failure?) 30 | 31 | client.close 32 | end 33 | 34 | it "can request remote resource with compression" do 35 | compressor = Protocol::HTTP::AcceptEncoding.new(client) 36 | 37 | response = compressor.get("/", {"accept-encoding" => "gzip"}) 38 | 39 | expect(response).to be(:success?) 40 | 41 | expect(response.body).to be_a Async::HTTP::Body::Inflate 42 | expect(response.read).to be(:start_with?, "") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/async/http/endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2021-2022, by Adam Daniels. 6 | # Copyright, 2024, by Thomas Morgan. 7 | 8 | require "async/http/endpoint" 9 | 10 | describe Async::HTTP::Endpoint do 11 | it "should fail to parse relative url" do 12 | expect do 13 | subject.parse("/foo/bar") 14 | end.to raise_exception(ArgumentError, message: be =~ /absolute/) 15 | end 16 | 17 | with "#port" do 18 | let(:url_string) {"https://localhost:9292"} 19 | 20 | it "extracts port from URL" do 21 | endpoint = Async::HTTP::Endpoint.parse(url_string) 22 | 23 | expect(endpoint).to have_attributes(port: be == 9292) 24 | end 25 | 26 | it "extracts port from options" do 27 | endpoint = Async::HTTP::Endpoint.parse(url_string, port: 9000) 28 | 29 | expect(endpoint).to have_attributes(port: be == 9000) 30 | end 31 | end 32 | 33 | with "#hostname" do 34 | describe Async::HTTP::Endpoint.parse("https://127.0.0.1:9292") do 35 | it "has correct hostname" do 36 | expect(subject).to have_attributes(hostname: be == "127.0.0.1") 37 | end 38 | 39 | it "should be connecting to 127.0.0.1" do 40 | expect(subject.endpoint).to be_a ::IO::Endpoint::SSLEndpoint 41 | expect(subject.endpoint).to have_attributes(hostname: be == "127.0.0.1") 42 | expect(subject.endpoint.endpoint).to have_attributes(hostname: be == "127.0.0.1") 43 | end 44 | end 45 | 46 | describe Async::HTTP::Endpoint.parse("https://127.0.0.1:9292", hostname: "localhost") do 47 | it "has correct hostname" do 48 | expect(subject).to have_attributes(hostname: be == "localhost") 49 | expect(subject).not.to be(:localhost?) 50 | end 51 | 52 | it "should be connecting to localhost" do 53 | expect(subject.endpoint).to be_a ::IO::Endpoint::SSLEndpoint 54 | expect(subject.endpoint).to have_attributes(hostname: be == "127.0.0.1") 55 | expect(subject.endpoint.endpoint).to have_attributes(hostname: be == "localhost") 56 | end 57 | end 58 | end 59 | 60 | with ".for" do 61 | describe Async::HTTP::Endpoint.for("http", "localhost") do 62 | it "should have correct attributes" do 63 | expect(subject).to have_attributes( 64 | scheme: be == "http", 65 | hostname: be == "localhost", 66 | path: be == "/" 67 | ) 68 | 69 | expect(subject).not.to be(:secure?) 70 | end 71 | end 72 | 73 | describe Async::HTTP::Endpoint.for("http", "localhost", "/foo") do 74 | it "should have correct attributes" do 75 | expect(subject).to have_attributes( 76 | scheme: be == "http", 77 | hostname: be == "localhost", 78 | path: be == "/foo" 79 | ) 80 | 81 | expect(subject).not.to be(:secure?) 82 | end 83 | end 84 | 85 | with "invalid scheme" do 86 | it "should raise an argument error" do 87 | expect do 88 | Async::HTTP::Endpoint.for("foo", "localhost") 89 | end.to raise_exception(ArgumentError, message: be =~ /Unsupported scheme: "foo"/) 90 | 91 | expect do 92 | Async::HTTP::Endpoint.for(:http, "localhost", "/foo") 93 | end.to raise_exception(ArgumentError, message: be =~ /Unsupported scheme: :http/) 94 | end 95 | end 96 | end 97 | 98 | with "#secure?" do 99 | describe Async::HTTP::Endpoint.parse("http://localhost") do 100 | it "should not be secure" do 101 | expect(subject).not.to be(:secure?) 102 | end 103 | end 104 | 105 | describe Async::HTTP::Endpoint.parse("https://localhost") do 106 | it "should be secure" do 107 | expect(subject).to be(:secure?) 108 | end 109 | end 110 | 111 | with "scheme: https" do 112 | describe Async::HTTP::Endpoint.parse("http://localhost", scheme: "https") do 113 | it "should be secure" do 114 | expect(subject).to be(:secure?) 115 | end 116 | end 117 | end 118 | end 119 | 120 | with "#localhost?" do 121 | describe Async::HTTP::Endpoint.parse("http://localhost") do 122 | it "should be localhost" do 123 | expect(subject).to be(:localhost?) 124 | end 125 | end 126 | 127 | describe Async::HTTP::Endpoint.parse("http://hello.localhost") do 128 | it "should be localhost" do 129 | expect(subject).to be(:localhost?) 130 | end 131 | end 132 | 133 | describe Async::HTTP::Endpoint.parse("http://localhost.") do 134 | it "should be localhost" do 135 | expect(subject).to be(:localhost?) 136 | end 137 | end 138 | 139 | describe Async::HTTP::Endpoint.parse("http://hello.localhost.") do 140 | it "should be localhost" do 141 | expect(subject).to be(:localhost?) 142 | end 143 | end 144 | 145 | describe Async::HTTP::Endpoint.parse("http://localhost.com") do 146 | it "should not be localhost" do 147 | expect(subject).not.to be(:localhost?) 148 | end 149 | end 150 | end 151 | 152 | with "#path" do 153 | describe Async::HTTP::Endpoint.parse("http://foo.com/bar?baz") do 154 | it "should have correct path" do 155 | expect(subject).to have_attributes(path: be == "/bar?baz") 156 | end 157 | end 158 | 159 | with "websocket scheme" do 160 | describe Async::HTTP::Endpoint.parse("wss://foo.com/bar?baz") do 161 | it "should have correct path" do 162 | expect(subject).to have_attributes(path: be == "/bar?baz") 163 | end 164 | end 165 | end 166 | end 167 | end 168 | 169 | describe Async::HTTP::Endpoint.parse("http://www.google.com/search") do 170 | it "should select the correct protocol" do 171 | expect(subject.protocol).to be == Async::HTTP::Protocol::HTTP 172 | end 173 | 174 | it "should parse the correct hostname" do 175 | expect(subject).to have_attributes( 176 | scheme: be == "http", 177 | hostname: be == "www.google.com", 178 | path: be == "/search" 179 | ) 180 | end 181 | 182 | it "should not be equal if path is different" do 183 | other = Async::HTTP::Endpoint.parse("http://www.google.com/search?q=ruby") 184 | expect(subject).not.to be == other 185 | expect(subject).not.to be(:eql?, other) 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /test/async/http/internet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2024, by Igor Sidorov. 6 | # Copyright, 2024, by Hal Brodigan. 7 | 8 | require "async/http/internet" 9 | require "async/reactor" 10 | 11 | require "json" 12 | require "sus/fixtures/async" 13 | 14 | describe Async::HTTP::Internet do 15 | include Sus::Fixtures::Async::ReactorContext 16 | 17 | let(:internet) {subject.new} 18 | let(:headers) {[["accept", "*/*"], ["user-agent", "async-http"]]} 19 | 20 | it "can fetch remote website" do 21 | response = internet.get("https://www.google.com/", headers) 22 | 23 | expect(response).to be(:success?) 24 | 25 | response.close 26 | end 27 | 28 | it "can accept URI::HTTP objects" do 29 | uri = URI.parse("https://www.google.com/") 30 | response = internet.get(uri, headers) 31 | 32 | expect(response).to be(:success?) 33 | ensure 34 | response&.close 35 | end 36 | 37 | let(:sample) {{"hello" => "world"}} 38 | let(:body) {[JSON.dump(sample)]} 39 | 40 | # This test is increasingly flakey. 41 | it "can fetch remote json" do 42 | response = internet.post("https://httpbin.org/anything", headers, body) 43 | 44 | expect(response).to be(:success?) 45 | expect{JSON.parse(response.read)}.not.to raise_exception 46 | end 47 | 48 | it "can fetch remote website when given custom endpoint instead of url" do 49 | ssl_context = OpenSSL::SSL::SSLContext.new 50 | ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_NONE) 51 | 52 | # example of site with invalid certificate that will fail to be fetched without custom SSL options 53 | endpoint = Async::HTTP::Endpoint.parse("https://expired.badssl.com", ssl_context: ssl_context) 54 | 55 | response = internet.get(endpoint, headers) 56 | 57 | expect(response).to be(:success?) 58 | ensure 59 | response&.close 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/async/http/internet/instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require "async/http/internet/instance" 7 | 8 | describe Async::HTTP::Internet do 9 | describe ".instance" do 10 | it "returns an internet instance" do 11 | expect(Async::HTTP::Internet.instance).to be_a(Async::HTTP::Internet) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/async/http/middleware/location_redirector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2019-2020, by Brian Morearty. 6 | 7 | require "async/http/middleware/location_redirector" 8 | require "async/http/server" 9 | 10 | require "sus/fixtures/async/http" 11 | 12 | describe Async::HTTP::Middleware::LocationRedirector do 13 | include Sus::Fixtures::Async::HTTP::ServerContext 14 | 15 | let(:relative_location) {subject.new(@client, 1)} 16 | 17 | with "server redirections" do 18 | with "301" do 19 | let(:app) do 20 | Protocol::HTTP::Middleware.for do |request| 21 | case request.path 22 | when "/home" 23 | Protocol::HTTP::Response[301, {"location" => "/"}, []] 24 | when "/" 25 | Protocol::HTTP::Response[301, {"location" => "/index.html"}, []] 26 | when "/index.html" 27 | Protocol::HTTP::Response[200, {}, [request.method]] 28 | end 29 | end 30 | end 31 | 32 | it "should redirect POST to GET" do 33 | body = Protocol::HTTP::Body::Buffered.wrap(["Hello, World!"]) 34 | expect(body).to receive(:finish) 35 | 36 | response = relative_location.post("/", {}, body) 37 | 38 | expect(response).to be(:success?) 39 | expect(response.read).to be == "GET" 40 | end 41 | 42 | with "limiting redirects" do 43 | it "should allow the maximum number of redirects" do 44 | response = relative_location.get("/") 45 | response.finish 46 | expect(response).to be(:success?) 47 | end 48 | 49 | it "should fail with maximum redirects" do 50 | expect do 51 | response = relative_location.get("/home") 52 | end.to raise_exception(subject::TooManyRedirects, message: be =~ /maximum/) 53 | end 54 | end 55 | 56 | with "handle_redirect returning false" do 57 | before do 58 | expect(relative_location).to receive(:handle_redirect).and_return(false) 59 | end 60 | 61 | it "should not follow the redirect" do 62 | response = relative_location.get("/") 63 | response.finish 64 | 65 | expect(response).to be(:redirection?) 66 | end 67 | end 68 | end 69 | 70 | with "302" do 71 | let(:app) do 72 | Protocol::HTTP::Middleware.for do |request| 73 | case request.path 74 | when "/" 75 | Protocol::HTTP::Response[302, {"location" => "/index.html"}, []] 76 | when "/index.html" 77 | Protocol::HTTP::Response[200, {}, [request.method]] 78 | end 79 | end 80 | end 81 | 82 | it "should redirect POST to GET" do 83 | response = relative_location.post("/") 84 | 85 | expect(response).to be(:success?) 86 | expect(response.read).to be == "GET" 87 | end 88 | end 89 | 90 | with "307" do 91 | let(:app) do 92 | Protocol::HTTP::Middleware.for do |request| 93 | case request.path 94 | when "/" 95 | Protocol::HTTP::Response[307, {"location" => "/index.html"}, []] 96 | when "/index.html" 97 | Protocol::HTTP::Response[200, {}, [request.method]] 98 | end 99 | end 100 | end 101 | 102 | it "should redirect with same method" do 103 | response = relative_location.post("/") 104 | 105 | expect(response).to be(:success?) 106 | expect(response.read).to be == "POST" 107 | end 108 | end 109 | 110 | with "308" do 111 | let(:app) do 112 | Protocol::HTTP::Middleware.for do |request| 113 | case request.path 114 | when "/" 115 | Protocol::HTTP::Response[308, {"location" => "/index.html"}, []] 116 | when "/index.html" 117 | Protocol::HTTP::Response[200, {}, [request.method]] 118 | end 119 | end 120 | end 121 | 122 | it "should redirect with same method" do 123 | response = relative_location.post("/") 124 | 125 | expect(response).to be(:success?) 126 | expect(response.read).to be == "POST" 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/async/http/mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/http/mock" 7 | require "async/http/endpoint" 8 | require "async/http/client" 9 | 10 | require "sus/fixtures/async/reactor_context" 11 | 12 | describe Async::HTTP::Mock do 13 | include Sus::Fixtures::Async::ReactorContext 14 | 15 | let(:endpoint) {Async::HTTP::Mock::Endpoint.new} 16 | 17 | it "can respond to requests" do 18 | server = Async do 19 | endpoint.run do |request| 20 | ::Protocol::HTTP::Response[200, [], ["Hello World"]] 21 | end 22 | end 23 | 24 | client = Async::HTTP::Client.new(endpoint) 25 | 26 | response = client.get("/index") 27 | 28 | expect(response).to be(:success?) 29 | expect(response.read).to be == "Hello World" 30 | end 31 | 32 | with "mocked client" do 33 | it "can mock a client" do 34 | server = Async do 35 | endpoint.run do |request| 36 | ::Protocol::HTTP::Response[200, [], ["Authority: #{request.authority}"]] 37 | end 38 | end 39 | 40 | mock(Async::HTTP::Client) do |mock| 41 | replacement_endpoint = self.endpoint 42 | 43 | mock.wrap(:new) do |original, original_endpoint, **options| 44 | original.call(replacement_endpoint.wrap(original_endpoint), **options) 45 | end 46 | end 47 | 48 | google_endpoint = Async::HTTP::Endpoint.parse("https://www.google.com") 49 | client = Async::HTTP::Client.new(google_endpoint) 50 | 51 | response = client.get("/search?q=hello") 52 | 53 | expect(response).to be(:success?) 54 | expect(response.read).to be == "Authority: www.google.com" 55 | ensure 56 | response&.close 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/async/http/protocol/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Thomas Morgan. 5 | # Copyright, 2024-2025, by Samuel Williams. 6 | 7 | require "async/http/protocol/http" 8 | require "async/http/a_protocol" 9 | 10 | describe Async::HTTP::Protocol::HTTP do 11 | let(:protocol) {subject.default} 12 | 13 | with ".default" do 14 | it "has a default instance" do 15 | expect(protocol).to be_a Async::HTTP::Protocol::HTTP 16 | end 17 | end 18 | 19 | with "#protocol_for" do 20 | let(:buffer) {StringIO.new} 21 | 22 | it "it can detect http/1.1" do 23 | buffer.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") 24 | buffer.rewind 25 | 26 | stream = IO::Stream(buffer) 27 | 28 | expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP1 29 | end 30 | 31 | it "it can detect http/2" do 32 | # This special preface is used to indicate that the client would like to use HTTP/2. 33 | # https://www.rfc-editor.org/rfc/rfc7540.html#section-3.5 34 | buffer.write("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") 35 | buffer.rewind 36 | 37 | stream = IO::Stream(buffer) 38 | 39 | expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP2 40 | end 41 | end 42 | 43 | with "server" do 44 | include Sus::Fixtures::Async::HTTP::ServerContext 45 | let(:protocol) {subject} 46 | 47 | with "http11 client" do 48 | it "should make a successful request" do 49 | response = client.get("/") 50 | expect(response).to be(:success?) 51 | expect(response.version).to be == "HTTP/1.1" 52 | response.read 53 | end 54 | end 55 | 56 | with "http2 client" do 57 | def make_client(endpoint, **options) 58 | options[:protocol] = Async::HTTP::Protocol::HTTP2 59 | super 60 | end 61 | 62 | it "should make a successful request" do 63 | response = client.get("/") 64 | expect(response).to be(:success?) 65 | expect(response.version).to be == "HTTP/2" 66 | response.read 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/async/http/protocol/http1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "async/http/protocol/http" 7 | require "async/http/a_protocol" 8 | 9 | describe Async::HTTP::Protocol::HTTP1 do 10 | with ".new" do 11 | it "can configure the protocol" do 12 | protocol = subject.new( 13 | persistent: false, 14 | maximum_line_length: 4096, 15 | ) 16 | 17 | expect(protocol.options).to have_keys( 18 | persistent: be == false, 19 | maximum_line_length: be == 4096, 20 | ) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/async/http/protocol/http10.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async/http/protocol/http10" 7 | require "async/http/a_protocol" 8 | 9 | describe Async::HTTP::Protocol::HTTP10 do 10 | it_behaves_like Async::HTTP::AProtocol 11 | end 12 | -------------------------------------------------------------------------------- /test/async/http/protocol/http11.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2024, by Samuel Williams. 5 | # Copyright, 2018, by Janko Marohnić. 6 | # Copyright, 2023, by Thomas Morgan. 7 | # Copyright, 2023, by Josh Huber. 8 | # Copyright, 2024, by Anton Zhuravsky. 9 | 10 | require "async/http/protocol/http11" 11 | require "async/http/a_protocol" 12 | 13 | describe Async::HTTP::Protocol::HTTP11 do 14 | it_behaves_like Async::HTTP::AProtocol 15 | 16 | with "#as_json" do 17 | include Sus::Fixtures::Async::HTTP::ServerContext 18 | let(:protocol) {subject} 19 | 20 | it "generates a JSON representation" do 21 | response = client.get("/") 22 | connection = response.connection 23 | 24 | expect(connection.as_json).to be =~ /Async::HTTP::Protocol::HTTP1::Client negotiated HTTP/ 25 | ensure 26 | response&.close 27 | end 28 | 29 | it "generates a JSON string" do 30 | response = client.get("/") 31 | connection = response.connection 32 | 33 | expect(JSON.dump(connection)).to be == connection.to_json 34 | ensure 35 | response&.close 36 | end 37 | end 38 | 39 | with "server" do 40 | include Sus::Fixtures::Async::HTTP::ServerContext 41 | let(:protocol) {subject} 42 | 43 | with "bad requests" do 44 | def around 45 | current = Console.logger.level 46 | Console.logger.fatal! 47 | 48 | super 49 | ensure 50 | Console.logger.level = current 51 | end 52 | 53 | it "should fail cleanly when path is empty" do 54 | response = client.get("") 55 | 56 | expect(response.status).to be == 400 57 | end 58 | end 59 | 60 | with "head request" do 61 | let(:app) do 62 | Protocol::HTTP::Middleware.for do |request| 63 | Protocol::HTTP::Response[200, {}, ["Hello", "World"]] 64 | end 65 | end 66 | 67 | it "doesn't reply with body" do 68 | 5.times do 69 | response = client.head("/") 70 | 71 | expect(response).to be(:success?) 72 | expect(response.version).to be == "HTTP/1.1" 73 | expect(response.body).to be(:empty?) 74 | expect(response.reason).to be == "OK" 75 | 76 | response.read 77 | end 78 | end 79 | end 80 | 81 | with "raw response" do 82 | let(:app) do 83 | Protocol::HTTP::Middleware.for do |request| 84 | peer = request.hijack! 85 | 86 | peer.write( 87 | "#{request.version} 200 It worked!\r\n" + 88 | "connection: close\r\n" + 89 | "\r\n" + 90 | "Hello World!" 91 | ) 92 | peer.close 93 | 94 | nil 95 | end 96 | end 97 | 98 | it "reads raw response" do 99 | response = client.get("/") 100 | 101 | expect(response.read).to be == "Hello World!" 102 | end 103 | 104 | it "has access to the http reason phrase" do 105 | response = client.head("/") 106 | 107 | expect(response.reason).to be == "It worked!" 108 | end 109 | end 110 | 111 | with "full hijack with empty response" do 112 | let(:body) {::Protocol::HTTP::Body::Buffered.new([], 0)} 113 | 114 | let(:app) do 115 | ::Protocol::HTTP::Middleware.for do |request| 116 | peer = request.hijack! 117 | 118 | peer.write( 119 | "#{request.version} 200 It worked!\r\n" + 120 | "connection: close\r\n" + 121 | "\r\n" + 122 | "Hello World!" 123 | ) 124 | peer.close 125 | 126 | ::Protocol::HTTP::Response[-1, {}, body] 127 | end 128 | end 129 | 130 | it "works properly" do 131 | expect(body).to receive(:close) 132 | 133 | response = client.get("/") 134 | 135 | expect(response.read).to be == "Hello World!" 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /test/async/http/protocol/http11/desync.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require "async/http/protocol/http11" 7 | 8 | require "sus/fixtures/async/http/server_context" 9 | 10 | describe Async::HTTP::Protocol::HTTP11 do 11 | include Sus::Fixtures::Async::ReactorContext 12 | include Sus::Fixtures::Async::HTTP::ServerContext 13 | 14 | let(:app) do 15 | Protocol::HTTP::Middleware.for do |request| 16 | Protocol::HTTP::Response[200, {}, [request.path]] 17 | end 18 | end 19 | 20 | def around 21 | current = Console.logger.level 22 | Console.logger.fatal! 23 | 24 | super 25 | ensure 26 | Console.logger.level = current 27 | end 28 | 29 | it "doesn't desync responses" do 30 | tasks = [] 31 | task = Async::Task.current 32 | 33 | backtraces = [] 34 | 35 | 100.times do 36 | tasks << task.async{ 37 | loop do 38 | response = client.get("/a") 39 | expect(response.read).to be == "/a" 40 | rescue Exception => exception 41 | backtraces << exception&.backtrace 42 | raise 43 | ensure 44 | response&.close 45 | end 46 | } 47 | end 48 | 49 | 100.times do 50 | tasks << task.async{ 51 | loop do 52 | response = client.get("/b") 53 | expect(response.read).to be == "/b" 54 | rescue Exception => exception 55 | backtraces << exception&.backtrace 56 | raise 57 | ensure 58 | response&.close 59 | end 60 | } 61 | end 62 | 63 | tasks.each do |child| 64 | sleep 0.01 65 | child.stop 66 | end 67 | 68 | # puts "Backtraces" 69 | # pp backtraces.sort.uniq 70 | expect(backtraces.size).to be >= 0 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/async/http/protocol/http2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2025, by Samuel Williams. 5 | 6 | require "async/http/protocol/http2" 7 | require "async/http/a_protocol" 8 | 9 | describe Async::HTTP::Protocol::HTTP2 do 10 | it_behaves_like Async::HTTP::AProtocol 11 | 12 | with "#as_json" do 13 | include Sus::Fixtures::Async::HTTP::ServerContext 14 | let(:protocol) {subject} 15 | 16 | it "generates a JSON representation" do 17 | response = client.get("/") 18 | connection = response.connection 19 | 20 | expect(connection.as_json).to be =~ /#/ 21 | ensure 22 | response&.close 23 | end 24 | 25 | it "generates a JSON string" do 26 | response = client.get("/") 27 | connection = response.connection 28 | 29 | expect(JSON.dump(connection)).to be == connection.to_json 30 | ensure 31 | response&.close 32 | end 33 | end 34 | 35 | with "server" do 36 | include Sus::Fixtures::Async::HTTP::ServerContext 37 | let(:protocol) {subject} 38 | 39 | with "bad requests" do 40 | it "should fail with explicit authority" do 41 | expect do 42 | client.post("/", [[":authority", "foo"]]) 43 | end.to raise_exception(Protocol::HTTP2::StreamError) 44 | end 45 | end 46 | 47 | with "closed streams" do 48 | it "should delete stream after response stream is closed" do 49 | response = client.get("/") 50 | connection = response.connection 51 | 52 | response.read 53 | 54 | expect(connection.streams).to be(:empty?) 55 | end 56 | end 57 | 58 | with "host header" do 59 | let(:app) do 60 | Protocol::HTTP::Middleware.for do |request| 61 | Protocol::HTTP::Response[200, request.headers, ["Authority: #{request.authority.inspect}"]] 62 | end 63 | end 64 | 65 | def make_client(endpoint, **options) 66 | # We specify nil for the authority - it won't be sent. 67 | options[:authority] = nil 68 | super 69 | end 70 | 71 | it "should not send :authority header if host header is present" do 72 | response = client.post("/", [["host", "foo"]]) 73 | 74 | expect(response.headers).to have_keys("host") 75 | expect(response.headers["host"]).to be == "foo" 76 | 77 | # TODO Should HTTP/2 respect host header? 78 | expect(response.read).to be == "Authority: nil" 79 | end 80 | end 81 | 82 | with "stopping requests" do 83 | let(:notification) {Async::Notification.new} 84 | 85 | let(:app) do 86 | Protocol::HTTP::Middleware.for do |request| 87 | body = Async::HTTP::Body::Writable.new 88 | 89 | reactor.async do |task| 90 | begin 91 | 100.times do |i| 92 | body.write("Chunk #{i}") 93 | sleep (0.01) 94 | end 95 | rescue 96 | # puts "Response generation failed: #{$!}" 97 | ensure 98 | body.close 99 | notification.signal 100 | end 101 | end 102 | 103 | Protocol::HTTP::Response[200, {}, body] 104 | end 105 | end 106 | 107 | let(:pool) {client.pool} 108 | 109 | it "should close stream without closing connection" do 110 | expect(pool).to be(:empty?) 111 | 112 | response = client.get("/") 113 | 114 | expect(pool).not.to be(:empty?) 115 | 116 | response.close 117 | 118 | notification.wait 119 | 120 | expect(response.stream.connection).to be(:reusable?) 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /test/async/http/protocol/https.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "async/http/protocol/https" 7 | require "async/http/a_protocol" 8 | 9 | describe Async::HTTP::Protocol::HTTPS do 10 | let(:protocol) {subject.default} 11 | 12 | with ".default" do 13 | it "has a default instance" do 14 | expect(protocol).to be_a Async::HTTP::Protocol::HTTPS 15 | end 16 | 17 | it "supports http/1.0" do 18 | expect(protocol.names).to be(:include?, "http/1.0") 19 | end 20 | 21 | it "supports http/1.1" do 22 | expect(protocol.names).to be(:include?, "http/1.1") 23 | end 24 | 25 | it "supports h2" do 26 | expect(protocol.names).to be(:include?, "h2") 27 | end 28 | end 29 | 30 | with "#protocol_for" do 31 | let(:buffer) {StringIO.new} 32 | 33 | it "can detect http/1.0" do 34 | stream = IO::Stream(buffer) 35 | expect(stream).to receive(:alpn_protocol).and_return("http/1.0") 36 | 37 | expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP10 38 | end 39 | 40 | it "it can detect http/1.1" do 41 | stream = IO::Stream(buffer) 42 | expect(stream).to receive(:alpn_protocol).and_return("http/1.1") 43 | 44 | expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP11 45 | end 46 | 47 | it "it can detect http/2" do 48 | stream = IO::Stream(buffer) 49 | expect(stream).to receive(:alpn_protocol).and_return("h2") 50 | 51 | expect(protocol.protocol_for(stream)).to be == Async::HTTP::Protocol::HTTP2 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/async/http/retry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "async/http/client" 7 | require "async/http/endpoint" 8 | 9 | require "sus/fixtures/async/http" 10 | 11 | describe "consistent retry behaviour" do 12 | include Sus::Fixtures::Async::HTTP::ServerContext 13 | 14 | let(:delay) {0.1} 15 | let(:retries) {2} 16 | 17 | let(:app) do 18 | Protocol::HTTP::Middleware.for do |request| 19 | sleep(delay) 20 | Protocol::HTTP::Response[200, {}, []] 21 | end 22 | end 23 | 24 | def make_request(body) 25 | # This causes the first request to fail with "SocketError" which is retried: 26 | Async::Task.current.with_timeout(delay / 2.0, SocketError) do 27 | return client.get("/", {}, body) 28 | end 29 | end 30 | 31 | it "retries with nil body" do 32 | response = make_request(nil) 33 | expect(response).to be(:success?) 34 | end 35 | 36 | it "retries with empty body" do 37 | response = make_request([]) 38 | expect(response).to be(:success?) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/async/http/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/http/server" 7 | require "async/http/endpoint" 8 | require "sus/fixtures/async" 9 | 10 | describe Async::HTTP::Server do 11 | include Sus::Fixtures::Async::ReactorContext 12 | 13 | let(:endpoint) {Async::HTTP::Endpoint.parse("http://localhost:0")} 14 | let(:app) {Protocol::HTTP::Middleware::Okay} 15 | let(:server) {subject.new(app, endpoint)} 16 | 17 | with "#run" do 18 | it "runs the server" do 19 | task = server.run 20 | 21 | expect(task).to be_a(Async::Task) 22 | 23 | task.stop 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/async/http/ssl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async/http/server" 7 | require "async/http/client" 8 | require "async/http/endpoint" 9 | 10 | require "sus/fixtures/async" 11 | require "sus/fixtures/openssl" 12 | require "sus/fixtures/async/http" 13 | 14 | describe Async::HTTP::Server do 15 | include Sus::Fixtures::Async::HTTP::ServerContext 16 | include Sus::Fixtures::OpenSSL::ValidCertificateContext 17 | 18 | with "application layer protocol negotiation" do 19 | let(:server_context) do 20 | OpenSSL::SSL::SSLContext.new.tap do |context| 21 | context.cert = certificate 22 | 23 | context.alpn_select_cb = lambda do |protocols| 24 | protocols.last 25 | end 26 | 27 | context.key = key 28 | end 29 | end 30 | 31 | let(:client_context) do 32 | OpenSSL::SSL::SSLContext.new.tap do |context| 33 | context.cert_store = certificate_store 34 | 35 | context.alpn_protocols = ["h2", "http/1.1"] 36 | 37 | context.verify_mode = OpenSSL::SSL::VERIFY_PEER 38 | end 39 | end 40 | 41 | def make_server_endpoint(bound_endpoint) 42 | ::IO::Endpoint::SSLEndpoint.new(super, ssl_context: server_context) 43 | end 44 | 45 | def make_client_endpoint(bound_endpoint) 46 | ::IO::Endpoint::SSLEndpoint.new(super, ssl_context: client_context) 47 | end 48 | 49 | it "client can get a resource via https" do 50 | response = client.get("/") 51 | 52 | expect(response).to be(:success?) 53 | expect(response.read).to be == "Hello World!" 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/async/http/statistics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async/http/statistics" 7 | require "sus/fixtures/async/http" 8 | 9 | describe Async::HTTP::Statistics do 10 | include Sus::Fixtures::Async::HTTP::ServerContext 11 | 12 | let(:app) do 13 | Protocol::HTTP::Middleware.for do |request| 14 | statistics = subject.start 15 | 16 | response = Protocol::HTTP::Response[200, {}, ["Hello ", "World!"]] 17 | 18 | statistics.wrap(response) do |statistics, error| 19 | expect(statistics.sent).to be == 12 20 | expect(error).to be_nil 21 | end.tap do |response| 22 | expect(response.body).to receive(:complete_statistics) 23 | end 24 | end 25 | end 26 | 27 | it "client can get resource" do 28 | response = client.get("/") 29 | expect(response.read).to be == "Hello World!" 30 | 31 | expect(response).to be(:success?) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/protocol/http/body/stream.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/http/protocol/http" 7 | require "protocol/http/body/streamable" 8 | require "sus/fixtures/async/http" 9 | 10 | AnEchoServer = Sus::Shared("an echo server") do 11 | let(:app) do 12 | ::Protocol::HTTP::Middleware.for do |request| 13 | output = ::Protocol::HTTP::Body::Writable.new 14 | 15 | Async do 16 | stream = ::Protocol::HTTP::Body::Stream.new(request.body, output) 17 | 18 | Console.debug(self, "Echoing chunks...") 19 | while chunk = stream.readpartial(1024) 20 | Console.debug(self, "Reading chunk:", chunk: chunk) 21 | stream.write(chunk) 22 | end 23 | rescue EOFError 24 | Console.debug(self, "EOF.") 25 | # Ignore. 26 | ensure 27 | Console.debug(self, "Closing stream.") 28 | stream.close 29 | end 30 | 31 | ::Protocol::HTTP::Response[200, {}, output] 32 | end 33 | end 34 | 35 | it "should echo the request body" do 36 | chunks = ["Hello,", "World!"] 37 | response_chunks = Queue.new 38 | 39 | output = ::Protocol::HTTP::Body::Writable.new 40 | response = client.post("/", body: output) 41 | stream = ::Protocol::HTTP::Body::Stream.new(response.body, output) 42 | 43 | begin 44 | Console.debug(self, "Echoing chunks...") 45 | chunks.each do |chunk| 46 | Console.debug(self, "Writing chunk:", chunk: chunk) 47 | stream.write(chunk) 48 | end 49 | 50 | Console.debug(self, "Closing write.") 51 | stream.close_write 52 | 53 | Console.debug(self, "Reading chunks...") 54 | while chunk = stream.readpartial(1024) 55 | Console.debug(self, "Reading chunk:", chunk: chunk) 56 | response_chunks << chunk 57 | end 58 | rescue EOFError 59 | Console.debug(self, "EOF.") 60 | # Ignore. 61 | ensure 62 | Console.debug(self, "Closing stream.") 63 | stream.close 64 | response_chunks.close 65 | end 66 | 67 | chunks.each do |chunk| 68 | expect(response_chunks.pop).to be == chunk 69 | end 70 | end 71 | end 72 | 73 | AnEchoClient = Sus::Shared("an echo client") do 74 | let(:chunks) {["Hello,", "World!"]} 75 | let(:response_chunks) {Queue.new} 76 | 77 | let(:app) do 78 | ::Protocol::HTTP::Middleware.for do |request| 79 | output = ::Protocol::HTTP::Body::Writable.new 80 | 81 | Async do 82 | stream = ::Protocol::HTTP::Body::Stream.new(request.body, output) 83 | 84 | Console.debug(self, "Echoing chunks...") 85 | chunks.each do |chunk| 86 | stream.write(chunk) 87 | end 88 | 89 | Console.debug(self, "Closing write.") 90 | stream.close_write 91 | 92 | Console.debug(self, "Reading chunks...") 93 | while chunk = stream.readpartial(1024) 94 | Console.debug(self, "Reading chunk:", chunk: chunk) 95 | response_chunks << chunk 96 | end 97 | rescue EOFError 98 | Console.debug(self, "EOF.") 99 | # Ignore. 100 | ensure 101 | Console.debug(self, "Closing stream.") 102 | stream.close 103 | end 104 | 105 | ::Protocol::HTTP::Response[200, {}, output] 106 | end 107 | end 108 | 109 | it "should echo the response body" do 110 | output = ::Protocol::HTTP::Body::Writable.new 111 | response = client.post("/", body: output) 112 | stream = ::Protocol::HTTP::Body::Stream.new(response.body, output) 113 | 114 | begin 115 | Console.debug(self, "Echoing chunks...") 116 | while chunk = stream.readpartial(1024) 117 | stream.write(chunk) 118 | end 119 | rescue EOFError 120 | Console.debug(self, "EOF.") 121 | # Ignore. 122 | ensure 123 | Console.debug(self, "Closing stream.") 124 | stream.close 125 | end 126 | 127 | chunks.each do |chunk| 128 | expect(response_chunks.pop).to be == chunk 129 | end 130 | end 131 | end 132 | 133 | [Async::HTTP::Protocol::HTTP1, Async::HTTP::Protocol::HTTP2].each do |protocol| 134 | describe protocol, unique: protocol.name do 135 | include Sus::Fixtures::Async::HTTP::ServerContext 136 | 137 | let(:protocol) {subject} 138 | 139 | it_behaves_like AnEchoServer 140 | it_behaves_like AnEchoClient 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/protocol/http/body/streamable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/http/protocol/http" 7 | require "protocol/http/body/streamable" 8 | require "sus/fixtures/async/http" 9 | 10 | AnEchoServer = Sus::Shared("an echo server") do 11 | let(:app) do 12 | ::Protocol::HTTP::Middleware.for do |request| 13 | streamable = ::Protocol::HTTP::Body::Streamable.response(request) do |stream| 14 | Console.debug(self, "Echoing chunks...") 15 | while chunk = stream.readpartial(1024) 16 | Console.debug(self, "Reading chunk:", chunk: chunk) 17 | stream.write(chunk) 18 | end 19 | rescue EOFError 20 | Console.debug(self, "EOF.") 21 | # Ignore. 22 | ensure 23 | Console.debug(self, "Closing stream.") 24 | stream.close 25 | end 26 | 27 | ::Protocol::HTTP::Response[200, {}, streamable] 28 | end 29 | end 30 | 31 | it "should echo the request body" do 32 | chunks = ["Hello,", "World!"] 33 | response_chunks = Queue.new 34 | 35 | output = ::Protocol::HTTP::Body::Writable.new 36 | response = client.post("/", body: output) 37 | stream = ::Protocol::HTTP::Body::Stream.new(response.body, output) 38 | 39 | begin 40 | Console.debug(self, "Echoing chunks...") 41 | chunks.each do |chunk| 42 | Console.debug(self, "Writing chunk:", chunk: chunk) 43 | stream.write(chunk) 44 | end 45 | 46 | Console.debug(self, "Closing write.") 47 | stream.close_write 48 | 49 | Console.debug(self, "Reading chunks...") 50 | while chunk = stream.readpartial(1024) 51 | Console.debug(self, "Reading chunk:", chunk: chunk) 52 | response_chunks << chunk 53 | end 54 | rescue EOFError 55 | Console.debug(self, "EOF.") 56 | # Ignore. 57 | ensure 58 | Console.debug(self, "Closing stream.") 59 | stream.close 60 | response_chunks.close 61 | end 62 | 63 | chunks.each do |chunk| 64 | expect(response_chunks.pop).to be == chunk 65 | end 66 | end 67 | end 68 | 69 | AnEchoClient = Sus::Shared("an echo client") do 70 | let(:chunks) {["Hello,", "World!"]} 71 | let(:response_chunks) {Queue.new} 72 | 73 | let(:app) do 74 | ::Protocol::HTTP::Middleware.for do |request| 75 | streamable = ::Protocol::HTTP::Body::Streamable.response(request) do |stream| 76 | Console.debug(self, "Echoing chunks...") 77 | chunks.each do |chunk| 78 | stream.write(chunk) 79 | end 80 | 81 | Console.debug(self, "Closing write.") 82 | stream.close_write 83 | 84 | Console.debug(self, "Reading chunks...") 85 | while chunk = stream.readpartial(1024) 86 | Console.debug(self, "Reading chunk:", chunk: chunk) 87 | response_chunks << chunk 88 | end 89 | rescue EOFError 90 | Console.debug(self, "EOF.") 91 | # Ignore. 92 | ensure 93 | Console.debug(self, "Closing stream.") 94 | stream.close 95 | end 96 | 97 | ::Protocol::HTTP::Response[200, {}, streamable] 98 | end 99 | end 100 | 101 | it "should echo the response body" do 102 | output = ::Protocol::HTTP::Body::Writable.new 103 | response = client.post("/", body: output) 104 | stream = ::Protocol::HTTP::Body::Stream.new(response.body, output) 105 | 106 | begin 107 | Console.debug(self, "Echoing chunks...") 108 | while chunk = stream.readpartial(1024) 109 | stream.write(chunk) 110 | end 111 | rescue EOFError 112 | Console.debug(self, "EOF.") 113 | # Ignore. 114 | ensure 115 | Console.debug(self, "Closing stream.") 116 | stream.close 117 | end 118 | 119 | chunks.each do |chunk| 120 | expect(response_chunks.pop).to be == chunk 121 | end 122 | end 123 | end 124 | 125 | [Async::HTTP::Protocol::HTTP1, Async::HTTP::Protocol::HTTP2].each do |protocol| 126 | describe protocol, unique: protocol.name do 127 | include Sus::Fixtures::Async::HTTP::ServerContext 128 | 129 | let(:protocol) {subject} 130 | 131 | it_behaves_like AnEchoServer 132 | it_behaves_like AnEchoClient 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/rack/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "sus/fixtures/async" 7 | require "async/http" 8 | 9 | require "rack/test" 10 | require "rack/builder" 11 | 12 | describe Rack::Test do 13 | include Sus::Fixtures::Async::ReactorContext 14 | include Rack::Test::Methods 15 | 16 | let(:app) do 17 | Rack::Builder.new do 18 | def body(*chunks) 19 | body = Async::HTTP::Body::Writable.new 20 | 21 | Async do |task| 22 | chunks.each do |chunk| 23 | body.write(chunk) 24 | sleep(0.1) 25 | end 26 | 27 | body.close 28 | end 29 | 30 | return body 31 | end 32 | 33 | # This echos the body back. 34 | run lambda { |env| [200, {}, body("Hello", " ", "World", "!")] } 35 | end 36 | end 37 | 38 | it "can read response body" do 39 | get "/" 40 | 41 | expect(last_response.body).to be == "Hello World!" 42 | end 43 | end 44 | --------------------------------------------------------------------------------