diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 00000000..81f3c655 --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,57 @@ +name: Coverage + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + COVERAGE: PartialSummary + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.2" + + steps: + - uses: actions/checkout@v3 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 5 + run: bundle exec bake test + + - uses: actions/upload-artifact@v2 + with: + name: coverage-${{matrix.os}}-${{matrix.ruby}} + path: .covered.db + + validate: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + + - uses: actions/download-artifact@v3 + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake covered:validate --paths */.covered.db \; diff --git a/.github/workflows/test-async-head.yaml b/.github/workflows/test-async-head.yaml deleted file mode 100644 index a6da4873..00000000 --- a/.github/workflows/test-async-head.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Test Async HEAD - -on: [push, pull_request] - -permissions: - contents: read - -env: - CONSOLE_OUTPUT: XTerm - BUNDLE_GEMFILE: gems/async-head.rb - -jobs: - test: - runs-on: ${{matrix.os}}-latest - - strategy: - matrix: - os: - - ubuntu - - ruby: - - head - - steps: - - uses: actions/checkout@v3 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{matrix.ruby}} - bundler-cache: true - - - name: Run tests - timeout-minutes: 10 - run: bundle exec bake test diff --git a/.github/workflows/test-async-v1.yaml b/.github/workflows/test-async-v1.yaml deleted file mode 100644 index 9d83bd0b..00000000 --- a/.github/workflows/test-async-v1.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Test Async v1 - -on: [push, pull_request] - -permissions: - contents: read - -env: - CONSOLE_OUTPUT: XTerm - BUNDLE_GEMFILE: gems/async-v1.rb - -jobs: - test: - runs-on: ${{matrix.os}}-latest - - strategy: - matrix: - os: - - ubuntu - - ruby: - - 2.7 - - steps: - - uses: actions/checkout@v3 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{matrix.ruby}} - bundler-cache: true - - - name: Run tests - timeout-minutes: 10 - run: bundle exec bake test diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml index 214149c4..cbff6759 100644 --- a/.github/workflows/test-external.yaml +++ b/.github/workflows/test-external.yaml @@ -20,7 +20,6 @@ jobs: - macos ruby: - - "2.7" - "3.0" - "3.1" - "3.2" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1a6b57f1..942ede73 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,7 +21,6 @@ jobs: - macos ruby: - - "2.7" - "3.0" - "3.1" - "3.2" @@ -46,10 +45,6 @@ jobs: ruby-version: ${{matrix.ruby}} bundler-cache: true - - name: Installing packages (ubuntu) - if: matrix.os == 'ubuntu' - run: sudo apt-get install apache2-utils - - name: Run tests timeout-minutes: 10 run: bundle exec bake test diff --git a/.gitignore b/.gitignore index 4da16e76..09a72e06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,5 @@ -.tags - /.bundle/ -/.yardoc -/gems.locked -/_yardoc/ -/coverage/ -/doc/ /pkg/ -/spec/reports/ -/tmp/ - -.rspec_status -.covered.db -/h2spec +/gems.locked +/.covered.db +/external diff --git a/.mailmap b/.mailmap index 3647b58d..50b4e977 100644 --- a/.mailmap +++ b/.mailmap @@ -1,2 +1,3 @@ Viacheslav Koval Sam Shadwell +Thomas Morgan diff --git a/.rspec b/.rspec deleted file mode 100644 index 8fbe32d9..00000000 --- a/.rspec +++ /dev/null @@ -1,3 +0,0 @@ ---format documentation ---warnings ---require spec_helper \ No newline at end of file diff --git a/async-http.gemspec b/async-http.gemspec index d1c579f5..e39a79b7 100644 --- a/async-http.gemspec +++ b/async-http.gemspec @@ -7,7 +7,7 @@ Gem::Specification.new do |spec| spec.version = Async::HTTP::VERSION spec.summary = "A HTTP client and server library." - spec.authors = ["Samuel Williams", "Brian Morearty", "Bruno Sutic", "Janko Marohnić", "Adam Daniels", "Cyril Roelandt", "Denis Talakevich", "Ian Ker-Seymer", "Igor Sidorov", "Marco Concetto Rudilosso", "Olle Jonsson", "Orgad Shaneh", "Sam Shadwell", "Stefan Wrobel", "Tim Meusel", "Trevor Turk", "Viacheslav Koval"] + spec.authors = ["Samuel Williams", "Brian Morearty", "Bruno Sutic", "Janko Marohnić", "Adam Daniels", "Thomas Morgan", "Cyril Roelandt", "Denis Talakevich", "Ian Ker-Seymer", "Igor Sidorov", "Marco Concetto Rudilosso", "Olle Jonsson", "Orgad Shaneh", "Sam Shadwell", "Stefan Wrobel", "Tim Meusel", "Trevor Turk", "Viacheslav Koval"] spec.license = "MIT" spec.cert_chain = ['release.cert'] @@ -17,18 +17,13 @@ Gem::Specification.new do |spec| spec.files = Dir.glob(['{bake,lib}/**/*', '*.md'], File::FNM_DOTMATCH, base: __dir__) + spec.required_ruby_version = ">= 3.0" + spec.add_dependency "async", ">= 1.25" spec.add_dependency "async-io", ">= 1.28" spec.add_dependency "async-pool", ">= 0.2" - spec.add_dependency "protocol-http", "~> 0.24.0" - spec.add_dependency "protocol-http1", "~> 0.15.0" + spec.add_dependency "protocol-http", "~> 0.25.0" + spec.add_dependency "protocol-http1", "~> 0.16.0" spec.add_dependency "protocol-http2", "~> 0.15.0" spec.add_dependency "traces", ">= 0.10.0" - - spec.add_development_dependency "async-container", "~> 0.14" - spec.add_development_dependency "async-rspec", "~> 1.10" - spec.add_development_dependency "covered" - spec.add_development_dependency "localhost" - spec.add_development_dependency "rack-test" - spec.add_development_dependency "rspec", "~> 3.6" end diff --git a/config/external.yaml b/config/external.yaml index 663e3e16..d94b9192 100644 --- a/config/external.yaml +++ b/config/external.yaml @@ -1,6 +1,6 @@ falcon: url: https://github.com/socketry/falcon.git - command: bundle exec rspec + command: bundle exec bake test async-rest: url: https://github.com/socketry/async-rest.git command: bundle exec rspec diff --git a/config/sus.rb b/config/sus.rb new file mode 100644 index 00000000..ee30cfcf --- /dev/null +++ b/config/sus.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2017-2023, by Samuel Williams. +# Copyright, 2018, by Janko Marohnić. + +ENV['CONSOLE_LEVEL'] ||= 'fatal' + +require 'covered/sus' +include Covered::Sus + +require 'traces' +ENV['TRACES_BACKEND'] ||= 'traces/backend/test' diff --git a/examples/google/about.html b/examples/google/about.html new file mode 100644 index 00000000..4bf91836 --- /dev/null +++ b/examples/google/about.html @@ -0,0 +1,16 @@ +ruby - Google Search

See results about

Founded in 2002, RUBY is an eminent fashion label based in Aotearoa, best known and loved for the mutually nourishing relationship it has with its community�...
RUBY. RUBETTES: A community who cares. RUBY, @liampatterns & @rubysaysrecycle, Aotearoa. www.rubynz.com. 7,526 posts. 89.9K followers. 426 following.
RUBY. 44321 likes � 286 talking about this. Welcome to the official RUBY & Liam Facebook page.
Shop for RUBY designer womens fashion online in New Zealand. Free NZ Shipping over $50. Stress-free Returns!
$30.00
100% New Zealand hazelnuts coated in Belgian ruby chocolate – the fourth category in chocolate after dark, milk and white. Ruby chocolate is made from the�...
$54.00
1 x Ruby Hazelnuts (200g) - 100% New Zealand hazelnuts coated in Belgian ruby chocolate – without adding any colourants or fruit flavourings, the unique taste�...
Taylors Fine Ruby Port 750ml � Description. Stylish fruity nose, full of intense concentrated blackcurrant and cherry aromas, full bodied palate crammed with�...
\ No newline at end of file diff --git a/examples/google/gems.locked b/examples/google/gems.locked new file mode 100644 index 00000000..522aecc0 --- /dev/null +++ b/examples/google/gems.locked @@ -0,0 +1,41 @@ +GEM + remote: https://rubygems.org/ + specs: + async (2.3.1) + console (~> 1.10) + io-event (~> 1.1) + timers (~> 4.1) + async-http (0.60.1) + async (>= 1.25) + async-io (>= 1.28) + async-pool (>= 0.2) + protocol-http (~> 0.24.0) + protocol-http1 (~> 0.15.0) + protocol-http2 (~> 0.15.0) + traces (>= 0.8.0) + async-io (1.34.3) + async + async-pool (0.3.12) + async (>= 1.25) + console (1.16.2) + fiber-local + fiber-local (1.0.0) + io-event (1.1.6) + protocol-hpack (1.4.2) + protocol-http (0.24.7) + protocol-http1 (0.15.1) + protocol-http (~> 0.22) + protocol-http2 (0.15.1) + protocol-hpack (~> 1.4) + protocol-http (~> 0.18) + timers (4.3.5) + traces (0.8.0) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + async-http (~> 0.60.0) + +BUNDLED WITH + 2.4.6 diff --git a/examples/google/gems.rb b/examples/google/gems.rb new file mode 100644 index 00000000..ce0a1bea --- /dev/null +++ b/examples/google/gems.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023, by Samuel Williams. + +source "https://rubygems.org/" + +gem "async-http", "~> 0.60.0" diff --git a/examples/google/multiple.rb b/examples/google/multiple.rb new file mode 100755 index 00000000..ad9123f7 --- /dev/null +++ b/examples/google/multiple.rb @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023, by Samuel Williams. + +require 'async' +require 'async/barrier' +require 'async/semaphore' +require 'async/http/internet' + +TOPICS = ["ruby", "python", "rust"] + +Async do + internet = Async::HTTP::Internet.new + barrier = Async::Barrier.new + semaphore = Async::Semaphore.new(2, parent: barrier) + + # Spawn an asynchronous task for each topic: + TOPICS.each do |topic| + semaphore.async do + response = internet.get "https://www.google.com/search?q=#{topic}" + puts "Found #{topic}: #{response.read.scan(topic).size} times." + end + end + + # Ensure we wait for all requests to complete before continuing: + barrier.wait +ensure + internet&.close +end diff --git a/examples/google/ruby.html b/examples/google/ruby.html new file mode 100644 index 00000000..e311ea4a --- /dev/null +++ b/examples/google/ruby.html @@ -0,0 +1,22 @@ +ruby - Google Search
Founded in 2002, RUBY is an eminent fashion label based in Aotearoa, best known and loved for the mutually nourishing relationship it has with its community�...
RUBY. RUBETTES: A community who cares. RUBY, @liampatterns & @rubysaysrecycle, Aotearoa. www.rubynz.com. 7,526 posts. 89.9K followers. 426 following.
RUBY. 44321 likes � 286 talking about this. Welcome to the official RUBY & Liam Facebook page.
Shop for RUBY designer womens fashion online in New Zealand. Free NZ Shipping over $50. Stress-free Returns!
$249.00
Firebird Pant in Black by New Zealand RUBY High-waisted pant with flattering straight leg silhouette Waist band with invisible zip closure at centre back�...
$30.00
100% New Zealand hazelnuts coated in Belgian ruby chocolate – the fourth category in chocolate after dark, milk and white. Ruby chocolate is made from the�...
Taylors Fine Ruby Port 750ml � Description. Stylish fruity nose, full of intense concentrated blackcurrant and cherry aromas, full bodied palate crammed with�...
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\x3d';var i=['dimg_27'];_setImagesSrc(i,s);})(); \ No newline at end of file diff --git a/fixtures/async/http/a_protocol.rb b/fixtures/async/http/a_protocol.rb new file mode 100644 index 00000000..c9a40c9e --- /dev/null +++ b/fixtures/async/http/a_protocol.rb @@ -0,0 +1,584 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2023, by Samuel Williams. +# Copyright, 2020, by Igor Sidorov. + +require 'async' +require 'async/clock' +require 'async/http/client' +require 'async/http/server' +require 'async/http/endpoint' +require 'async/http/body/hijack' +require 'tempfile' + +require 'protocol/http/body/file' + +require 'sus/fixtures/async/http' + +module Async + module HTTP + AProtocol = Sus::Shared("a protocol") do + include Sus::Fixtures::Async::HTTP::ServerContext + + let(:protocol) {subject} + + it "should have valid scheme" do + expect(client.scheme).to be == "http" + end + + with '#close' do + it 'can close the connection' do + Async do |task| + response = client.get("/") + expect(response).to be(:success?) + response.finish + + client.close + + expect(task.children).to be(:empty?) + end.wait + end + end + + with "interim response" do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + request.write_interim_response( + ::Protocol::HTTP::Response[103, [["link", "; rel=preload; as=style"]]] + ) + + ::Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + end + + it "can read informational response" do + response = client.get("/") + expect(response).to be(:success?) + expect(response.read).to be == "Hello World" + end + end + + with "huge body" do + let(:body) {::Protocol::HTTP::Body::File.open("/dev/zero", size: 8*1024**2)} + + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + ::Protocol::HTTP::Response[200, {}, body] + end + end + + it "client can download data quickly" do + response = client.get("/") + expect(response).to be(:success?) + + data_size = 0 + duration = Async::Clock.measure do + while chunk = response.body.read + data_size += chunk.bytesize + chunk.clear + end + + response.finish + end + + size_mbytes = data_size / 1024**2 + + inform "Data size: #{size_mbytes}MB Duration: #{duration.round(2)}s Throughput: #{(size_mbytes / duration).round(2)}MB/s" + end + end + + with 'buffered body' do + let(:body) {Async::HTTP::Body::Buffered.new(["Hello World"])} + let(:response) {::Protocol::HTTP::Response[200, {}, body]} + + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + response + end + end + + it "response body should be closed" do + expect(body).to receive(:close) + # expect(response).to receive(:close) + + expect(client.get("/", {}).read).to be == "Hello World" + end + end + + with 'empty body' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + ::Protocol::HTTP::Response[204] + end + end + + it 'properly handles no content responses' do + expect(client.get("/", {}).read).to be_nil + end + end + + with 'with trailer' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + if trailer = request.headers['trailer'] + expect(request.headers).not.to have_keys('etag') + request.finish + expect(request.headers).to have_keys('etag') + + ::Protocol::HTTP::Response[200, [], "request trailer"] + else + headers = ::Protocol::HTTP::Headers.new + headers.add('trailer', 'etag') + + body = Async::HTTP::Body::Writable.new + + Async do |task| + body.write("response trailer") + task.sleep(0.01) + headers.add('etag', 'abcd') + body.close + end + + ::Protocol::HTTP::Response[200, headers, body] + end + end + end + + it "can send request trailer" do + skip "Protocol does not support trailers!" unless subject.bidirectional? + + headers = ::Protocol::HTTP::Headers.new + headers.add('trailer', 'etag') + body = Async::HTTP::Body::Writable.new + + Async do |task| + body.write("Hello") + task.sleep(0.01) + headers.add('etag', 'abcd') + body.close + end + + response = client.post("/", headers, body) + expect(response.read).to be == "request trailer" + + expect(response).to be(:success?) + end + + it "can receive response trailer" do + skip "Protocol does not support trailers!" unless subject.bidirectional? + + response = client.get("/") + expect(response.headers).to have_keys('trailer') + headers = response.headers + expect(headers).not.to have_keys('etag') + + expect(response.read).to be == "response trailer" + expect(response).to be(:success?) + + # It was sent as a trailer. + expect(headers).to have_keys('etag') + end + end + + with 'with working server' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + if request.method == 'POST' + # We stream the request body directly to the response. + ::Protocol::HTTP::Response[200, {}, request.body] + elsif request.method == 'GET' + expect(request.body).to be_nil + + ::Protocol::HTTP::Response[200, { + 'remote-address' => request.remote_address.inspect + }, ["#{request.method} #{request.version}"]] + else + ::Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + end + end + + it "should have valid scheme" do + expect(server.scheme).to be == "http" + end + + it "disconnects slow clients" do + response = client.get("/") + response.read + + # We expect this connection to be closed: + connection = response.connection + + reactor.sleep(1.0) + + response = client.get("/") + response.read + + expect(connection).not.to be(:reusable?) + + # client.close + # reactor.sleep(0.1) + # reactor.print_hierarchy + end + + with 'using GET method' do + let(:expected) {"GET #{protocol::VERSION}"} + + it "can handle many simultaneous requests" do + duration = Async::Clock.measure do + 10.times do + tasks = 100.times.collect do + Async do + client.get("/") + end + end + + tasks.each do |task| + response = task.wait + expect(response).to be(:success?) + expect(response.read).to be == expected + end + end + end + + inform "Pool: #{client.pool}" + inform "Duration: #{duration.round(2)}" + end + + with 'with response' do + let(:response) {client.get("/")} + + def after + response.finish + super + end + + it "can finish gracefully" do + expect(response).to be(:success?) + end + + it "is successful" do + expect(response).to be(:success?) + expect(response.read).to be == expected + end + + it "provides content length" do + expect(response.body.length).not.to be_nil + end + + let(:tempfile) {Tempfile.new} + + it "can save to disk" do + response.save(tempfile.path) + expect(tempfile.read).to be == expected + + tempfile.close + end + + it "has remote-address header" do + expect(response.headers['remote-address']).not.to be_nil + end + + it "has protocol version" do + expect(response.version).not.to be_nil + end + end + end + + with 'HEAD' do + let(:response) {client.head("/")} + + it "is successful and without body" do + expect(response).to be(:success?) + expect(response.body).not.to be_nil + expect(response.body).to be(:empty?) + expect(response.body.length).not.to be_nil + expect(response.read).to be_nil + end + end + + with 'POST' do + let(:response) {client.post("/", {}, ["Hello", " ", "World"])} + + def after + response.finish + super + end + + it "is successful" do + expect(response).to be(:success?) + expect(response.read).to be == "Hello World" + expect(client.pool).not.to be(:busy?) + end + + it "can buffer response" do + buffer = response.finish + + expect(buffer.join).to be == "Hello World" + + expect(client.pool).not.to be(:busy?) + end + + it "should not contain content-length response header" do + expect(response.headers).not.to have_keys('content-length') + end + + it "fails gracefully when closing connection" do + client.pool.acquire do |connection| + connection.stream.close + end + end + end + end + + with 'content length' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + ::Protocol::HTTP::Response[200, [], ["Content Length: #{request.body.length}"]] + end + end + + it "can send push promises" do + response = client.post("/test", [], ["Hello World!"]) + expect(response).to be(:success?) + + expect(response.body.length).to be == 18 + expect(response.read).to be == "Content Length: 12" + end + end + + with 'hijack with nil response' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + nil + end + end + + it "fails with appropriate error" do + response = client.get("/") + + expect(response).to be(:server_failure?) + end + end + + with 'partial hijack' do + let(:content) {"Hello World!"} + + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + Async::HTTP::Body::Hijack.response(request, 200, {}) do |stream| + stream.write content + stream.write content + stream.close + end + end + end + + it "reads hijacked body" do + response = client.get("/") + + expect(response.read).to be == (content*2) + end + end + + with 'body with incorrect length' do + let(:bad_body) {Async::HTTP::Body::Buffered.new(["Borked"], 10)} + + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + ::Protocol::HTTP::Response[200, {}, bad_body] + end + end + + it "fails with appropriate error" do + response = client.get("/") + + expect do + response.read + end.to raise_exception(EOFError) + end + end + + with 'streaming server' do + let(:sent_chunks) {[]} + + let(:app) do + chunks = sent_chunks + + ::Protocol::HTTP::Middleware.for do |request| + body = Async::HTTP::Body::Writable.new + + Async::Reactor.run do |task| + 10.times do |i| + chunk = "Chunk #{i}" + chunks << chunk + + body.write chunk + task.sleep 0.25 + end + + body.finish + end + + ::Protocol::HTTP::Response[200, {}, body] + end + end + + it "can cancel response" do + response = client.get("/") + + expect(response.body.read).to be == "Chunk 0" + + response.close + + expect(sent_chunks).to be == ["Chunk 0"] + end + end + + with 'hijack server' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + if request.hijack? + io = request.hijack! + io.write "HTTP/1.1 200 Okay\r\nContent-Length: 16\r\n\r\nHijack Succeeded" + io.flush + io.close + else + ::Protocol::HTTP::Response[200, {}, ["Hijack Failed"]] + end + end + end + + it "will hijack response if possible" do + response = client.get("/") + + expect(response.read).to be =~ /Hijack/ + end + end + + with 'broken server' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + raise RuntimeError.new('simulated failure') + end + end + + it "can't get /" do + expect do + response = client.get("/") + end.to raise_exception(Exception) + end + end + + with 'slow server' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + sleep(endpoint.timeout * 2) + ::Protocol::HTTP::Response[200, {}, []] + end + end + + it "can't get /" do + expect do + client.get("/") + end.to raise_exception(Async::TimeoutError) + end + end + + with 'bi-directional streaming' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + # Echo the request body back to the client. + ::Protocol::HTTP::Response[200, {}, request.body] + end + end + + it "can read from request body and write response body simultaneously" do + skip "Protocol does not support bidirectional streaming!" unless subject.bidirectional? + + body = Async::HTTP::Body::Writable.new + + # Ideally, the flow here is as follows: + # 1/ Client writes headers to server. + # 2/ Client starts writing data to server (in async task). + # 3/ Client reads headers from server. + # 4a/ Client reads data from server. + # 4b/ Client finishes sending data to server. + response = client.post(endpoint.path, [], body) + + expect(response).to be(:success?) + + body.write "." + count = 0 + + response.each do |chunk| + if chunk.bytesize > 32 + body.close + else + count += 1 + body.write chunk*2 + Async::Task.current.sleep(0.1) + end + end + + expect(count).to be == 6 + end + end + + with 'multiple client requests' do + let(:app) do + ::Protocol::HTTP::Middleware.for do |request| + ::Protocol::HTTP::Response[200, {}, [request.path]] + end + end + + def around + current = Console.logger.level + Console.logger.fatal! + + super + ensure + Console.logger.level = current + end + + it "doesn't cancel all requests" do + tasks = [] + task = Async::Task.current + stopped = [] + + 10.times do + tasks << task.async { + begin + loop do + client.get('http://127.0.0.1:8080/a').finish + end + ensure + stopped << 'a' + end + } + end + + 10.times do + tasks << task.async { + begin + loop do + client.get('http://127.0.0.1:8080/b').finish + end + ensure + stopped << 'b' + end + } + end + + tasks.each do |child| + task.sleep 0.01 + child.stop + end + + expect(stopped.sort).to be == stopped + end + end + end + end +end diff --git a/fixtures/async/http/body/a_writable_body.rb b/fixtures/async/http/body/a_writable_body.rb new file mode 100644 index 00000000..eca1c5d9 --- /dev/null +++ b/fixtures/async/http/body/a_writable_body.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http/body/deflate' + +module Async + module HTTP + module Body + AWritableBody = Sus::Shared("a writable body") do + it "can write and read data" do + 3.times do |i| + body.write("Hello World #{i}") + expect(body.read).to be == "Hello World #{i}" + end + end + + it "can buffer data in order" do + 3.times do |i| + body.write("Hello World #{i}") + end + + 3.times do |i| + expect(body.read).to be == "Hello World #{i}" + end + end + + with '#join' do + it "can join chunks" do + 3.times do |i| + body.write("#{i}") + end + + body.close + + expect(body.join).to be == "012" + end + end + + with '#each' do + it "can read all data in order" do + 3.times do |i| + body.write("Hello World #{i}") + end + + body.close + + 3.times do |i| + chunk = body.read + expect(chunk).to be == "Hello World #{i}" + end + end + + it "can propagate failures" do + reactor.async do + expect do + body.each do |chunk| + raise RuntimeError.new("It was too big!") + end + end.to raise_exception(RuntimeError, message: be =~ /big/) + end + + expect{ + body.write("Beep boop") # This will cause a failure. + ::Async::Task.current.yield + body.write("Beep boop") # This will fail. + }.to raise_exception(RuntimeError, message: be =~ /big/) + end + + it "can propagate failures in nested bodies" do + nested = ::Protocol::HTTP::Body::Deflate.for(body) + + reactor.async do + expect do + nested.each do |chunk| + raise RuntimeError.new("It was too big!") + end + end.to raise_exception(RuntimeError, message: be =~ /big/) + end + + expect{ + body.write("Beep boop") # This will cause a failure. + ::Async::Task.current.yield + body.write("Beep boop") # This will fail. + }.to raise_exception(RuntimeError, message: be =~ /big/) + end + + it "will stop after finishing" do + output_task = reactor.async do + body.each do |chunk| + expect(chunk).to be == "Hello World!" + end + end + + body.write("Hello World!") + body.close + + expect(body).not.to be(:empty?) + + ::Async::Task.current.yield + + expect(output_task).to be(:finished?) + expect(body).to be(:empty?) + end + end + end + end + end +end diff --git a/gems.rb b/gems.rb index 435688d1..2173f993 100644 --- a/gems.rb +++ b/gems.rb @@ -7,6 +7,15 @@ gemspec +# gem "async", path: "../async" +# gem "async-io", path: "../async-io" +# gem "traces", path: "../traces" + +# gem "protocol-http", path: "../protocol-http" +# gem "protocol-http1", path: "../protocol-http1" +# gem "protocol-http2", path: "../protocol-http2" +# gem "protocol-hpack", path: "../protocol-hpack" + group :maintenance, optional: true do gem "bake-modernize" gem "bake-gem" @@ -16,18 +25,22 @@ end group :test do + gem "covered" + gem "sus" + gem "sus-fixtures-async" + gem "sus-fixtures-async-http", "~> 0.7" + gem "sus-fixtures-openssl" + gem "bake" gem "bake-test" gem "bake-test-external" -end - -# gem "async", path: "../async" -# gem "async-io", path: "../async-io" -# gem "traces", path: "../traces" - -# gem "protocol-http", path: "../protocol-http" -# gem "protocol-http1", path: "../protocol-http1" -# gem "protocol-http2", path: "../protocol-http2" -# gem "protocol-hpack", path: "../protocol-hpack" + + gem "async-container", "~> 0.14" + gem "async-rspec", "~> 1.10" -gem "thread-local" + gem "localhost" + gem "rack-test" + + # Optional dependency: + gem "thread-local" +end diff --git a/lib/async/http/body/delayed.rb b/lib/async/http/body/delayed.rb index 03869f70..7b0f57b5 100644 --- a/lib/async/http/body/delayed.rb +++ b/lib/async/http/body/delayed.rb @@ -3,13 +3,14 @@ # Released under the MIT License. # Copyright, 2018-2023, by Samuel Williams. # Copyright, 2020, by Bruno Sutic. +# Copyright, 2023, by Thomas Morgan. require 'protocol/http/body/wrapper' module Async module HTTP module Body - class Delayed < Protocol::HTTP::Body::Wrapper + class Delayed < ::Protocol::HTTP::Body::Wrapper def initialize(body, delay = 0.01) super(body) diff --git a/lib/async/http/protocol/http1/request.rb b/lib/async/http/protocol/http1/request.rb index b482bbb3..37d5c8b3 100644 --- a/lib/async/http/protocol/http1/request.rb +++ b/lib/async/http/protocol/http1/request.rb @@ -38,6 +38,10 @@ def hijack? def hijack! @connection.hijack! end + + def write_interim_response(response) + @connection.write_interim_response(response.version, response.status, response.headers) + end end end end diff --git a/lib/async/http/protocol/http1/response.rb b/lib/async/http/protocol/http1/response.rb index 67e402d6..0803da38 100644 --- a/lib/async/http/protocol/http1/response.rb +++ b/lib/async/http/protocol/http1/response.rb @@ -11,16 +11,24 @@ module Protocol module HTTP1 class Response < Protocol::Response def self.read(connection, request) - if parts = connection.read_response(request.method) - self.new(connection, *parts) + while parts = connection.read_response(request.method) + response = self.new(connection, *parts) + + if response.final? + return response + end end end UPGRADE = 'upgrade' - - # @param reason [String] HTTP response line reason, ignored. + + # @attribute [String] The HTTP response line reason. + attr :reason + + # @parameter reason [String] HTTP response line reason phrase. def initialize(connection, version, status, reason, headers, body) @connection = connection + @reason = reason protocol = headers.delete(UPGRADE) diff --git a/lib/async/http/protocol/http1/server.rb b/lib/async/http/protocol/http1/server.rb index 71fc9de0..23e1160b 100644 --- a/lib/async/http/protocol/http1/server.rb +++ b/lib/async/http/protocol/http1/server.rb @@ -3,6 +3,7 @@ # Released under the MIT License. # Copyright, 2018-2023, by Samuel Williams. # Copyright, 2020, by Igor Sidorov. +# Copyright, 2023, by Thomas Morgan. require_relative 'connection' @@ -14,6 +15,9 @@ class Server < Connection def fail_request(status) @persistent = false write_response(@version, status, {}, nil) + write_body(@version, nil) + rescue Errno::ECONNRESET, Errno::EPIPE + # Handle when connection is already closed end def next_request @@ -79,7 +83,7 @@ def each(task: Task.current) version = request.version # Same as above: - request = nil unless body + request = nil unless request.body response = nil write_body(version, body, head, trailer) @@ -94,7 +98,7 @@ def each(task: Task.current) end # Gracefully finish reading the request body if it was not already done so. - request&.finish + request&.each{} # This ensures we yield at least once every iteration of the loop and allow other fibers to execute. task.yield @@ -105,6 +109,7 @@ def each(task: Task.current) end end end + end end end diff --git a/lib/async/http/protocol/http2/request.rb b/lib/async/http/protocol/http2/request.rb index f28fbefa..4fe519d6 100644 --- a/lib/async/http/protocol/http2/request.rb +++ b/lib/async/http/protocol/http2/request.rb @@ -141,6 +141,16 @@ def send_response(response) @stream.send_headers(nil, headers, ::Protocol::HTTP2::END_STREAM) end end + + def write_interim_response(response) + protocol_headers = [ + [STATUS, response.status] + ] + + headers = ::Protocol::HTTP::Headers::Merged.new(protocol_headers, response.headers) + + @stream.send_headers(nil, headers) + end end end end diff --git a/lib/async/http/protocol/http2/response.rb b/lib/async/http/protocol/http2/response.rb index e919ca55..fccebcbd 100644 --- a/lib/async/http/protocol/http2/response.rb +++ b/lib/async/http/protocol/http2/response.rb @@ -39,7 +39,13 @@ def accept_push_promise_stream(promised_stream_id, headers) # This should be invoked from the background reader, and notifies the task waiting for the headers that we are done. def receive_initial_headers(headers, end_stream) headers.each do |key, value| + # It's guaranteed that this should be the first header: if key == STATUS + status = Integer(value) + + # Ignore informational headers: + return if status >= 100 && status < 200 + @response.status = Integer(value) elsif key == PROTOCOL @response.protocol = value diff --git a/lib/async/http/protocol/http2/stream.rb b/lib/async/http/protocol/http2/stream.rb index 54a7e150..7cb3c876 100644 --- a/lib/async/http/protocol/http2/stream.rb +++ b/lib/async/http/protocol/http2/stream.rb @@ -50,13 +50,11 @@ def receive_trailing_headers(headers, end_stream) end def process_headers(frame) - if @headers.nil? - @headers = ::Protocol::HTTP::Headers.new - self.receive_initial_headers(super, frame.end_stream?) - elsif frame.end_stream? + if frame.end_stream? && @headers self.receive_trailing_headers(super, frame.end_stream?) else - raise ::Protocol::HTTP2::HeaderError, "Unable to process headers!" + @headers ||= ::Protocol::HTTP::Headers.new + self.receive_initial_headers(super, frame.end_stream?) end # TODO this might need to be in an ensure block: @@ -65,7 +63,7 @@ def process_headers(frame) @input = nil end rescue ::Protocol::HTTP2::HeaderError => error - Console.logger.error(self, error) + Console.logger.debug(self, error) send_reset_stream(error.code) end diff --git a/lib/async/http/protocol/request.rb b/lib/async/http/protocol/request.rb index 5a6e8a15..6782718d 100644 --- a/lib/async/http/protocol/request.rb +++ b/lib/async/http/protocol/request.rb @@ -25,6 +25,9 @@ def hijack? false end + def write_interim_response(response) + end + def peer if connection = self.connection connection.peer diff --git a/lib/async/http/version.rb b/lib/async/http/version.rb index d4755542..cfbd05da 100644 --- a/lib/async/http/version.rb +++ b/lib/async/http/version.rb @@ -5,6 +5,6 @@ module Async module HTTP - VERSION = "0.60.2" + VERSION = "0.61.0" end end diff --git a/license.md b/license.md index c9fd2273..22d2d13e 100644 --- a/license.md +++ b/license.md @@ -17,6 +17,7 @@ Copyright, 2021-2022, by Adam Daniels. Copyright, 2022, by Ian Ker-Seymer. Copyright, 2022, by Marco Concetto Rudilosso. Copyright, 2022, by Tim Meusel. +Copyright, 2023, by Thomas Morgan. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/readme.md b/readme.md index 75bb6558..6eb705a9 100644 --- a/readme.md +++ b/readme.md @@ -367,6 +367,14 @@ We welcome contributions to this project. 4. Push to the branch (`git push origin my-new-feature`). 5. Create new Pull Request. +### Developer Certificate of Origin + +This project uses the [Developer Certificate of Origin](https://developercertificate.org/). All contributors to this project must agree to this document to have their contributions accepted. + +### Contributor Covenant + +This project is governed by [Contributor Covenant](https://www.contributor-covenant.org/). All contributors and participants agree to abide by its terms. + ## See Also - [benchmark-http](https://github.com/socketry/benchmark-http) — A benchmarking tool to report on web server concurrency. diff --git a/spec/async/http/body/pipe_spec.rb b/spec/async/http/body/pipe_spec.rb deleted file mode 100644 index 9f8ce2c8..00000000 --- a/spec/async/http/body/pipe_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2020, by Bruno Sutic. -# Copyright, 2020-2023, by Samuel Williams. - -require 'async' -require 'async/http/body/pipe' -require 'async/http/body/writable' - -RSpec.describe Async::HTTP::Body::Pipe do - let(:input) { Async::HTTP::Body::Writable.new } - let(:pipe) { described_class.new(input) } - - let(:data) { 'Hello World!' } - - describe '#to_io' do - include_context Async::RSpec::Reactor - - let(:io) { pipe.to_io } - - before do - Async::Task.current.async do |task| # input writer task - first, second = data.split(' ') - input.write("#{first} ") - task.sleep(input_write_duration) if input_write_duration > 0 - input.write(second) - input.close - end - end - - after { io.close } - - shared_examples :returns_io_socket do - it 'returns an io socket' do - expect(io).to be_a(Async::IO::Socket) - expect(io.read).to eq data - end - end - - context 'when reading blocks' do - let(:input_write_duration) { 0.01 } - - include_examples :returns_io_socket - end - - context 'when reading does not block' do - let(:input_write_duration) { 0 } - - include_examples :returns_io_socket - end - end - - describe 'going out of reactor scope' do - context 'when pipe is closed' do - it 'finishes' do - Async { pipe.close } - end - end - - context 'when pipe is not closed' do - it 'finishes' do # ensures pipe background tasks are transient - Async { pipe } - end - end - end -end diff --git a/spec/async/http/body/slowloris_spec.rb b/spec/async/http/body/slowloris_spec.rb deleted file mode 100644 index 8ae0a1cf..00000000 --- a/spec/async/http/body/slowloris_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2019-2023, by Samuel Williams. - -require_relative 'writable_examples' - -require 'async/http/body/slowloris' - -RSpec.describe Async::HTTP::Body::Slowloris do - include_context Async::RSpec::Reactor - - it_behaves_like Async::HTTP::Body::Writable - - it "closes body with error if throughput is not maintained" do - subject.write("Hello World") - - sleep 0.1 - - expect do - subject.write("Hello World") - end.to raise_error(Async::HTTP::Body::Slowloris::ThroughputError, /Slow write/) - end - - it "doesn't close body if throughput is exceeded" do - subject.write("Hello World") - - expect do - subject.write("Hello World") - end.to_not raise_error - end -end diff --git a/spec/async/http/body/writable_examples.rb b/spec/async/http/body/writable_examples.rb deleted file mode 100644 index dca3a926..00000000 --- a/spec/async/http/body/writable_examples.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2019-2023, by Samuel Williams. - -require 'async/http/server' -require 'async/http/client' -require 'async/reactor' - -require 'async/http/body' -require 'protocol/http/body/deflate' -require 'async/http/body/writable' -require 'async/http/endpoint' - -require 'async/io/ssl_socket' -require 'async/rspec/ssl' - -RSpec.shared_examples_for Async::HTTP::Body::Writable do - it "can write and read data" do - 3.times do |i| - subject.write("Hello World #{i}") - expect(subject.read).to be == "Hello World #{i}" - end - end - - it "can buffer data in order" do - 3.times do |i| - subject.write("Hello World #{i}") - end - - 3.times do |i| - expect(subject.read).to be == "Hello World #{i}" - end - end - - context '#join' do - it "can join chunks" do - 3.times do |i| - subject.write("#{i}") - end - - subject.close - - expect(subject.join).to be == "012" - end - end - - context '#each' do - it "can read all data in order" do - 3.times do |i| - subject.write("Hello World #{i}") - end - - subject.close - - 3.times do |i| - chunk = subject.read - expect(chunk).to be == "Hello World #{i}" - end - end - - it "can propagate failures" do - reactor.async do - expect do - subject.each do |chunk| - raise RuntimeError.new("It was too big!") - end - end.to raise_error(RuntimeError, /big/) - end - - expect{ - subject.write("Beep boop") # This will cause a failure. - Async::Task.current.yield - subject.write("Beep boop") # This will fail. - }.to raise_error(RuntimeError, /big/) - end - - it "can propagate failures in nested bodies" do - nested = Protocol::HTTP::Body::Deflate.for(subject) - - reactor.async do - expect do - nested.each do |chunk| - raise RuntimeError.new("It was too big!") - end - end.to raise_error(RuntimeError, /big/) - end - - expect{ - subject.write("Beep boop") # This will cause a failure. - Async::Task.current.yield - subject.write("Beep boop") # This will fail. - }.to raise_error(RuntimeError, /big/) - end - - it "will stop after finishing" do - output_task = reactor.async do - subject.each do |chunk| - expect(chunk).to be == "Hello World!" - end - end - - subject.write("Hello World!") - subject.close - - expect(subject).to_not be_empty - - Async::Task.current.yield - - expect(output_task).to be_finished - expect(subject).to be_empty - end - end -end diff --git a/spec/async/http/body/writable_spec.rb b/spec/async/http/body/writable_spec.rb deleted file mode 100644 index 8340ecf1..00000000 --- a/spec/async/http/body/writable_spec.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2018-2023, by Samuel Williams. - -require_relative 'writable_examples' - -RSpec.describe Async::HTTP::Body::Writable do - include_context Async::RSpec::Reactor - - it_behaves_like Async::HTTP::Body::Writable -end diff --git a/spec/async/http/client_spec.rb b/spec/async/http/client_spec.rb deleted file mode 100644 index 19bc7b35..00000000 --- a/spec/async/http/client_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2017-2023, by Samuel Williams. - -require_relative 'server_context' - -require 'async/http/server' -require 'async/http/client' -require 'async/reactor' - -require 'async/io/ssl_socket' -require 'async/http/endpoint' -require 'protocol/http/accept_encoding' - -RSpec.describe Async::HTTP::Client, timeout: 5 do - describe Async::HTTP::Protocol::HTTP1 do - include_context Async::HTTP::Server - let(:protocol) {described_class} - - it "client can get resource" do - response = client.get("/") - response.read - expect(response).to be_success - end - end - - context 'non-existant host' do - include_context Async::RSpec::Reactor - - let(:endpoint) {Async::HTTP::Endpoint.parse('http://the.future/')} - let(:client) {Async::HTTP::Client.new(endpoint)} - - it "should fail to connect" do - expect do - client.get("/") - end.to raise_error(SocketError, /not known/) - end - end - - describe Async::HTTP::Protocol::HTTPS do - include_context Async::RSpec::Reactor - - let(:endpoint) {Async::HTTP::Endpoint.parse('https://www.codeotaku.com/')} - let(:client) {Async::HTTP::Client.new(endpoint)} - - it "should specify hostname" do - expect(endpoint.hostname).to be == "www.codeotaku.com" - expect(client.authority).to be == "www.codeotaku.com" - end - - it "can request remote resource" do - 2.times do - response = client.get("/index") - expect(response).to be_success - response.finish - end - - client.close - end - - it "can request remote resource with compression" do - compressor = Protocol::HTTP::AcceptEncoding.new(client) - - response = compressor.get("/index", {'accept-encoding' => 'gzip'}) - - expect(response).to be_success - - expect(response.body).to be_kind_of Async::HTTP::Body::Inflate - expect(response.read).to be_start_with('') - - client.close - end - end -end diff --git a/spec/async/http/endpoint_spec.rb b/spec/async/http/endpoint_spec.rb deleted file mode 100644 index b02a6ec8..00000000 --- a/spec/async/http/endpoint_spec.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2018-2023, by Samuel Williams. -# Copyright, 2021-2022, by Adam Daniels. - -require 'async/http/endpoint' - -RSpec.describe Async::HTTP::Endpoint do - it "should fail to parse relative url" do - expect{ - described_class.parse("/foo/bar") - }.to raise_error(ArgumentError, /absolute/) - end - - describe '#port' do - let(:url_string) {"https://localhost:9292/"} - - it "extracts port from URL" do - endpoint = Async::HTTP::Endpoint.parse(url_string) - - expect(endpoint.port).to eq 9292 - end - - it "extracts port from options" do - endpoint = Async::HTTP::Endpoint.parse(url_string, port: 9000) - - expect(endpoint.port).to eq 9000 - end - end - - describe '#hostname' do - describe Async::HTTP::Endpoint.parse("https://127.0.0.1:9292/") do - it {is_expected.to have_attributes(hostname: '127.0.0.1')} - - it "should be connecting to 127.0.0.1" do - expect(subject.endpoint).to be_a Async::IO::SSLEndpoint - expect(subject.endpoint).to have_attributes(hostname: '127.0.0.1') - expect(subject.endpoint.endpoint).to have_attributes(hostname: '127.0.0.1') - end - end - - describe Async::HTTP::Endpoint.parse("https://127.0.0.1:9292/", hostname: 'localhost') do - it {is_expected.to have_attributes(hostname: 'localhost')} - it {is_expected.to_not be_localhost} - - it "should be connecting to localhost" do - expect(subject.endpoint).to be_a Async::IO::SSLEndpoint - expect(subject.endpoint).to have_attributes(hostname: '127.0.0.1') - expect(subject.endpoint.endpoint).to have_attributes(hostname: 'localhost') - end - end - end - - describe '.for' do - context Async::HTTP::Endpoint.for("http", "localhost") do - it {is_expected.to have_attributes(scheme: "http", hostname: "localhost", path: "/")} - it {is_expected.to_not be_secure} - end - - context Async::HTTP::Endpoint.for("http", "localhost", "/foo") do - it {is_expected.to have_attributes(scheme: "http", hostname: "localhost", path: "/foo")} - end - end - - describe '#secure?' do - subject {Async::HTTP::Endpoint.parse(description)} - - context 'http://localhost/' do - it { is_expected.to_not be_secure } - end - - context 'https://localhost/' do - it { is_expected.to be_secure } - end - - context 'with scheme: https' do - subject {Async::HTTP::Endpoint.parse("http://localhost/", scheme: 'https')} - - it { is_expected.to be_secure } - end - end - - describe '#localhost?' do - subject {Async::HTTP::Endpoint.parse(description)} - - context 'http://localhost/' do - it { is_expected.to be_localhost } - end - - context 'http://hello.localhost/' do - it { is_expected.to be_localhost } - end - - context 'http://localhost./' do - it { is_expected.to be_localhost } - end - - context 'http://hello.localhost./' do - it { is_expected.to be_localhost } - end - - context 'http://localhost.com/' do - it { is_expected.to_not be_localhost } - end - end - - describe '#path' do - it "can normal urls" do - endpoint = Async::HTTP::Endpoint.parse("http://foo.com/bar?baz") - expect(endpoint.path).to be == "/bar?baz" - end - - it "can handle websocket urls" do - endpoint = Async::HTTP::Endpoint.parse("wss://foo.com/bar?baz") - expect(endpoint.path).to be == "/bar?baz" - end - end -end - -RSpec.describe "http://www.google.com/search" do - let(:endpoint) {Async::HTTP::Endpoint.parse(subject)} - - it "should be valid endpoint" do - expect{endpoint}.to_not raise_error - end - - it "should select the correct protocol" do - expect(endpoint.protocol).to be Async::HTTP::Protocol::HTTP1 - end - - it "should parse the correct hostname" do - expect(endpoint.hostname).to be == "www.google.com" - end - - it "should not be equal if path is different" do - other = Async::HTTP::Endpoint.parse('http://www.google.com/search?q=ruby') - expect(endpoint).to_not be_eql other - end -end diff --git a/spec/async/http/internet_spec.rb b/spec/async/http/internet_spec.rb deleted file mode 100644 index 15597b48..00000000 --- a/spec/async/http/internet_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2018-2023, by Samuel Williams. - -require 'async/http/internet' -require 'async/reactor' - -require 'json' - -RSpec.describe Async::HTTP::Internet, timeout: 30 do - include_context Async::RSpec::Reactor - - let(:headers) {[['accept', '*/*'], ['user-agent', 'async-http']]} - - after do - subject.close - end - - it "can fetch remote website" do - response = subject.get("https://www.codeotaku.com/index", headers) - - expect(response).to be_success - - response.close - end - - let(:sample) {{"hello" => "world"}} - let(:body) {[JSON.dump(sample)]} - - # This test is increasingly flakey. - xit "can fetch remote json" do - response = subject.post("https://httpbin.org/anything", headers, body) - - expect(response).to be_success - expect{JSON.parse(response.read)}.to_not raise_error - end -end diff --git a/spec/async/http/performance_spec.rb b/spec/async/http/performance_spec.rb deleted file mode 100755 index acb3df2b..00000000 --- a/spec/async/http/performance_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2017-2023, by Samuel Williams. - -require 'async/http/server' -require 'async/http/client' - -require_relative 'server_context' -require 'async/container' - -require 'etc' - -RSpec.shared_examples_for 'client benchmark' do - let(:endpoint) {Async::HTTP::Endpoint.parse('http://127.0.0.1:9294/', timeout: 0.8, reuse_port: true)} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, {}, []] - end - end - - let(:url) {endpoint.url.to_s} - let(:repeats) {1000} - let(:concurrency) {Etc.nprocessors || 2} - - before do - Sync do - # We bind the endpoint before running the server so that we know incoming connections will be accepted: - @bound_endpoint = Async::IO::SharedEndpoint.bound(endpoint) - end - - # I feel a dedicated class might be better than this hack: - allow(@bound_endpoint).to receive(:protocol).and_return(endpoint.protocol) - allow(@bound_endpoint).to receive(:scheme).and_return(endpoint.scheme) - - @container = Async::Container.new - - GC.disable - - @container.run(count: concurrency) do |instance| - Async do - instance.ready! - server.run - end - end - - @bound_endpoint.close - end - - after do - @container.stop - - GC.enable - end - - it "runs benchmark", timeout: nil do - if ab = `which ab`.chomp! - system(ab, "-k", "-n", (concurrency*repeats).to_s, "-c", concurrency.to_s, url) - end - - if wrk = `which wrk`.chomp! - system(wrk, "-c", concurrency.to_s, "-d", "2", "-t", concurrency.to_s, url) - end - end -end - -RSpec.describe Async::HTTP::Server do - describe Protocol::HTTP::Middleware::Okay do - let(:server) do - Async::HTTP::Server.new( - Protocol::HTTP::Middleware::Okay, - @bound_endpoint - ) - end - - include_examples 'client benchmark' - end - - describe 'multiple chunks' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do - Protocol::HTTP::Response[200, {}, "Hello World".chars] - end - end - - include_examples 'client benchmark' - end -end diff --git a/spec/async/http/protocol/http11_spec.rb b/spec/async/http/protocol/http11_spec.rb deleted file mode 100755 index 697e8f86..00000000 --- a/spec/async/http/protocol/http11_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2017-2023, by Samuel Williams. -# Copyright, 2018, by Janko Marohnić. - -require 'async/http/protocol/http11' -require_relative 'shared_examples' - -RSpec.describe Async::HTTP::Protocol::HTTP11 do - it_behaves_like Async::HTTP::Protocol - - context 'head request' do - include_context Async::HTTP::Server - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, {}, ["Hello", "World"]] - end - end - - it "doesn't reply with body" do - 5.times do - response = client.head("/") - - expect(response).to be_success - expect(response.version).to be == "HTTP/1.1" - expect(response.body).to be_empty - - response.read - end - end - end - - context 'raw response' do - include_context Async::HTTP::Server - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - peer = request.hijack! - - peer.write( - "#{request.version} 200 It worked!\r\n" + - "connection: close\r\n" + - "\r\n" + - "Hello World!" - ) - peer.close - - nil - end - end - - it "reads raw response" do - response = client.get("/") - - expect(response.read).to be == "Hello World!" - end - end -end diff --git a/spec/async/http/protocol/http2_spec.rb b/spec/async/http/protocol/http2_spec.rb deleted file mode 100644 index 2e8a52a8..00000000 --- a/spec/async/http/protocol/http2_spec.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2018-2023, by Samuel Williams. - -require 'async/http/protocol/http2' -require_relative 'shared_examples' - -RSpec.describe Async::HTTP::Protocol::HTTP2 do - it_behaves_like Async::HTTP::Protocol - - context 'bad requests' do - include_context Async::HTTP::Server - - it "should fail with explicit authority" do - expect do - client.post("/", [[':authority', 'foo']]) - end.to raise_error(Protocol::HTTP2::StreamError) - end - end - - context 'closed streams' do - include_context Async::HTTP::Server - - it 'should delete stream after response stream is closed' do - response = client.get("/") - connection = response.connection - - response.read - - expect(connection.streams).to be_empty - end - end - - context 'host header' do - include_context Async::HTTP::Server - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, request.headers, ["Authority: #{request.authority.inspect}"]] - end - end - - # We specify nil for the authority - it won't be sent. - let!(:client) {Async::HTTP::Client.new(endpoint, authority: nil)} - - it "should not send :authority header if host header is present" do - response = client.post("/", [['host', 'foo']]) - - expect(response.headers).to include('host') - expect(response.headers['host']).to be == 'foo' - - # TODO Should HTTP/2 respect host header? - expect(response.read).to be == "Authority: nil" - end - end - - context 'stopping requests' do - include_context Async::HTTP::Server - - let(:notification) {Async::Notification.new} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - body = Async::HTTP::Body::Writable.new - - reactor.async do |task| - begin - 100.times do |i| - body.write("Chunk #{i}") - task.sleep (0.01) - end - rescue - # puts "Response generation failed: #{$!}" - ensure - body.close - notification.signal - end - end - - Protocol::HTTP::Response[200, {}, body] - end - end - - let(:pool) {client.pool} - - it "should close stream without closing connection" do - expect(pool).to be_empty - - response = client.get("/") - - expect(pool).to_not be_empty - - response.close - - notification.wait - - expect(response.stream.connection).to be_reusable - end - end -end diff --git a/spec/async/http/protocol/shared_examples.rb b/spec/async/http/protocol/shared_examples.rb deleted file mode 100644 index b29873d5..00000000 --- a/spec/async/http/protocol/shared_examples.rb +++ /dev/null @@ -1,553 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2018-2023, by Samuel Williams. -# Copyright, 2020, by Igor Sidorov. - -require_relative '../server_context' - -require 'async' -require 'async/clock' -require 'async/http/client' -require 'async/http/server' -require 'async/http/endpoint' -require 'async/http/body/hijack' -require 'tempfile' - -require 'protocol/http/body/file' - -require 'async/rspec/profile' - -RSpec.shared_examples_for Async::HTTP::Protocol do - include_context Async::HTTP::Server - - it "should have valid scheme" do - expect(client.scheme).to be == "http" - end - - context '#close' do - it 'can close the connection' do - Async do |task| - response = client.get("/") - expect(response).to be_success - response.finish - - client.close - - expect(task.children).to be_empty - end.wait - end - end - - context "huge body", timeout: 600 do - let(:body) {Protocol::HTTP::Body::File.open("/dev/zero", size: 512*1024**2)} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, {}, body] - end - end - - it "client can download data quickly" do |example| - response = client.get("/") - expect(response).to be_success - - data_size = 0 - duration = Async::Clock.measure do - while chunk = response.body.read - data_size += chunk.bytesize - chunk.clear - end - - response.finish - end - - size_mbytes = data_size / 1024**2 - - example.reporter.message "Data size: #{size_mbytes}MB Duration: #{duration.round(2)}s Throughput: #{(size_mbytes / duration).round(2)}MB/s" - end - end - - context 'buffered body' do - let(:body) {Async::HTTP::Body::Buffered.new(["Hello World"])} - let(:response) {Protocol::HTTP::Response[200, {}, body]} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - response - end - end - - it "response body should be closed" do - expect(body).to receive(:close).and_call_original - # expect(response).to receive(:close).and_call_original - - expect(client.get("/", {}).read).to be == "Hello World" - end - end - - context 'empty body' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[204] - end - end - - it 'properly handles no content responses' do - expect(client.get("/", {}).read).to be_nil - end - end - - context 'with trailer', if: described_class.bidirectional? do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - if trailer = request.headers['trailer'] - expect(request.headers).to_not include('etag') - request.finish - expect(request.headers).to include('etag') - - Protocol::HTTP::Response[200, [], "request trailer"] - else - headers = Protocol::HTTP::Headers.new - headers.add('trailer', 'etag') - - body = Async::HTTP::Body::Writable.new - - Async do |task| - body.write("response trailer") - task.sleep(0.01) - headers.add('etag', 'abcd') - body.close - end - - Protocol::HTTP::Response[200, headers, body] - end - end - end - - it "can send request trailer" do - headers = Protocol::HTTP::Headers.new - headers.add('trailer', 'etag') - body = Async::HTTP::Body::Writable.new - - Async do |task| - body.write("Hello") - task.sleep(0.01) - headers.add('etag', 'abcd') - body.close - end - - response = client.post("/", headers, body) - expect(response.read).to be == "request trailer" - - expect(response).to be_success - end - - it "can receive response trailer" do - response = client.get("/") - expect(response.headers).to include('trailer') - headers = response.headers - expect(headers).to_not include('etag') - - expect(response.read).to be == "response trailer" - expect(response).to be_success - - # It was sent as a trailer. - expect(headers).to include('etag') - end - end - - context 'with working server' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - if request.method == 'POST' - # We stream the request body directly to the response. - Protocol::HTTP::Response[200, {}, request.body] - elsif request.method == 'GET' - expect(request.body).to be nil - - Protocol::HTTP::Response[200, { - 'remote-address' => request.remote_address.inspect - }, ["#{request.method} #{request.version}"]] - else - Protocol::HTTP::Response[200, {}, ["Hello World"]] - end - end - end - - it "should have valid scheme" do - expect(server.scheme).to be == "http" - end - - it "disconnects slow clients" do - response = client.get("/") - response.read - - # We expect this connection to be closed: - connection = response.connection - - reactor.sleep(1.0) - - response = client.get("/") - response.read - - expect(connection).to_not be_reusable - - # client.close - # reactor.sleep(0.1) - # reactor.print_hierarchy - end - - context 'using GET method' do - let(:expected) {"GET #{protocol::VERSION}"} - - it "can handle many simultaneous requests", timeout: 10 do |example| - duration = Async::Clock.measure do - 10.times do - tasks = 100.times.collect do - Async do - client.get("/") - end - end - - tasks.each do |task| - response = task.wait - expect(response).to be_success - expect(response.read).to eq expected - end - end - end - - example.reporter.message "Pool: #{client.pool}" - example.reporter.message "Duration = #{duration.round(2)}" - end - - context 'with response' do - let(:response) {client.get("/")} - after {response.finish} - - it "can finish gracefully" do - expect(response).to be_success - end - - it "is successful" do - expect(response).to be_success - expect(response.read).to eq expected - end - - it "provides content length" do - expect(response.body.length).to_not be_nil - end - - let(:tempfile) {Tempfile.new} - - it "can save to disk" do - response.save(tempfile.path) - expect(tempfile.read).to eq expected - - tempfile.close - end - - it "has remote-address header" do - expect(response.headers['remote-address']).to_not be_nil - end - - it "has protocol version" do - expect(response.version).to_not be_nil - end - end - end - - context 'HEAD' do - let(:response) {client.head("/")} - after {response.finish} - - it "is successful and without body" do - expect(response).to be_success - expect(response.body).to_not be_nil - expect(response.body).to be_empty - expect(response.body.length).to_not be_nil - expect(response.read).to be_nil - end - end - - context 'POST' do - let(:response) {client.post("/", {}, ["Hello", " ", "World"])} - - after {response.finish} - - it "is successful" do - expect(response).to be_success - expect(response.read).to be == "Hello World" - - expect(client.pool).to_not be_busy - end - - it "can buffer response" do - buffer = response.finish - - expect(buffer.join).to be == "Hello World" - - expect(client.pool).to_not be_busy - end - - it "should not contain content-length response header" do - expect(response.headers).to_not include('content-length') - end - - it "fails gracefully when closing connection" do - client.pool.acquire do |connection| - connection.stream.close - end - end - end - end - - context 'content length' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, [], ["Content Length: #{request.body.length}"]] - end - end - - it "can send push promises" do - response = client.post("/test", [], ["Hello World!"]) - expect(response).to be_success - - expect(response.body.length).to be == 18 - expect(response.read).to be == "Content Length: 12" - end - end - - context 'hijack with nil response' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - nil - end - end - - it "fails with appropriate error" do - response = client.get("/") - - expect(response).to be_server_failure - end - end - - context 'partial hijack' do - let(:content) {"Hello World!"} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Async::HTTP::Body::Hijack.response(request, 200, {}) do |stream| - stream.write content - stream.write content - stream.close - end - end - end - - it "reads hijacked body" do - response = client.get("/") - - expect(response.read).to be == (content*2) - end - end - - context 'body with incorrect length' do - let(:bad_body) {Async::HTTP::Body::Buffered.new(["Borked"], 10)} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, {}, bad_body] - end - end - - it "fails with appropriate error" do - response = client.get("/") - - expect do - response.read - end.to raise_error(EOFError) - end - end - - context 'streaming server' do - let!(:sent_chunks) {[]} - - let(:server) do - chunks = sent_chunks - - Async::HTTP::Server.for(@bound_endpoint) do |request| - body = Async::HTTP::Body::Writable.new - - Async::Reactor.run do |task| - 10.times do |i| - chunk = "Chunk #{i}" - chunks << chunk - - body.write chunk - task.sleep 0.25 - end - - body.finish - end - - Protocol::HTTP::Response[200, {}, body] - end - end - - it "can cancel response" do - response = client.get("/") - - expect(response.body.read).to be == "Chunk 0" - - response.close - - expect(sent_chunks).to be == ["Chunk 0"] - end - end - - context 'hijack server' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - if request.hijack? - io = request.hijack! - io.write "HTTP/1.1 200 Okay\r\nContent-Length: 16\r\n\r\nHijack Succeeded" - io.flush - io.close - else - Protocol::HTTP::Response[200, {}, ["Hijack Failed"]] - end - end - end - - it "will hijack response if possible" do - response = client.get("/") - - expect(response.read).to include("Hijack") - end - end - - context 'broken server' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - raise RuntimeError.new('simulated failure') - end - end - - it "can't get /" do - expect do - response = client.get("/") - end.to raise_error(Exception) - end - end - - context 'slow server' do - let(:endpoint) {Async::HTTP::Endpoint.parse('http://127.0.0.1:0/', reuse_port: true, timeout: 0.1)} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Async::Task.current.sleep(endpoint.timeout * 2) - Protocol::HTTP::Response[200, {}, []] - end - end - - it "can't get /" do - expect do - client.get("/") - end.to raise_error(Async::TimeoutError) - end - end - - context 'bi-directional streaming', if: described_class.bidirectional? do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - # Echo the request body back to the client. - Protocol::HTTP::Response[200, {}, request.body] - end - end - - it "can read from request body and write response body simultaneously" do - body = Async::HTTP::Body::Writable.new - - # Ideally, the flow here is as follows: - # 1/ Client writes headers to server. - # 2/ Client starts writing data to server (in async task). - # 3/ Client reads headers from server. - # 4a/ Client reads data from server. - # 4b/ Client finishes sending data to server. - response = client.post(endpoint.path, [], body) - - expect(response).to be_success - - body.write "." - count = 0 - - response.each do |chunk| - if chunk.bytesize > 32 - body.close - else - count += 1 - body.write chunk*2 - Async::Task.current.sleep(0.1) - end - end - - expect(count).to be == 6 - end - end - - context 'multiple client requests' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, {}, [request.path]] - end - end - - around do |example| - current = Console.logger.level - Console.logger.fatal! - - example.run - ensure - Console.logger.level = current - end - - it "doesn't cancel all requests" do - tasks = [] - task = Async::Task.current - stopped = [] - - 10.times do - tasks << task.async { - begin - loop do - client.get('http://127.0.0.1:8080/a').finish - end - ensure - stopped << 'a' - end - } - end - - 10.times do - tasks << task.async { - begin - loop do - client.get('http://127.0.0.1:8080/b').finish - end - ensure - stopped << 'b' - end - } - end - - tasks.each do |child| - task.sleep 0.01 - child.stop - end - - expect(stopped.sort).to be == stopped - end - end -end diff --git a/spec/async/http/retry_spec.rb b/spec/async/http/retry_spec.rb deleted file mode 100644 index 2c648ad4..00000000 --- a/spec/async/http/retry_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2020-2023, by Samuel Williams. - -require_relative 'server_context' - -require 'async/http/client' -require 'async/http/endpoint' - -RSpec.describe 'consistent retry behaviour' do - include_context Async::HTTP::Server - let(:protocol) {Async::HTTP::Protocol::HTTP1} - - let(:delay) {0.1} - let(:retries) {2} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Async::Task.current.sleep(delay) - Protocol::HTTP::Response[200, {}, []] - end - end - - def make_request(body) - # This causes the first request to fail with "SocketError" which is retried: - Async::Task.current.with_timeout(delay / 2, SocketError) do - return client.get('/', {}, body) - end - end - - specify 'with nil body' do - make_request(nil) - end - - specify 'with empty array body' do - make_request([]) - end -end diff --git a/spec/async/http/server_context.rb b/spec/async/http/server_context.rb deleted file mode 100644 index 82779170..00000000 --- a/spec/async/http/server_context.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2019-2023, by Samuel Williams. - -require 'async/http/server' -require 'async/http/client' -require 'async/http/endpoint' -require 'async/io/shared_endpoint' - -RSpec.shared_context Async::HTTP::Server do - include_context Async::RSpec::Reactor - - let(:protocol) {described_class} - let(:endpoint) {Async::HTTP::Endpoint.parse('http://127.0.0.1:0/', timeout: 0.8, reuse_port: true, protocol: protocol)} - - let(:server_endpoint) {endpoint} - let(:client_endpoint) {endpoint} - - let(:retries) {1} - - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - Protocol::HTTP::Response[200, {}, []] - end - end - - before do - # We bind the endpoint before running the server so that we know incoming connections will be accepted: - @bound_endpoint = Async::IO::SharedEndpoint.bound(server_endpoint) - - # I feel a dedicated class might be better than this hack: - allow(@bound_endpoint).to receive(:protocol).and_return(server_endpoint.protocol) - allow(@bound_endpoint).to receive(:scheme).and_return(server_endpoint.scheme) - - @server_task = Async do - server.run - end - - local_address_endpoint = @bound_endpoint.local_address_endpoint - - if timeout = client_endpoint.timeout - local_address_endpoint.each do |endpoint| - endpoint.options = {timeout: timeout} - end - end - - client_endpoint.endpoint = local_address_endpoint - @client = Async::HTTP::Client.new(client_endpoint, protocol: client_endpoint.protocol, retries: retries) - end - - after do - @client&.close - @server_task&.stop - @bound_endpoint&.close - end - - let(:client) {@client} -end diff --git a/spec/async/http/ssl_spec.rb b/spec/async/http/ssl_spec.rb deleted file mode 100644 index c2586a37..00000000 --- a/spec/async/http/ssl_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2018-2023, by Samuel Williams. - -require 'async/http/server' -require 'async/http/client' -require 'async/http/endpoint' - -require 'async/io/ssl_socket' - -require 'async/rspec/reactor' -require 'async/rspec/ssl' - -RSpec.describe Async::HTTP::Server, timeout: 5 do - include_context Async::RSpec::Reactor - include_context Async::RSpec::SSL::ValidCertificate - - describe "application layer protocol negotiation" do - let(:server_context) do - OpenSSL::SSL::SSLContext.new.tap do |context| - context.cert = certificate - - context.alpn_select_cb = lambda do |protocols| - protocols.last - end - - context.key = key - end - end - - let(:client_context) do - OpenSSL::SSL::SSLContext.new.tap do |context| - context.cert_store = certificate_store - - context.alpn_protocols = ["h2", "http/1.1"] - - context.verify_mode = OpenSSL::SSL::VERIFY_PEER - end - end - - # Shared port for localhost network tests. - let(:server_endpoint) {Async::HTTP::Endpoint.parse("https://localhost:6779/", ssl_context: server_context)} - let(:client_endpoint) {Async::HTTP::Endpoint.parse("https://localhost:6779/", ssl_context: client_context)} - - it "client can get a resource via https" do - server = Async::HTTP::Server.for(server_endpoint, protocol: Async::HTTP::Protocol::HTTP1) do |request| - Protocol::HTTP::Response[200, {}, ['Hello World']] - end - - client = Async::HTTP::Client.new(client_endpoint) - - Async do |task| - server_task = task.async do - server.run - end - - response = client.get("/") - - expect(response).to be_success - expect(response.read).to be == "Hello World" - - client.close - server_task.stop - end - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index 3dc0d204..00000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2017-2023, by Samuel Williams. -# Copyright, 2018, by Janko Marohnić. - -require 'traces' - -require 'bundler/setup' -require 'covered/rspec' - -require 'async/rspec' - -ENV['TRACES_BACKEND'] ||= 'traces/backend/test' - -RSpec.shared_context 'docstring as description' do - let(:description) {self.class.metadata.fetch(:description_args).first} -end - -RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - config.include_context 'docstring as description' - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end diff --git a/spec/async/http/body_spec.rb b/test/async/http/body.rb similarity index 55% rename from spec/async/http/body_spec.rb rename to test/async/http/body.rb index e16e13f9..e08ad660 100644 --- a/spec/async/http/body_spec.rb +++ b/test/async/http/body.rb @@ -5,22 +5,15 @@ require 'async/http/body' -require 'async/http/server' -require 'async/http/client' -require 'async/http/endpoint' - -require 'async/io/ssl_socket' - -require_relative 'server_context' - +require 'sus/fixtures/async' +require 'sus/fixtures/openssl' +require 'sus/fixtures/async/http' require 'localhost/authority' -RSpec.shared_examples Async::HTTP::Body do - let(:client) {Async::HTTP::Client.new(client_endpoint, protocol: described_class)} - - context 'with echo server' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint, protocol: described_class) do |request| +ABody = Sus::Shared("a body") do + with 'echo server' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| input = request.body output = Async::HTTP::Body::Writable.new @@ -46,16 +39,16 @@ response = client.post("/", {}, output) - expect(response).to be_success + expect(response).to be(:success?) expect(response.read).to be == "!dlroW olleH" end end - context "with streaming server" do + with "streaming server" do let(:notification) {Async::Notification.new} - let(:server) do - Async::HTTP::Server.for(@bound_endpoint, protocol: described_class) do |request| + let(:app) do + Protocol::HTTP::Middleware.for do |request| body = Async::HTTP::Body::Writable.new Async::Task.current.async do |task| @@ -74,7 +67,7 @@ it "can stream response" do response = client.get("/") - expect(response).to be_success + expect(response).to be(:success?) j = 0 # This validates interleaving @@ -88,23 +81,28 @@ end end -RSpec.describe Async::HTTP::Protocol::HTTP1 do - include_context Async::HTTP::Server +describe Async::HTTP::Protocol::HTTP1 do + include Sus::Fixtures::Async::HTTP::ServerContext - it_should_behave_like Async::HTTP::Body + it_behaves_like ABody end -RSpec.describe Async::HTTP::Protocol::HTTPS do - include_context Async::HTTP::Server +describe Async::HTTP::Protocol::HTTPS do + include Sus::Fixtures::Async::HTTP::ServerContext + include Sus::Fixtures::OpenSSL::ValidCertificateContext let(:authority) {Localhost::Authority.new} let(:server_context) {authority.server_context} let(:client_context) {authority.client_context} - # Shared port for localhost network tests. - let(:server_endpoint) {Async::HTTP::Endpoint.parse("https://localhost:0/", ssl_context: server_context, reuse_port: true)} - let(:client_endpoint) {Async::HTTP::Endpoint.parse("https://localhost:0/", ssl_context: client_context, reuse_port: true)} + def make_server_endpoint(bound_endpoint) + Async::IO::SSLEndpoint.new(super, ssl_context: server_context) + end + + def make_client_endpoint(bound_endpoint) + Async::IO::SSLEndpoint.new(super, ssl_context: client_context) + end - it_should_behave_like Async::HTTP::Body + it_behaves_like ABody end diff --git a/spec/async/http/body/hijack_spec.rb b/test/async/http/body/hijack.rb similarity index 56% rename from spec/async/http/body/hijack_spec.rb rename to test/async/http/body/hijack.rb index 5397180f..73cad02d 100644 --- a/spec/async/http/body/hijack_spec.rb +++ b/test/async/http/body/hijack.rb @@ -5,42 +5,44 @@ require 'async/http/body/hijack' -RSpec.describe Async::HTTP::Body::Hijack do - include_context Async::RSpec::Reactor +require 'sus/fixtures/async' + +describe Async::HTTP::Body::Hijack do + include Sus::Fixtures::Async::ReactorContext + + let(:body) do + subject.wrap do |stream| + 3.times do + stream.write(content) + end + stream.close + end + end let(:content) {"Hello World!"} - describe '#call' do + with '#call' do let(:stream) {Async::HTTP::Body::Writable.new} - subject do - described_class.wrap do |stream| - 3.times do - stream.write(content) - end - stream.close - end - end - it "should generate body using direct invocation" do - subject.call(stream) + body.call(stream) 3.times do expect(stream.read).to be == content end expect(stream.read).to be_nil - expect(stream).to be_empty + expect(stream).to be(:empty?) end it "should generate body using stream" do 3.times do - expect(subject.read).to be == content + expect(body.read).to be == content end - expect(subject.read).to be_nil + expect(body.read).to be_nil - expect(subject).to be_empty + expect(body).to be(:empty?) end end end diff --git a/test/async/http/body/pipe.rb b/test/async/http/body/pipe.rb new file mode 100644 index 00000000..fe620ec9 --- /dev/null +++ b/test/async/http/body/pipe.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020, by Bruno Sutic. +# Copyright, 2020-2023, by Samuel Williams. + +require 'async' +require 'async/http/body/pipe' +require 'async/http/body/writable' + +require 'sus/fixtures/async' + +describe Async::HTTP::Body::Pipe do + let(:input) {Async::HTTP::Body::Writable.new} + let(:pipe) {subject.new(input)} + + let(:data) {'Hello World!'} + + with '#to_io' do + include Sus::Fixtures::Async::ReactorContext + + let(:input_write_duration) {0} + let(:io) { pipe.to_io } + + def before + super + + # input writer task + Async do |task| + first, second = data.split(' ') + input.write("#{first} ") + task.sleep(input_write_duration) if input_write_duration > 0 + input.write(second) + input.close + end + end + + def aftrer + io.close + + super + end + + it "returns an io socket" do + expect(io).to be_a(Async::IO::Socket) + expect(io.read).to be == data + end + + with 'blocking reads' do + let(:input_write_duration) {0.01} + + it 'returns an io socket' do + expect(io.read).to be == data + end + end + end + + with 'reactor going out of scope' do + it 'finishes' do + # ensures pipe background tasks are transient + Async{pipe} + end + + with 'closed pipe' do + it 'finishes' do + Async{pipe.close} + end + end + end +end diff --git a/test/async/http/body/slowloris.rb b/test/async/http/body/slowloris.rb new file mode 100644 index 00000000..dc3e48be --- /dev/null +++ b/test/async/http/body/slowloris.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'async/http/body/slowloris' + +require 'sus/fixtures/async' +require 'async/http/body/a_writable_body' + +describe Async::HTTP::Body::Slowloris do + include Sus::Fixtures::Async::ReactorContext + + let(:body) {subject.new} + + it_behaves_like Async::HTTP::Body::AWritableBody + + it "closes body with error if throughput is not maintained" do + body.write("Hello World") + + sleep 0.1 + + expect do + body.write("Hello World") + end.to raise_exception(Async::HTTP::Body::Slowloris::ThroughputError, message: be =~ /Slow write/) + end + + it "doesn't close body if throughput is exceeded" do + body.write("Hello World") + + expect do + body.write("Hello World") + end.not.to raise_exception + end +end diff --git a/test/async/http/body/writable.rb b/test/async/http/body/writable.rb new file mode 100644 index 00000000..9d553a58 --- /dev/null +++ b/test/async/http/body/writable.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2023, by Samuel Williams. + +require 'async/http/body/slowloris' + +require 'sus/fixtures/async' +require 'async/http/body/a_writable_body' + +describe Async::HTTP::Body::Writable do + include Sus::Fixtures::Async::ReactorContext + + let(:body) {subject.new} + + it_behaves_like Async::HTTP::Body::AWritableBody +end diff --git a/test/async/http/client.rb b/test/async/http/client.rb new file mode 100644 index 00000000..3a4cdb91 --- /dev/null +++ b/test/async/http/client.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2017-2023, by Samuel Williams. + +require 'async/http/server' +require 'async/http/client' +require 'async/reactor' + +require 'async/io/ssl_socket' +require 'async/http/endpoint' +require 'protocol/http/accept_encoding' + +require 'sus/fixtures/async' +require 'sus/fixtures/async/http' + +describe Async::HTTP::Client do + with 'basic server' do + include Sus::Fixtures::Async::HTTP::ServerContext + + it "client can get resource" do + response = client.get("/") + response.read + expect(response).to be(:success?) + end + end + + with 'non-existant host' do + include Sus::Fixtures::Async::ReactorContext + + let(:endpoint) {Async::HTTP::Endpoint.parse('http://the.future/')} + let(:client) {Async::HTTP::Client.new(endpoint)} + + it "should fail to connect" do + expect do + client.get("/") + end.to raise_exception(SocketError, message: be =~ /not known/) + end + end +end diff --git a/test/async/http/client/codeotaku.rb b/test/async/http/client/codeotaku.rb new file mode 100644 index 00000000..5dfe7487 --- /dev/null +++ b/test/async/http/client/codeotaku.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023, by Samuel Williams. + +require 'async/http/client' +require 'async/http/endpoint' +require 'protocol/http/accept_encoding' + +require 'sus/fixtures/async' + +describe Async::HTTP::Client do + include Sus::Fixtures::Async::ReactorContext + + let(:endpoint) {Async::HTTP::Endpoint.parse('https://www.codeotaku.com/')} + let(:client) {Async::HTTP::Client.new(endpoint)} + + it "should specify hostname" do + expect(endpoint.hostname).to be == "www.codeotaku.com" + expect(client.authority).to be == "www.codeotaku.com" + end + + it 'can fetch remote resource' do + response = client.get('/index') + + response.finish + + expect(response).not.to be(:failure?) + end + + it "can request remote resource with compression" do + compressor = Protocol::HTTP::AcceptEncoding.new(client) + + response = compressor.get("/index", {'accept-encoding' => 'gzip'}) + + expect(response).to be(:success?) + + expect(response.body).to be_a Async::HTTP::Body::Inflate + expect(response.read).to be(:start_with?, '') + end +end + diff --git a/spec/async/http/client/google_spec.rb b/test/async/http/client/google.rb similarity index 73% rename from spec/async/http/client/google_spec.rb rename to test/async/http/client/google.rb index 63f583c4..f8c8ae27 100644 --- a/spec/async/http/client/google_spec.rb +++ b/test/async/http/client/google.rb @@ -6,18 +6,20 @@ require 'async/http/client' require 'async/http/endpoint' -RSpec.describe Async::HTTP::Client, timeout: 5 do - include_context Async::RSpec::Reactor +require 'sus/fixtures/async' + +describe Async::HTTP::Client do + include Sus::Fixtures::Async::ReactorContext let(:endpoint) {Async::HTTP::Endpoint.parse('https://www.google.com/')} let(:client) {Async::HTTP::Client.new(endpoint)} it 'can fetch remote resource' do response = client.get('/', 'accept' => '*/*') - + response.finish - - expect(response).to_not be_failure + + expect(response).not.to be(:failure?) client.close end diff --git a/test/async/http/endpoint.rb b/test/async/http/endpoint.rb new file mode 100644 index 00000000..d14b1464 --- /dev/null +++ b/test/async/http/endpoint.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2023, by Samuel Williams. +# Copyright, 2021-2022, by Adam Daniels. + +require 'async/http/endpoint' + +describe Async::HTTP::Endpoint do + it "should fail to parse relative url" do + expect do + subject.parse("/foo/bar") + end.to raise_exception(ArgumentError, message: be =~ /absolute/) + end + + with '#port' do + let(:url_string) {"https://localhost:9292/"} + + it "extracts port from URL" do + endpoint = Async::HTTP::Endpoint.parse(url_string) + + expect(endpoint).to have_attributes(port: be == 9292) + end + + it "extracts port from options" do + endpoint = Async::HTTP::Endpoint.parse(url_string, port: 9000) + + expect(endpoint).to have_attributes(port: be == 9000) + end + end + + with '#hostname' do + describe Async::HTTP::Endpoint.parse("https://127.0.0.1:9292/") do + it 'has correct hostname' do + expect(subject).to have_attributes(hostname: be == '127.0.0.1') + end + + it "should be connecting to 127.0.0.1" do + expect(subject.endpoint).to be_a Async::IO::SSLEndpoint + expect(subject.endpoint).to have_attributes(hostname: be == '127.0.0.1') + expect(subject.endpoint.endpoint).to have_attributes(hostname: be == '127.0.0.1') + end + end + + describe Async::HTTP::Endpoint.parse("https://127.0.0.1:9292/", hostname: 'localhost') do + it 'has correct hostname' do + expect(subject).to have_attributes(hostname: be == 'localhost') + expect(subject).not.to be(:localhost?) + end + + it "should be connecting to localhost" do + expect(subject.endpoint).to be_a Async::IO::SSLEndpoint + expect(subject.endpoint).to have_attributes(hostname: be == '127.0.0.1') + expect(subject.endpoint.endpoint).to have_attributes(hostname: be == 'localhost') + end + end + end + + with '.for' do + describe Async::HTTP::Endpoint.for("http", "localhost") do + it "should have correct attributes" do + expect(subject).to have_attributes( + scheme: be == "http", + hostname: be == "localhost", + path: be == "/" + ) + + expect(subject).not.to be(:secure?) + end + end + + describe Async::HTTP::Endpoint.for("http", "localhost", "/foo") do + it "should have correct attributes" do + expect(subject).to have_attributes( + scheme: be == "http", + hostname: be == "localhost", + path: be == "/foo" + ) + + expect(subject).not.to be(:secure?) + end + end + end + + with '#secure?' do + describe Async::HTTP::Endpoint.parse("http://localhost/") do + it "should not be secure" do + expect(subject).not.to be(:secure?) + end + end + + describe Async::HTTP::Endpoint.parse("https://localhost/") do + it "should be secure" do + expect(subject).to be(:secure?) + end + end + + with 'scheme: https' do + describe Async::HTTP::Endpoint.parse("http://localhost/", scheme: 'https') do + it "should be secure" do + expect(subject).to be(:secure?) + end + end + end + end + + with '#localhost?' do + describe Async::HTTP::Endpoint.parse("http://localhost/") do + it "should be localhost" do + expect(subject).to be(:localhost?) + end + end + + describe Async::HTTP::Endpoint.parse("http://hello.localhost/") do + it "should be localhost" do + expect(subject).to be(:localhost?) + end + end + + describe Async::HTTP::Endpoint.parse("http://localhost./") do + it "should be localhost" do + expect(subject).to be(:localhost?) + end + end + + describe Async::HTTP::Endpoint.parse("http://hello.localhost./") do + it "should be localhost" do + expect(subject).to be(:localhost?) + end + end + + describe Async::HTTP::Endpoint.parse("http://localhost.com/") do + it "should not be localhost" do + expect(subject).not.to be(:localhost?) + end + end + end + + with '#path' do + describe Async::HTTP::Endpoint.parse("http://foo.com/bar?baz") do + it "should have correct path" do + expect(subject).to have_attributes(path: be == "/bar?baz") + end + end + + with 'websocket scheme' do + describe Async::HTTP::Endpoint.parse("wss://foo.com/bar?baz") do + it "should have correct path" do + expect(subject).to have_attributes(path: be == "/bar?baz") + end + end + end + end +end + +describe Async::HTTP::Endpoint.parse("http://www.google.com/search") do + it "should select the correct protocol" do + expect(subject.protocol).to be == Async::HTTP::Protocol::HTTP1 + end + + it "should parse the correct hostname" do + expect(subject).to have_attributes( + scheme: be == "http", + hostname: be == "www.google.com", + path: be == "/search" + ) + end + + it "should not be equal if path is different" do + other = Async::HTTP::Endpoint.parse('http://www.google.com/search?q=ruby') + expect(subject).not.to be == other + expect(subject).not.to be(:eql?, other) + end +end diff --git a/test/async/http/internet.rb b/test/async/http/internet.rb new file mode 100644 index 00000000..ac25f408 --- /dev/null +++ b/test/async/http/internet.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2023, by Samuel Williams. + +require 'async/http/internet' +require 'async/reactor' + +require 'json' +require 'sus/fixtures/async' + +describe Async::HTTP::Internet do + include Sus::Fixtures::Async::ReactorContext + + let(:internet) {subject.new} + let(:headers) {[['accept', '*/*'], ['user-agent', 'async-http']]} + + it "can fetch remote website" do + response = internet.get("https://www.codeotaku.com/index", headers) + + expect(response).to be(:success?) + + response.close + end + + let(:sample) {{"hello" => "world"}} + let(:body) {[JSON.dump(sample)]} + + # This test is increasingly flakey. + it "can fetch remote json" do + response = internet.post("https://httpbin.org/anything", headers, body) + + expect(response).to be(:success?) + expect{JSON.parse(response.read)}.not.to raise_exception + end +end diff --git a/spec/async/http/internet/instance_spec.rb b/test/async/http/internet/instance.rb similarity index 59% rename from spec/async/http/internet/instance_spec.rb rename to test/async/http/internet/instance.rb index 846d2028..d254bd59 100644 --- a/spec/async/http/internet/instance_spec.rb +++ b/test/async/http/internet/instance.rb @@ -4,12 +4,11 @@ # Copyright, 2021-2023, by Samuel Williams. require 'async/http/internet/instance' -require 'async/reactor' -RSpec.describe Async::HTTP::Internet, timeout: 5 do +describe Async::HTTP::Internet do describe '.instance' do it "returns an internet instance" do - expect(Async::HTTP::Internet.instance).to be_kind_of(Async::HTTP::Internet) + expect(Async::HTTP::Internet.instance).to be_a(Async::HTTP::Internet) end end end diff --git a/spec/async/http/protocol/http10_spec.rb b/test/async/http/protocol/http10.rb similarity index 55% rename from spec/async/http/protocol/http10_spec.rb rename to test/async/http/protocol/http10.rb index 7f06eb98..26ae0be4 100644 --- a/spec/async/http/protocol/http10_spec.rb +++ b/test/async/http/protocol/http10.rb @@ -4,8 +4,8 @@ # Copyright, 2018-2023, by Samuel Williams. require 'async/http/protocol/http10' -require_relative 'shared_examples' +require 'async/http/a_protocol' -RSpec.describe Async::HTTP::Protocol::HTTP10 do - it_behaves_like Async::HTTP::Protocol +describe Async::HTTP::Protocol::HTTP10 do + it_behaves_like Async::HTTP::AProtocol end diff --git a/test/async/http/protocol/http11.rb b/test/async/http/protocol/http11.rb new file mode 100755 index 00000000..67448c2a --- /dev/null +++ b/test/async/http/protocol/http11.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2017-2023, by Samuel Williams. +# Copyright, 2018, by Janko Marohnić. +# Copyright, 2023, by Thomas Morgan. + +require 'async/http/protocol/http11' +require 'async/http/a_protocol' + +describe Async::HTTP::Protocol::HTTP11 do + it_behaves_like Async::HTTP::AProtocol + + with 'server' do + include Sus::Fixtures::Async::HTTP::ServerContext + let(:protocol) {subject} + + with 'bad requests' do + def around + current = Console.logger.level + Console.logger.fatal! + + super + ensure + Console.logger.level = current + end + + it "should fail cleanly when path is empty" do + response = client.get("") + + expect(response.status).to be == 400 + end + end + + with 'head request' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, {}, ["Hello", "World"]] + end + end + + it "doesn't reply with body" do + 5.times do + response = client.head("/") + + expect(response).to be(:success?) + expect(response.version).to be == "HTTP/1.1" + expect(response.body).to be(:empty?) + expect(response.reason).to be == "OK" + + response.read + end + end + end + + with 'raw response' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + peer = request.hijack! + + peer.write( + "#{request.version} 200 It worked!\r\n" + + "connection: close\r\n" + + "\r\n" + + "Hello World!" + ) + peer.close + + nil + end + end + + it "reads raw response" do + response = client.get("/") + + expect(response.read).to be == "Hello World!" + end + + it "has access to the http reason phrase" do + response = client.head("/") + + expect(response.reason).to be == "It worked!" + end + end + end +end diff --git a/spec/async/http/protocol/http11/desync_spec.rb b/test/async/http/protocol/http11/desync.rb similarity index 73% rename from spec/async/http/protocol/http11/desync_spec.rb rename to test/async/http/protocol/http11/desync.rb index 5e9f7cf2..d31598a6 100644 --- a/spec/async/http/protocol/http11/desync_spec.rb +++ b/test/async/http/protocol/http11/desync.rb @@ -3,23 +3,26 @@ # Released under the MIT License. # Copyright, 2021-2023, by Samuel Williams. -require_relative '../../server_context' require 'async/http/protocol/http11' -RSpec.describe Async::HTTP::Protocol::HTTP11, timeout: 30 do - include_context Async::HTTP::Server +require 'sus/fixtures/async/http/server_context' + +describe Async::HTTP::Protocol::HTTP11 do + include Sus::Fixtures::Async::ReactorContext + include Sus::Fixtures::Async::HTTP::ServerContext - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + let(:app) do + Protocol::HTTP::Middleware.for do |request| Protocol::HTTP::Response[200, {}, [request.path]] end end - around do |example| + + def around current = Console.logger.level Console.logger.fatal! - - example.run + + super ensure Console.logger.level = current end @@ -63,7 +66,8 @@ child.stop end - puts "Backtraces" - pp backtraces.sort.uniq + # puts "Backtraces" + # pp backtraces.sort.uniq + expect(backtraces).not.to be(:empty?) end end diff --git a/test/async/http/protocol/http2.rb b/test/async/http/protocol/http2.rb new file mode 100644 index 00000000..f914653f --- /dev/null +++ b/test/async/http/protocol/http2.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2023, by Samuel Williams. + +require 'async/http/protocol/http2' +require 'async/http/a_protocol' + +describe Async::HTTP::Protocol::HTTP2 do + it_behaves_like Async::HTTP::AProtocol + + with 'server' do + include Sus::Fixtures::Async::HTTP::ServerContext + let(:protocol) {subject} + + with 'bad requests' do + it "should fail with explicit authority" do + expect do + client.post("/", [[':authority', 'foo']]) + end.to raise_exception(Protocol::HTTP2::StreamError) + end + end + + with 'closed streams' do + it 'should delete stream after response stream is closed' do + response = client.get("/") + connection = response.connection + + response.read + + expect(connection.streams).to be(:empty?) + end + end + + with 'host header' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, request.headers, ["Authority: #{request.authority.inspect}"]] + end + end + + def make_client(endpoint, **options) + # We specify nil for the authority - it won't be sent. + options[:authority] = nil + super + end + + it "should not send :authority header if host header is present" do + response = client.post("/", [['host', 'foo']]) + + expect(response.headers).to have_keys('host') + expect(response.headers['host']).to be == 'foo' + + # TODO Should HTTP/2 respect host header? + expect(response.read).to be == "Authority: nil" + end + end + + with 'stopping requests' do + let(:notification) {Async::Notification.new} + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + body = Async::HTTP::Body::Writable.new + + reactor.async do |task| + begin + 100.times do |i| + body.write("Chunk #{i}") + task.sleep (0.01) + end + rescue + # puts "Response generation failed: #{$!}" + ensure + body.close + notification.signal + end + end + + Protocol::HTTP::Response[200, {}, body] + end + end + + let(:pool) {client.pool} + + it "should close stream without closing connection" do + expect(pool).to be(:empty?) + + response = client.get("/") + + expect(pool).not.to be(:empty?) + + response.close + + notification.wait + + expect(response.stream.connection).to be(:reusable?) + end + end + end +end diff --git a/spec/async/http/proxy_spec.rb b/test/async/http/proxy.rb similarity index 75% rename from spec/async/http/proxy_spec.rb rename to test/async/http/proxy.rb index ace9665f..23c823e4 100644 --- a/spec/async/http/proxy_spec.rb +++ b/test/async/http/proxy.rb @@ -9,32 +9,34 @@ require 'async/http/protocol' require 'async/http/body/hijack' -require_relative 'server_context' +require 'sus/fixtures/async/http' -RSpec.shared_examples_for Async::HTTP::Proxy do - include_context Async::HTTP::Server +AProxy = Sus::Shared("a proxy") do + include Sus::Fixtures::Async::HTTP::ServerContext - describe '.proxied_endpoint' do + let(:protocol) {subject} + + with '.proxied_endpoint' do it "can construct valid endpoint" do endpoint = Async::HTTP::Endpoint.parse("http://www.codeotaku.com/") proxied_endpoint = client.proxied_endpoint(endpoint) - expect(proxied_endpoint).to be_kind_of(Async::HTTP::Endpoint) + expect(proxied_endpoint).to be_a(Async::HTTP::Endpoint) end end - describe '.proxied_client' do + with '.proxied_client' do it "can construct valid client" do endpoint = Async::HTTP::Endpoint.parse("http://www.codeotaku.com/") proxied_client = client.proxied_client(endpoint) - expect(proxied_client).to be_kind_of(Async::HTTP::Client) + expect(proxied_client).to be_a(Async::HTTP::Client) end end - context 'CONNECT' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + with 'CONNECT' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| Async::HTTP::Body::Hijack.response(request, 200, {}) do |stream| chunk = stream.read stream.close_read @@ -52,7 +54,7 @@ response = client.connect("127.0.0.1:1234", [], input) - expect(response).to be_success + expect(response).to be(:success?) input.write(data) input.close @@ -61,9 +63,9 @@ end end - context 'echo server' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + with 'echo server' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| expect(request.path).to be == "localhost:1" Async::HTTP::Body::Hijack.response(request, 200, {}) do |stream| @@ -81,7 +83,7 @@ it "can connect to remote system using block" do proxy = Async::HTTP::Proxy.tcp(client, "localhost", 1) - expect(proxy.client.pool).to be_empty + expect(proxy.client.pool).to be(:empty?) proxy.connect do |peer| stream = Async::IO::Stream.new(peer) @@ -93,12 +95,12 @@ end proxy.close - expect(proxy.client.pool).to be_empty + expect(proxy.client.pool).to be(:empty?) end it "can connect to remote system" do proxy = Async::HTTP::Proxy.tcp(client, "localhost", 1) - expect(proxy.client.pool).to be_empty + expect(proxy.client.pool).to be(:empty?) stream = Async::IO::Stream.new(proxy.connect) @@ -110,13 +112,13 @@ stream.close proxy.close - expect(proxy.client.pool).to be_empty + expect(proxy.client.pool).to be(:empty?) end end - context 'proxied client' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + with 'proxied client' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| expect(request.method).to be == "CONNECT" unless authorization_lambda.call(request) @@ -174,18 +176,16 @@ proxy_client = client.proxied_client(endpoint) response = proxy_client.get("/search") - expect(response).to_not be_failure + expect(response).not.to be(:failure?) # The response would be a redirect: - expect(response).to be_redirection + expect(response).to be(:redirection?) response.finish # The proxy.connnect response is not being released correctly - after pipe is done: - expect(proxy_client.pool).to_not be_empty + expect(proxy_client.pool).not.to be(:empty?) proxy_client.close - expect(proxy_client.pool).to be_empty - - pp client + expect(proxy_client.pool).to be(:empty?) end it 'can get secure website' do @@ -194,18 +194,18 @@ response = proxy_client.get("/search") - expect(response).to_not be_failure - expect(response.read).to_not be_empty + expect(response).not.to be(:failure?) + expect(response.read).not.to be(:empty?) proxy_client.close end - context 'authorization header required' do + with 'authorization header required' do let(:authorization_lambda) do ->(request) {request.headers['proxy-authorization'] == 'supersecretpassword' } end - context 'request includes headers' do + with 'request includes headers' do let(:headers) { [['Proxy-Authorization', 'supersecretpassword']] } it 'succeeds' do @@ -214,14 +214,14 @@ response = proxy_client.get('/search') - expect(response).to_not be_failure - expect(response.read).to_not be_empty + expect(response).not.to be(:failure?) + expect(response.read).not.to be(:empty?) proxy_client.close end end - context 'request does not include headers' do + with 'request does not include headers' do it 'does not succeed' do endpoint = Async::HTTP::Endpoint.parse("https://www.google.com/") proxy_client = client.proxied_client(endpoint) @@ -229,7 +229,7 @@ expect do # Why is this response not 407? Because the response should come from the proxied connection, but that connection failed to be established. Because of that, there is no response. If we respond here with 407, it would be indistinguisable from the remote server returning 407. That would be an odd case, but none-the-less a valid one. response = proxy_client.get('/search') - end.to raise_error(Async::HTTP::Proxy::ConnectFailure) + end.to raise_exception(Async::HTTP::Proxy::ConnectFailure) proxy_client.close end @@ -238,14 +238,14 @@ end end -RSpec.describe Async::HTTP::Protocol::HTTP10 do - it_behaves_like Async::HTTP::Proxy +describe Async::HTTP::Protocol::HTTP10 do + it_behaves_like AProxy end -RSpec.describe Async::HTTP::Protocol::HTTP11 do - it_behaves_like Async::HTTP::Proxy +describe Async::HTTP::Protocol::HTTP11 do + it_behaves_like AProxy end -RSpec.describe Async::HTTP::Protocol::HTTP2 do - it_behaves_like Async::HTTP::Proxy +describe Async::HTTP::Protocol::HTTP2 do + it_behaves_like AProxy end diff --git a/spec/async/http/relative_location_spec.rb b/test/async/http/relative_location.rb similarity index 61% rename from spec/async/http/relative_location_spec.rb rename to test/async/http/relative_location.rb index 55657d39..4f453dbb 100644 --- a/spec/async/http/relative_location_spec.rb +++ b/test/async/http/relative_location.rb @@ -4,21 +4,20 @@ # Copyright, 2018-2023, by Samuel Williams. # Copyright, 2019-2020, by Brian Morearty. -require_relative 'server_context' - require 'async/http/relative_location' require 'async/http/server' -RSpec.describe Async::HTTP::RelativeLocation do - include_context Async::HTTP::Server - let(:protocol) {Async::HTTP::Protocol::HTTP1} +require 'sus/fixtures/async/http' + +describe Async::HTTP::RelativeLocation do + include Sus::Fixtures::Async::HTTP::ServerContext - subject {described_class.new(@client, 1)} + let(:relative_location) {subject.new(@client, 1)} - context 'server redirections' do - context '301' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + with 'server redirections' do + with '301' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| case request.path when '/home' Protocol::HTTP::Response[301, {'location' => '/'}, []] @@ -31,30 +30,30 @@ end it 'should redirect POST to GET' do - response = subject.post('/') + response = relative_location.post('/') - expect(response).to be_success + expect(response).to be(:success?) expect(response.read).to be == "GET" end - context 'limiting redirects' do + with 'limiting redirects' do it 'should allow the maximum number of redirects' do - response = subject.get('/') + response = relative_location.get('/') response.finish - expect(response).to be_success + expect(response).to be(:success?) end it 'should fail with maximum redirects' do expect{ - response = subject.get('/home') - }.to raise_error(Async::HTTP::TooManyRedirects, /maximum/) + response = relative_location.get('/home') + }.to raise_exception(Async::HTTP::TooManyRedirects, message: be =~ /maximum/) end end end - context '302' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + with '302' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| case request.path when '/' Protocol::HTTP::Response[302, {'location' => '/index.html'}, []] @@ -65,16 +64,16 @@ end it 'should redirect POST to GET' do - response = subject.post('/') + response = relative_location.post('/') - expect(response).to be_success + expect(response).to be(:success?) expect(response.read).to be == "GET" end end - context '307' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + with '307' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| case request.path when '/' Protocol::HTTP::Response[307, {'location' => '/index.html'}, []] @@ -85,16 +84,16 @@ end it 'should redirect with same method' do - response = subject.post('/') + response = relative_location.post('/') - expect(response).to be_success + expect(response).to be(:success?) expect(response.read).to be == "POST" end end - context '308' do - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| + with '308' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| case request.path when '/' Protocol::HTTP::Response[308, {'location' => '/index.html'}, []] @@ -105,9 +104,9 @@ end it 'should redirect with same method' do - response = subject.post('/') + response = relative_location.post('/') - expect(response).to be_success + expect(response).to be(:success?) expect(response.read).to be == "POST" end end diff --git a/test/async/http/retry.rb b/test/async/http/retry.rb new file mode 100644 index 00000000..20fb7dd8 --- /dev/null +++ b/test/async/http/retry.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2023, by Samuel Williams. + +require 'async/http/client' +require 'async/http/endpoint' + +require 'sus/fixtures/async/http' + +describe 'consistent retry behaviour' do + include Sus::Fixtures::Async::HTTP::ServerContext + + let(:delay) {0.1} + let(:retries) {2} + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + sleep(delay) + Protocol::HTTP::Response[200, {}, []] + end + end + + def make_request(body) + # This causes the first request to fail with "SocketError" which is retried: + Async::Task.current.with_timeout(delay / 2.0, SocketError) do + return client.get('/', {}, body) + end + end + + it "retries with nil body" do + response = make_request(nil) + expect(response).to be(:success?) + end + + it "retries with empty body" do + response = make_request([]) + expect(response).to be(:success?) + end +end diff --git a/test/async/http/ssl.rb b/test/async/http/ssl.rb new file mode 100644 index 00000000..54ccb70c --- /dev/null +++ b/test/async/http/ssl.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2023, by Samuel Williams. + +require 'async/http/server' +require 'async/http/client' +require 'async/http/endpoint' + +require 'async/io/ssl_socket' + +require 'sus/fixtures/async' +require 'sus/fixtures/openssl' +require 'sus/fixtures/async/http' + +describe Async::HTTP::Server do + include Sus::Fixtures::Async::HTTP::ServerContext + include Sus::Fixtures::OpenSSL::ValidCertificateContext + + with "application layer protocol negotiation" do + let(:server_context) do + OpenSSL::SSL::SSLContext.new.tap do |context| + context.cert = certificate + + context.alpn_select_cb = lambda do |protocols| + protocols.last + end + + context.key = key + end + end + + let(:client_context) do + OpenSSL::SSL::SSLContext.new.tap do |context| + context.cert_store = certificate_store + + context.alpn_protocols = ["h2", "http/1.1"] + + context.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end + + def make_server_endpoint(bound_endpoint) + Async::IO::SSLEndpoint.new(super, ssl_context: server_context) + end + + def make_client_endpoint(bound_endpoint) + Async::IO::SSLEndpoint.new(super, ssl_context: client_context) + end + + it "client can get a resource via https" do + response = client.get("/") + + expect(response).to be(:success?) + expect(response.read).to be == "Hello World!" + end + end +end diff --git a/spec/async/http/statistics_spec.rb b/test/async/http/statistics.rb similarity index 56% rename from spec/async/http/statistics_spec.rb rename to test/async/http/statistics.rb index dac46248..df0aa039 100644 --- a/spec/async/http/statistics_spec.rb +++ b/test/async/http/statistics.rb @@ -3,17 +3,15 @@ # Released under the MIT License. # Copyright, 2018-2023, by Samuel Williams. -require_relative 'server_context' - require 'async/http/statistics' +require 'sus/fixtures/async/http' -RSpec.describe Async::HTTP::Statistics, timeout: 5 do - include_context Async::HTTP::Server - let(:protocol) {Async::HTTP::Protocol::HTTP1} +describe Async::HTTP::Statistics do + include Sus::Fixtures::Async::HTTP::ServerContext - let(:server) do - Async::HTTP::Server.for(@bound_endpoint) do |request| - statistics = described_class.start + let(:app) do + Protocol::HTTP::Middleware.for do |request| + statistics = subject.start response = Protocol::HTTP::Response[200, {}, ["Hello ", "World!"]] @@ -21,7 +19,7 @@ expect(statistics.sent).to be == 12 expect(error).to be_nil end.tap do |response| - expect(response.body).to receive(:complete_statistics).and_call_original + expect(response.body).to receive(:complete_statistics) end end end @@ -30,6 +28,6 @@ response = client.get("/") expect(response.read).to be == "Hello World!" - expect(response).to be_success + expect(response).to be(:success?) end end diff --git a/spec/rack/test_spec.rb b/test/rack/test.rb similarity index 87% rename from spec/rack/test_spec.rb rename to test/rack/test.rb index 3b12ddb2..0576ed77 100644 --- a/spec/rack/test_spec.rb +++ b/test/rack/test.rb @@ -3,14 +3,14 @@ # Released under the MIT License. # Copyright, 2019-2023, by Samuel Williams. +require 'sus/fixtures/async' +require 'async/http' + require 'rack/test' require 'rack/builder' -require 'async' -require 'async/http' - -RSpec.describe Rack::Test do - include_context Async::RSpec::Reactor +describe Rack::Test do + include Sus::Fixtures::Async::ReactorContext include Rack::Test::Methods let(:app) do