diff --git a/config/external.yaml b/config/external.yaml index 21bcc9b..af34817 100644 --- a/config/external.yaml +++ b/config/external.yaml @@ -1,3 +1,3 @@ falcon: url: https://github.com/socketry/falcon - command: bundle exec rspec + command: bundle exec sus diff --git a/fixtures/async/http/cache/a_general_cache.rb b/fixtures/async/http/cache/a_general_cache.rb new file mode 100644 index 0000000..267b224 --- /dev/null +++ b/fixtures/async/http/cache/a_general_cache.rb @@ -0,0 +1,167 @@ +require 'async/http/cache/general' + +module Async::HTTP::Cache + AGeneralCache = Sus::Shared("a general cache") do + it "should cache GET requests" do + # Warm up the cache: + response = client.get("/") + + expect(response.read).to be == "Hello World" + + # Makle 10 more requests, which return the cache: + 10.times do + response = client.get("/") + expect(response.read).to be == "Hello World" + end + + expect(cache).to have_attributes(count: be == 10) + end + + it "should cache HEAD requests" do + response = client.head("/") + # HTTP/1 with content length prevents trailers from being sent. + # Let's get the test suite working and figure out what to do here later. + # content_length = response.body.length + # expect(content_length).to be == 11 + expect(response.read).to be_nil + + 10.times do + response = client.head("/") + # expect(response.body.length).to be == content_length + expect(response.read).to be_nil + end + + expect(cache).to have_attributes(count: be == 10) + end + + it "should not cache POST requests" do + response = client.post("/") + expect(response.read).to be == "Hello World" + + response = client.post("/") + expect(response.read).to be == "Hello World" + + expect(cache).to have_attributes(count: be == 0) + end + + with 'varied response' do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + response = if user_agent = request.headers['user-agent'] + Protocol::HTTP::Response[200, [['cache-control', 'max-age=1, public'], ['vary', 'user-agent']], [user_agent]] + else + Protocol::HTTP::Response[200, [['cache-control', 'max-age=1, public'], ['vary', 'user-agent']], ['Hello', ' ', 'World']] + end + + if request.head? + response.body = Protocol::HTTP::Body::Head.for(response.body) + end + + response + end + end + + let(:user_agents) {[ + 'test-a', + 'test-b', + ]} + + it "should cache GET requests" do + 2.times do + user_agents.each do |user_agent| + response = client.get("/", {'user-agent' => user_agent}) + expect(response.headers['vary']).to be(:include?, 'user-agent') + expect(response.read).to be == user_agent + end + end + + expect(store.index.size).to be == 2 + end + end + + with 'cache writes' do + with 'response code' do + let(:app) do + Protocol::HTTP::Middleware.for do |_request| + Protocol::HTTP::Response[response_code, [], ['body']] + end + end + + [200, 203, 300, 301, 302, 404, 410].each do |response_code| + with "cacheable response code #{response_code}" do + let(:response_code) {response_code} + + it 'is cached' do + responses = 2.times.map {client.get("/", {}).tap(&:finish)} + headers = responses.map {|r| r.headers.to_h} + + expect(headers.first).not.to be(:include?, 'x-cache') + expect(headers.last).to have_keys('x-cache' => be == ['hit']) + end + end + end + + [202, 303, 400, 403, 500, 503].each do |response_code| + with "not cacheable response code #{response_code}" do + let(:response_code) {response_code} + + it 'is not cached' do + responses = 2.times.map {client.get("/", {}).tap(&:finish)} + response_headers = responses.map {|r| r.headers.to_h} + + expect(response_headers).to be == [{}, {}] # no x-cache: hit + end + end + end + end + + with 'by cache-control: flag' do + let(:app) do + Protocol::HTTP::Middleware.for do |_request| + Protocol::HTTP::Response[200, headers] # no body? + end + end + + ['no-store', 'private'].each do |flag| + let(:headers) {[['cache-control', flag]]} + let(:headers_hash) {Hash[headers.map {|k, v| [k, [v]]}]} + + with "not cacheable response #{flag}" do + it 'is not cached' do + responses = 2.times.map {client.get("/", {}).tap(&:finish)} + response_headers = responses.map {|r| r.headers.to_h} + + expect(response_headers).to be == [headers_hash, headers_hash] # no x-cache: hit + end + end + end + + with 'cacheable response' do + let(:headers) {[]} + + it 'is cached' do + responses = 2.times.map {client.get("/", {}).tap(&:finish)} + headers = responses.map {|r| r.headers.to_h} + + expect(headers).to be == [{}, {"x-cache"=>["hit"]}] + end + end + end + end + + with 'if-none-match' do + it 'validate etag' do + # First, warm up the cache: + response = client.get("/") + expect(response.headers).not.to be(:include?, 'etag') + expect(response.read).to be == "Hello World" + expect(response.headers).to be(:include?, 'etag') + + etag = response.headers['etag'] + + response = client.get("/", {'if-none-match' => etag}) + expect(response).to be(:not_modified?) + end + end + end +end diff --git a/gems.rb b/gems.rb index d63f841..0c00e6a 100644 --- a/gems.rb +++ b/gems.rb @@ -24,6 +24,9 @@ gem "covered" gem "decode" + gem "sus-fixtures-async-http" + gem "sus-fixtures-console" + gem "bake-test" gem "bake-test-external" end diff --git a/lib/async/http/cache/body.rb b/lib/async/http/cache/body.rb index 3c5597d..2a4351f 100644 --- a/lib/async/http/cache/body.rb +++ b/lib/async/http/cache/body.rb @@ -7,6 +7,9 @@ require 'protocol/http/body/completable' require 'protocol/http/body/digestable' +require 'console' +require 'console/event/failure' + module Async module HTTP module Cache @@ -36,7 +39,7 @@ def self.wrap(response, &block) # Wrap the response with the callback: ::Protocol::HTTP::Body::Completable.wrap(response) do |error| if error - Console.logger.error(self) {error} + Console::Event::Failure.for(error).emit(self) else yield response, rewindable.buffered end diff --git a/spec/async/http/cache/general_spec.rb b/spec/async/http/cache/general_spec.rb deleted file mode 100644 index 497cb60..0000000 --- a/spec/async/http/cache/general_spec.rb +++ /dev/null @@ -1,229 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2020-2022, by Samuel Williams. -# Copyright, 2022, by Colin Kelley. - -require_relative 'server_context' - -require 'async/http/cache/general' - -RSpec.shared_examples_for Async::HTTP::Cache::General do - it "should cache GET requests" do - response = subject.get("/") - expect(response.read).to be == "Hello World" - - 10.times do - response = subject.get("/") - expect(response.read).to be == "Hello World" - end - - expect(cache).to have_attributes(count: 10) - end - - it "should cache HEAD requests" do - response = subject.head("/") - # HTTP/1 with content length prevents trailers from being sent. - # Let's get the test suite working and figure out what to do here later. - # content_length = response.body.length - # expect(content_length).to be == 11 - expect(response.read).to be_nil - - 10.times do - response = subject.head("/") - # expect(response.body.length).to be == content_length - expect(response.read).to be_nil - end - - expect(cache).to have_attributes(count: 10) - end - - it "should not cache POST requests" do - response = subject.post("/") - expect(response.read).to be == "Hello World" - - response = subject.post("/") - expect(response.read).to be == "Hello World" - - expect(cache).to have_attributes(count: 0) - end - - context 'with varied response' do - let(:app) do - Protocol::HTTP::Middleware.for do |request| - response = if user_agent = request.headers['user-agent'] - Protocol::HTTP::Response[200, [['cache-control', 'max-age=1, public'], ['vary', 'user-agent']], [user_agent]] - else - Protocol::HTTP::Response[200, [['cache-control', 'max-age=1, public'], ['vary', 'user-agent']], ['Hello', ' ', 'World']] - end - - if request.head? - response.body = Protocol::HTTP::Body::Head.for(response.body) - end - - response - end - end - - let(:user_agents) {[ - 'test-a', - 'test-b', - ]} - - it "should cache GET requests" do - 2.times do - user_agents.each do |user_agent| - response = subject.get("/", {'user-agent' => user_agent}) - expect(response.headers['vary']).to include('user-agent') - expect(response.read).to be == user_agent - end - end - - expect(store.index.size).to be == 2 - end - end - - context 'cache writes' do - context 'by response code' do - let(:app) do - Protocol::HTTP::Middleware.for do |_request| - Protocol::HTTP::Response[response_code, [], ['body']] - end - end - - [200, 203, 300, 301, 302, 404, 410].each do |response_code| - context "when cacheable: #{response_code}" do - let(:response_code) {response_code} - - it 'is cached' do - responses = 2.times.map {subject.get("/", {}).tap(&:finish)} - headers = responses.map {|r| r.headers.to_h} - - expect(headers.first).not_to include('x-cache') - expect(headers.last).to include('x-cache' => ['hit']) - end - end - end - - [202, 303, 400, 403, 500, 503].each do |response_code| - context "when not cacheable: #{response_code}" do - let(:response_code) {response_code} - - it 'is not cached' do - responses = 2.times.map {subject.get("/", {}).tap(&:finish)} - response_headers = responses.map {|r| r.headers.to_h} - - expect(response_headers).to be == [{}, {}] # no x-cache: hit - end - end - end - end - - context 'by cache-control: flag' do - let(:app) do - Protocol::HTTP::Middleware.for do |_request| - Protocol::HTTP::Response[200, headers] # no body? - end - end - - ['no-store', 'private'].each do |flag| - let(:headers) {[['cache-control', flag]]} - let(:headers_hash) {Hash[headers.map {|k, v| [k, [v]]}]} - - context "when not cacheable #{flag}" do - it 'is not cached' do - responses = 2.times.map {subject.get("/", {}).tap(&:finish)} - response_headers = responses.map {|r| r.headers.to_h} - - expect(response_headers).to be == [headers_hash, headers_hash] # no x-cache: hit - end - end - end - - context 'when cacheable' do - let(:headers) {[]} - - it 'is cached' do - responses = 2.times.map {subject.get("/", {}).tap(&:finish)} - headers = responses.map {|r| r.headers.to_h} - - expect(headers).to be == [{}, {"x-cache"=>["hit"]}] - end - end - end - end - - context 'with if-none-match' do - it 'validate etag' do - # First, warm up the cache: - response = subject.get("/") - expect(response.headers).to_not include('etag') - expect(response.read).to be == "Hello World" - expect(response.headers).to include('etag') - - etag = response.headers['etag'] - - response = subject.get("/", {'if-none-match' => etag}) - expect(response).to be_not_modified - end - end -end - -RSpec.describe Async::HTTP::Cache::General do - include_context Async::HTTP::Server - - let(:app) do - Protocol::HTTP::Middleware.for do |request| - body = Async::HTTP::Body::Writable.new # (11) - - Async do |task| - body.write "Hello" - body.write " " - task.yield - body.write "World" - body.close - rescue Async::HTTP::Body::Writable::Closed - # Ignore... probably head request. - end - - response = Protocol::HTTP::Response[200, [['cache-control', 'max-age=1, public']], body] - - if request.head? - response.body = Protocol::HTTP::Body::Head.for(response.body) - end - - response - end - end - - let(:store) {cache.store.delegate} - - context 'with client-side cache' do - subject(:cache) {described_class.new(client)} - let(:store) {subject.store.delegate} - - include_examples Async::HTTP::Cache::General - end - - context 'with server-side cache via HTTP/1.1' do - let(:protocol) {Async::HTTP::Protocol::HTTP11} - - subject {client} - - let(:cache) {described_class.new(app)} - let(:middleware) {cache} - - include_examples Async::HTTP::Cache::General - end - - context 'with server-side cache via HTTP/2' do - let(:protocol) {Async::HTTP::Protocol::HTTP2} - - subject {client} - - let(:cache) {described_class.new(app)} - let(:middleware) {cache} - - include_examples Async::HTTP::Cache::General - end -end diff --git a/spec/async/http/cache/server_context.rb b/spec/async/http/cache/server_context.rb deleted file mode 100644 index 99459f9..0000000 --- a/spec/async/http/cache/server_context.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2020-2022, 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) {Async::HTTP::Protocol::HTTP1} - let(:endpoint) {Async::HTTP::Endpoint.parse('http://127.0.0.1:9294', reuse_port: true, protocol: protocol)} - - let(:retries) {1} - - let(:app) do - Protocol::HTTP::Middleware::HelloWorld - end - - let(:middleware) do - app - end - - let(:server) do - Async::HTTP::Server.new(middleware, @bound_endpoint) - 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(endpoint) - - # 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) - - @server_task = Async do - server.run - end - - @client = Async::HTTP::Client.new(endpoint, retries: retries) - end - - after do - @client.close - @server_task.stop - @bound_endpoint.close - end - - let(:client) {@client} -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index 75bfb9c..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2020, by Samuel Williams. - -require "bundler/setup" -require "async/rspec" - -RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on `Module` and `main` - config.disable_monkey_patching! - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end diff --git a/spec/async/http/cache_spec.rb b/test/async/http/cache.rb similarity index 63% rename from spec/async/http/cache_spec.rb rename to test/async/http/cache.rb index 1e2f3af..e6b5eae 100644 --- a/spec/async/http/cache_spec.rb +++ b/test/async/http/cache.rb @@ -4,8 +4,8 @@ # Copyright, 2020, by Samuel Williams. # Copyright, 2022, by Colin Kelley. -RSpec.describe Async::HTTP::Cache do +describe Async::HTTP::Cache do it "has a version number" do - expect(Async::HTTP::Cache::VERSION).to match(/\A\d+\.\d+\.\d+\z/) + expect(Async::HTTP::Cache::VERSION).to be =~ /\d+\.\d+\.\d+/ end end diff --git a/spec/async/http/cache/body_spec.rb b/test/async/http/cache/body.rb similarity index 59% rename from spec/async/http/cache/body_spec.rb rename to test/async/http/cache/body.rb index 715d1f6..90fb47c 100644 --- a/spec/async/http/cache/body_spec.rb +++ b/test/async/http/cache/body.rb @@ -4,24 +4,27 @@ # Copyright, 2020, by Samuel Williams. require 'async/http/cache/body' +require 'protocol/http' -RSpec.describe Async::HTTP::Cache::Body do - include_context RSpec::Memory +require 'sus/fixtures/console' + +describe Async::HTTP::Cache::Body do + include_context Sus::Fixtures::Console::CapturedLogger let(:body) {Protocol::HTTP::Body::Buffered.new(["Hello", "World"])} let(:response) {Protocol::HTTP::Response[200, [], body]} - describe ".wrap" do + with ".wrap" do it "can buffer and stream bodies" do invoked = false - described_class.wrap(response) do |response, body| + subject.wrap(response) do |response, body| invoked = true # The cached/buffered body: expect(body.read).to be == "Hello" expect(body.read).to be == "World" - expect(body.read).to be nil + expect(body.read).to be_nil end body = response.body @@ -29,17 +32,17 @@ # The actual body: expect(body.read).to be == "Hello" expect(body.read).to be == "World" - expect(body.read).to be nil + expect(body.read).to be_nil body.close - expect(invoked).to be true + expect(invoked).to be == true end it "ignores failed responses" do invoked = false - described_class.wrap(response) do + subject.wrap(response) do invoked = true end @@ -47,9 +50,14 @@ expect(body.read).to be == "Hello" - body.close(IOError.new("failed")) + body.close(IOError.new("expected failure")) + + expect(invoked).to be == false - expect(invoked).to be false + expect_console.to have_logged( + severity: be == :error, + event: have_keys(message: be =~ /expected failure/) + ) end end end diff --git a/test/async/http/cache/general.rb b/test/async/http/cache/general.rb new file mode 100644 index 0000000..b14bec2 --- /dev/null +++ b/test/async/http/cache/general.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2022, by Samuel Williams. +# Copyright, 2022, by Colin Kelley. + +require 'sus/fixtures/async/http' +require 'async/http/cache/a_general_cache' + +describe Async::HTTP::Cache::General do + include Sus::Fixtures::Async::HTTP::ServerContext + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + body = Async::HTTP::Body::Writable.new # (11) + + Async do |task| + body.write "Hello" + body.write " " + task.yield + body.write "World" + body.close + rescue Async::HTTP::Body::Writable::Closed + # Ignore... probably head request. + end + + response = Protocol::HTTP::Response[200, [['cache-control', 'max-age=1, public']], body] + + if request.head? + response.body = Protocol::HTTP::Body::Head.for(response.body) + end + + response + end + end + + let(:store) {cache.store.delegate} + + with 'client-side cache' do + let(:cache) {subject.new(@client)} + alias client cache + + let(:store) {client.store.delegate} + + it_behaves_like Async::HTTP::Cache::AGeneralCache + end + + with 'server-side cache via HTTP/1.1' do + let(:protocol) {Async::HTTP::Protocol::HTTP11} + + let(:cache) {subject.new(app)} + alias middleware cache + + it_behaves_like Async::HTTP::Cache::AGeneralCache + end + + with 'server-side cache via HTTP/2' do + let(:protocol) {Async::HTTP::Protocol::HTTP2} + + let(:cache) {subject.new(app)} + alias middleware cache + + it_behaves_like Async::HTTP::Cache::AGeneralCache + end +end