diff --git a/lib/http/chainable.rb b/lib/http/chainable.rb index 188f6c37..b3ba0438 100644 --- a/lib/http/chainable.rb +++ b/lib/http/chainable.rb @@ -238,6 +238,7 @@ def nodelay end # Turn on given features. Available features are: + # * acceptable # * auto_inflate # * auto_deflate # * instrumentation diff --git a/lib/http/feature.rb b/lib/http/feature.rb index b178becf..3c855de9 100644 --- a/lib/http/feature.rb +++ b/lib/http/feature.rb @@ -16,6 +16,7 @@ def on_error(_request, _error); end require "http/features/auto_inflate" require "http/features/auto_deflate" +require "http/features/acceptable" require "http/features/logging" require "http/features/instrumentation" require "http/features/normalize_uri" diff --git a/lib/http/features/acceptable.rb b/lib/http/features/acceptable.rb new file mode 100644 index 00000000..2edf7172 --- /dev/null +++ b/lib/http/features/acceptable.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module HTTP + module Features + class Acceptable < Feature + def wrap_response(response) + return response if accepted?(response) + + Response.new( + status: 406, + version: response.version, + headers: response.headers, + proxy_headers: response.proxy_headers, + connection: response.connection, + body: response.body, + request: response.request + ) + end + + private + + def accepted?(response) + accept = response.request[Headers::ACCEPT] + + return true unless accept + + ranges = accept.split(/\s*,\s*/).map { |r| r.gsub(/\s*;.*/, "") } + ranges.any? { |range| match?(response.mime_type, range) } + end + + def match?(mime_type, range) + return true if range == "*/*" + + m1, m2 = mime_type.split("/", 2) + r1, r2 = range.split("/", 2) + + m1 == r1 && ["*", m2].include?(r2) + end + + HTTP::Options.register_feature(:acceptable, self) + end + end +end diff --git a/spec/lib/http/features/acceptable_spec.rb b/spec/lib/http/features/acceptable_spec.rb new file mode 100644 index 00000000..f3230f9f --- /dev/null +++ b/spec/lib/http/features/acceptable_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +RSpec.describe HTTP::Features::Acceptable do + subject(:feature) { described_class.new } + + let(:connection) { double } + let(:headers) { {} } + + describe "#wrap_response" do + subject(:result) { feature.wrap_response(response) } + + let(:request) do + HTTP::Request.new( + verb: :get, + uri: "https://example.com/", + headers: headers + ) + end + let(:response) do + HTTP::Response.new( + version: "1.1", + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + connection: connection, + request: request + ) + end + + context "when there is no Accept header" do + it "returns original request" do + expect(result).to be response + end + end + + context "when MIME type matches single range" do + let(:headers) { { accept: "text/html" } } + + it "returns original request" do + expect(result).to be response + end + end + + context "when MIME type matches range with parameter" do + let(:headers) { { accept: "text/html; q=1" } } + + it "returns original request" do + expect(result).to be response + end + end + + context "when MIME type matches one of multiple ranges" do + let(:headers) { { accept: "text/plain, text/html, image/gif" } } + + it "returns original request" do + expect(result).to be response + end + end + + context "when type matches and subtype does not" do + let(:headers) { { accept: "text/plain" } } + + it "returns synthetic 406 status" do + expect(result.code).to be 406 + end + + it "returns original version" do + expect(result.version).to be response.version + end + + it "returns original headers" do + expect(result.headers).to eq response.headers + end + + it "returns original connection" do + expect(result.connection).to be response.connection + end + + it "returns original request" do + expect(result.request).to be request + end + end + + context "when both type and subtype do not match" do + let(:headers) { { accept: "image/gif" } } + + it "returns original request" do + expect(result.code).to be 406 + end + end + + context "when range is */*" do + let(:headers) { { accept: "*/*" } } + + it "returns original request" do + expect(result).to be response + end + end + + context "when type matches and subtype is wildcard" do + let(:headers) { { accept: "text/*" } } + + it "returns original request" do + expect(result).to be response + end + end + + context "when type does not match and subtype is wildcard" do + let(:headers) { { accept: "image/*" } } + + it "returns original request" do + expect(result.code).to be 406 + end + end + end +end