Add exception types

Add specs
Fix some bugs
This commit is contained in:
George Dietrich 2020-12-22 12:52:33 -05:00
parent 8d6d8cbf79
commit 180ba3fae4
17 changed files with 433 additions and 45 deletions

View file

@ -20,3 +20,6 @@ development_dependencies:
ameba: ameba:
github: crystal-ameba/ameba github: crystal-ameba/ameba
version: ~> 0.13.0 version: ~> 0.13.0
athena-spec:
github: athena-framework/spec
version: ~> 0.2.3

View file

@ -0,0 +1,31 @@
require "./spec_helper"
struct AcceptLanguageTest < ASPEC::TestCase
@[DataProvider("type_data_provider")]
def test_get_type(header : String?, expected : String?) : Nil
ANG::AcceptLanguage.new(header).type.should eq expected
end
def type_data_provider : Tuple
{
{"en;q=0.7", "en"},
{"en-GB;q=0.8", "en-gb"},
{"da", "da"},
{"en-gb;q=0.8", "en-gb"},
{"es;q=0.7", "es"},
{"fr ; q= 0.1", "fr"},
}
end
@[DataProvider("value_data_provider")]
def test_get_value(header : String?, expected : String?) : Nil
ANG::AcceptLanguage.new(header).value.should eq expected
end
def value_data_provider : Tuple
{
{"en;q=0.7", "en;q=0.7"},
{"en-GB;q=0.8", "en-GB;q=0.8"},
}
end
end

43
spec/accept_match_spec.cr Normal file
View file

@ -0,0 +1,43 @@
require "./spec_helper"
struct AcceptMatchTest < ASPEC::TestCase
@[DataProvider("compare_data_provider")]
def test_compare(match1 : ANG::AcceptMatch, match2 : ANG::AcceptMatch, expected : Int32) : Nil
(match1 <=> match2).should eq expected
end
def compare_data_provider : Tuple
{
{ANG::AcceptMatch.new(1.0, 110, 1), ANG::AcceptMatch.new(1.0, 111, 1), 0},
{ANG::AcceptMatch.new(0.1, 10, 1), ANG::AcceptMatch.new(0.1, 10, 2), -1},
{ANG::AcceptMatch.new(0.5, 110, 5), ANG::AcceptMatch.new(0.5, 11, 4), 1},
{ANG::AcceptMatch.new(0.4, 110, 1), ANG::AcceptMatch.new(0.6, 111, 3), 1},
{ANG::AcceptMatch.new(0.6, 110, 1), ANG::AcceptMatch.new(0.4, 111, 3), -1},
}
end
@[DataProvider("reduce_data_provider")]
def test_reduce(matches : Hash(Int32, ANG::AcceptMatch), match : ANG::AcceptMatch, expected : Hash(Int32, ANG::AcceptMatch)) : Nil
ANG::AcceptMatch.reduce(matches, match).should eq expected
end
def reduce_data_provider : Tuple
{
{
{1 => ANG::AcceptMatch.new(1.0, 10, 1)},
ANG::AcceptMatch.new(0.5, 111, 1),
{1 => ANG::AcceptMatch.new(0.5, 111, 1)},
},
{
{1 => ANG::AcceptMatch.new(1.0, 110, 1)},
ANG::AcceptMatch.new(0.5, 11, 1),
{1 => ANG::AcceptMatch.new(1.0, 110, 1)},
},
{
{0 => ANG::AcceptMatch.new(1.0, 10, 1)},
ANG::AcceptMatch.new(0.5, 111, 1),
{0 => ANG::AcceptMatch.new(1.0, 10, 1), 1 => ANG::AcceptMatch.new(0.5, 111, 1)},
},
}
end
end

54
spec/accept_spec.cr Normal file
View file

@ -0,0 +1,54 @@
require "./spec_helper"
struct AcceptTest < ASPEC::TestCase
def test_parameters : Nil
ANG::Accept.new("foo/bar; q=1; hello=world").parameters["hello"]?.should eq "world"
end
@[DataProvider("normalized_value_data_provider")]
def test_normalized_value(header : String, expected : String) : Nil
ANG::Accept.new(header).normalized_value.should eq expected
end
def normalized_value_data_provider : Tuple
{
{"text/html; z=y; a=b; c=d", "text/html; z=y; a=b; c=d"},
{"application/pdf; q=1; param=p", "application/pdf; param=p"},
}
end
@[DataProvider("type_data_provider")]
def test_type(header : String, expected : String) : Nil
ANG::Accept.new(header).type.should eq expected
end
def type_data_provider : Tuple
{
{"text/html;hello=world", "text/html"},
{"application/pdf", "application/pdf"},
{"application/xhtml+xml;q=0.9", "application/xhtml+xml"},
{"text/plain; q=0.5", "text/plain"},
{"text/html;level=2;q=0.4", "text/html"},
{"text/html ; level = 2 ; q = 0.4", "text/html"},
{"text/*", "text/*"},
{"text/* ;q=1 ;level=2", "text/*"},
{"*/*", "*/*"},
{"*", "*/*"},
{"*/* ; param=555", "*/*"},
{"* ; param=555", "*/*"},
{"TEXT/hTmL;leVel=2; Q=0.4", "text/html"},
}
end
@[DataProvider("value_data_provider")]
def test_value(header : String, expected : String) : Nil
ANG::Accept.new(header).value.should eq expected
end
def value_data_provider : Tuple
{
{"text/html;hello=world ;q=0.5", "text/html;hello=world ;q=0.5"},
{"application/pdf", "application/pdf"},
}
end
end

View file

@ -1,7 +0,0 @@
require "./spec_helper"
describe Athena::Negotiation do
it "works" do
false.should eq(true)
end
end

65
spec/base_accept_spec.cr Normal file
View file

@ -0,0 +1,65 @@
require "./spec_helper"
private struct MockAccept < ANG::BaseAccept; end
struct BaseAcceptTest < ASPEC::TestCase
@[DataProvider("build_parameters_data_provider")]
def test_build_parameters_string(header : String, expected : String) : Nil
MockAccept.new(header).normalized_value.should eq expected
end
def build_parameters_data_provider : Tuple
{
{"media/type; xxx = 1.0;level=2;foo=bar", "media/type; xxx=1.0; level=2; foo=bar"},
}
end
@[DataProvider("parameters_data_provider")]
def test_parse_parameters(header : String, expected_parameters : Hash(String, String)) : Nil
accept = MockAccept.new header
parameters = accept.parameters
# TODO: Can this be improved?
if header.includes? 'q'
parameters["q"] = accept.quality.to_s
end
expected_parameters.size.should eq parameters.size
expected_parameters.each do |k, v|
parameters.has_key?(k).should be_true
parameters[k].should eq v
end
end
def parameters_data_provider : Tuple
{
{
"application/json ;q=1.0; level=2;foo= bar",
{
"q" => "1.0",
"level" => "2",
"foo" => "bar",
},
},
{
"application/json ;q = 1.0; level = 2; FOO = bAr",
{
"q" => "1.0",
"level" => "2",
"foo" => "bAr",
},
},
{
"application/json;q=1.0",
{
"q" => "1.0",
},
},
{
"application/json;foo",
{} of String => String,
},
}
end
end

101
spec/negotiator_spec.cr Normal file
View file

@ -0,0 +1,101 @@
require "./spec_helper"
struct NegotiatorTest < ASPEC::TestCase
@negotiator : ANG::Negotiator
def initialize
@negotiator = ANG::Negotiator.new
end
def test_exception_handling : Nil
ex = expect_raises ANG::Exceptions::InvalidMediaType, "Invalid media type: '/qwer'." do
@negotiator.best "foo/bar", {"/qwer"}
end
ex.type.should eq "/qwer"
expect_raises ArgumentError, "priorities should not be empty." do
@negotiator.best "foo/bar", [] of String
end
expect_raises ArgumentError, "The header string should not be empty." do
@negotiator.best "", {"text/html"}
end
end
@[DataProvider("best_data_provider")]
def test_best(header : String, priorities : Indexable(String), expected : Tuple(String, Hash(String, String) | Nil) | Nil) : Nil
begin
accept_header = @negotiator.best header, priorities
rescue ex
ex.should eq expected
return
end
if accept_header.nil?
expected.should be_nil
return
end
accept_header.should be_a ANG::Accept
expected = expected.should_not be_nil
accept_header.type.should eq expected[0]
accept_header.parameters.should eq (expected[1] || Hash(String, String).new)
end
def best_data_provider : Tuple
rfc_header = "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5"
php_pear_header = "text/html,application/xhtml+xml,application/xml;q=0.9,text/*;q=0.7,*/*,image/gif; q=0.8, image/jpeg; q=0.6, image/*"
{
{"/qwer", {"f/g"}, nil},
{"text/html", {"application/rss"}, nil},
{rfc_header, {"text/html;q=0.4", "text/plain"}, {"text/plain", nil}},
# See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html.
{rfc_header, {"text/html;level=1"}, {"text/html", {"level" => "1"}}},
{rfc_header, {"text/html"}, {"text/html", nil}},
{rfc_header, {"image/jpeg"}, {"image/jpeg", nil}},
{rfc_header, {"text/html;level=2"}, {"text/html", {"level" => "2"}}},
{rfc_header, {"text/html;level=3"}, {"text/html", {"level" => "3"}}},
{"text/*;q=0.7, text/html;q=0.3, */*;q=0.5, image/png;q=0.4", {"text/html", "image/png"}, {"image/png", nil}},
{"image/png;q=0.1, text/plain, audio/ogg;q=0.9", {"image/png", "text/plain", "audio/ogg"}, {"text/plain", nil}},
{"image/png, text/plain, audio/ogg", {"baz/asdf"}, nil},
{"image/png, text/plain, audio/ogg", {"audio/ogg"}, {"audio/ogg", nil}},
{"image/png, text/plain, audio/ogg", {"YO/SuP"}, nil},
{"text/html; charset=UTF-8, application/pdf", {"text/html; charset=UTF-8"}, {"text/html", {"charset" => "UTF-8"}}},
{"text/html; charset=UTF-8, application/pdf", {"text/html"}, nil},
{"text/html, application/pdf", {"text/html; charset=UTF-8"}, {"text/html", {"charset" => "UTF-8"}}},
# PHP"s PEAR HTTP2 assertions I took from the other lib.
{php_pear_header, {"image/gif", "image/png", "application/xhtml+xml", "application/xml", "text/html", "image/jpeg", "text/plain"}, {"image/png", nil}},
{php_pear_header, {"image/gif", "application/xhtml+xml", "application/xml", "image/jpeg", "text/plain"}, {"application/xhtml+xml", nil}},
{php_pear_header, {"image/gif", "application/xml", "image/jpeg", "text/plain"}, {"application/xml", nil}},
{php_pear_header, {"image/gif", "image/jpeg", "text/plain"}, {"image/gif", nil}},
{php_pear_header, {"text/plain", "image/png", "image/jpeg"}, {"image/png", nil}},
{php_pear_header, {"image/jpeg", "image/gif"}, {"image/gif", nil}},
{php_pear_header, {"image/png"}, {"image/png", nil}},
{php_pear_header, {"audio/midi"}, {"audio/midi", nil}},
{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", {"application/rss+xml"}, {"application/rss+xml", nil}},
# Case sensitiviy
{"text/* ; q=0.3, TEXT/html ;Q=0.7, text/html ; level=1, texT/Html ;leVel = 2 ;q=0.4, */* ; q=0.5", {"text/html; level=2"}, {"text/html", {"level" => "2"}}},
{"text/* ; q=0.3, text/html;Q=0.7, text/html ;level=1, text/html; level=2;q=0.4, */*;q=0.5", {"text/HTML; level=3"}, {"text/html", {"level" => "3"}}},
# IE8
{"image/jpeg, application/x-ms-application, image/gif, application/xaml+xml, image/pjpeg, application/x-ms-xbap, */*", {"text/html", "application/xhtml+xml"}, {"text/html", nil}},
# wildcards with '+'
{"application/vnd.api+json", {"application/json", "application/*+json"}, {"application/*+json", nil}},
{"application/json;q=0.7, application/*+json;q=0.7", {"application/hal+json", "application/problem+json"}, {"application/hal+json", nil}},
{"application/json;q=0.7, application/problem+*;q=0.7", {"application/hal+xml", "application/problem+xml"}, {"application/problem+xml", nil}},
{php_pear_header, {"application/*+xml"}, {"application/*+xml", nil}},
{"application/hal+json", {"application/ld+json", "application/hal+json", "application/xml", "text/xml", "application/json", "text/html"}, {"application/hal+json", nil}},
}
end
end

View file

@ -1,2 +1,7 @@
require "spec" require "spec"
require "athena-spec"
require "../src/athena-negotiation" require "../src/athena-negotiation"
include ASPEC::Methods
ASPEC.run_all

View file

@ -1,9 +1,9 @@
abstract class Athena::Negotiation::AbstractNegotiator abstract class Athena::Negotiation::AbstractNegotiator
private abstract def create_header(header : String) : ANG::BaseAccept private abstract def create_header(header : String) : ANG::BaseAccept
def best(header : String, priorities : Array(String), strict : Bool = false) : ANG::BaseAccept? def best(header : String, priorities : Indexable(String), strict : Bool = false) : ANG::BaseAccept?
raise ArgumentError.new "priorities should not be empty" if priorities.empty? raise ArgumentError.new "priorities should not be empty." if priorities.empty?
raise ArgumentError.new "The header string should not be empty" if header.blank? raise ArgumentError.new "The header string should not be empty." if header.blank?
accepted_headers = Array(ANG::BaseAccept).new accepted_headers = Array(ANG::BaseAccept).new
@ -17,7 +17,26 @@ abstract class Athena::Negotiation::AbstractNegotiator
matches = self.find_matches accepted_headers, accepted_priorties matches = self.find_matches accepted_headers, accepted_priorties
pp matches specific_matches = matches.reduce({} of Int32 => ANG::AcceptMatch) do |matches, match|
ANG::AcceptMatch.reduce matches, match
end.values
specific_matches.sort!
match = specific_matches.shift?
match.nil? ? nil : accepted_priorties[match.index]
end
protected def match(header : ANG::BaseAccept, priority : ANG::BaseAccept, index : Int32) : ANG::AcceptMatch?
accept_type = header.type
priority_type = priority.type
equal = accept_type.downcase == priority_type.downcase
if equal || accept_type == "*"
return ANG::AcceptMatch.new header.quality * priority.quality, 1 * (equal ? 1 : 0), index
end
nil nil
end end
@ -28,7 +47,7 @@ abstract class Athena::Negotiation::AbstractNegotiator
end end
end end
private def find_matches(headers : Array(ANG::BaseAccept), priorities : Array(ANG::BaseAccept)) : Array(ANG::AcceptMatch) private def find_matches(headers : Array(ANG::BaseAccept), priorities : Indexable(ANG::BaseAccept)) : Array(ANG::AcceptMatch)
matches = [] of ANG::AcceptMatch matches = [] of ANG::AcceptMatch
priorities.each_with_index do |priority, idx| priorities.each_with_index do |priority, idx|
@ -41,17 +60,4 @@ abstract class Athena::Negotiation::AbstractNegotiator
matches matches
end end
private def match(header : ANG::BaseAccept, priority : ANG::BaseAccept, index : Int32) : ANG::AcceptMatch?
accept_type = header.type
priority_type = priority.type
equal = accept_type.downcase <=> priority_type.downcase
if !equal.zero? || accept_type == "*"
return ANG::AcceptMatch.new header.quality * priority.quality, 1 * equal, index
end
nil
end
end end

View file

@ -12,7 +12,7 @@ struct Athena::Negotiation::Accept < Athena::Negotiation::BaseAccept
parts = @type.split '/' parts = @type.split '/'
# TODO: Use more specific exception # TODO: Use more specific exception
raise "Invalid media type: '#{@type}'." if parts.size != 2 || !parts[0].presence || !parts[1].presence raise ANG::Exceptions::InvalidMediaType.new @type, "Invalid media type: '#{@type}'." if parts.size != 2 || !parts[0].presence || !parts[1].presence
@base_part = parts[0] @base_part = parts[0]
@sub_part = parts[1] @sub_part = parts[1]

View file

@ -10,8 +10,6 @@ struct Athena::Negotiation::AcceptLanguage < Athena::Negotiation::BaseAccept
parts = @value.split '-' parts = @value.split '-'
pp parts
case parts.size case parts.size
when 2 when 2
@language = parts[0] @language = parts[0]

View file

@ -1,7 +1,29 @@
struct Athena::Negotiation::AcceptMatch struct Athena::Negotiation::AcceptMatch
include Comparable(self)
getter quality : Float32 getter quality : Float32
getter score : Int32 getter score : Int32
getter index : Int32 getter index : Int32
def initialize(@quality : Float32, @score : Int32, @index : Int32); end def self.reduce(matches : Hash(Int32, self), match : self) : Hash(Int32, self)
if !matches.has_key?(match.index) || matches[match.index].score < match.score
matches[match.index] = match
end
matches
end
def initialize(@quality : Float32, @score : Int32, @index : Int32); end
def <=>(other : self) : Int32
if @quality != other.quality
return @quality > other.quality ? -1 : 1
end
if @index != other.index
return @index > other.index ? 1 : -1
end
0
end
end end

View file

@ -5,22 +5,14 @@ require "./accept_encoding"
require "./accept_language" require "./accept_language"
require "./negotiator" require "./negotiator"
require "./exceptions/*"
# Convenience alias to make referencing `Athena::Negotiation` types easier. # Convenience alias to make referencing `Athena::Negotiation` types easier.
alias ANG = Athena::Negotiation alias ANG = Athena::Negotiation
module Athena::Negotiation module Athena::Negotiation
end end
# pp ANG::Accept.new "application/json;q=1.0" # n = ANG::Negotiator.new
# pp ANG::Accept.new "application/json ;q=1.0; level=2;foo= bar"
# pp ANG::Accept.new "text/html ; level = 2 ; q = 0.4"
# puts # pp n.best "text/html; charset=UTF-8, application/pdf", ["text/html"]
# puts
# pp ANG::AcceptLanguage.new "en-gb;q=0.8"
n = ANG::Negotiator.new
pp n.best "text/html;level=1", ["text/html"] # text/html
pp n.best "text/*;q=0.7, text/html;q=0.3, */*;q=0.5, image/png;q=0.4", ["text/html", "image/png"] # image/png

View file

@ -2,7 +2,7 @@ abstract struct Athena::Negotiation::BaseAccept
getter quality : Float32 = 1.0 getter quality : Float32 = 1.0
getter normalized_value : String getter normalized_value : String
getter value : String getter value : String
getter parameters : Hash(String, String) getter parameters : Hash(String, String) = Hash(String, String).new
getter type : String getter type : String
def initialize(@value : String) def initialize(@value : String)
@ -10,13 +10,13 @@ abstract struct Athena::Negotiation::BaseAccept
parts = @value.split ';' parts = @value.split ';'
@type = parts.shift.strip.downcase @type = parts.shift.strip.downcase
@parameters = parts.to_h do |part| parts.each do |part|
part = part.split '=' part = part.split '='
# TODO: Use more specific exception # Skip invalid parameters
raise ArgumentError.new "Invalid header: '#{@value}'." unless part.size == 2 next unless part.size == 2
{part[0].strip.downcase, part[1].strip(" \"")} @parameters[part[0].strip.downcase] = part[1].strip(" \"")
end end
if quality = @parameters.delete "q" if quality = @parameters.delete "q"
@ -28,6 +28,7 @@ abstract struct Athena::Negotiation::BaseAccept
unless @parameters.empty? unless @parameters.empty?
io << "; " io << "; "
# TODO: Do we care the parameters aren't sorted?
parameters.join(io, "; ") { |(k, v), io| io << "#{k}=#{v}" } parameters.join(io, "; ") { |(k, v), io| io << "#{k}=#{v}" }
end end
end end

View file

@ -0,0 +1,9 @@
require "./negotiation_exception"
class Athena::Negotiation::Exceptions::InvalidMediaType < Athena::Negotiation::Exceptions::Exception
getter type : String
def initialize(@type : String, message : String? = nil, cause : Exception? = nil)
super message, cause
end
end

View file

@ -0,0 +1,2 @@
abstract class Athena::Negotiation::Exceptions::Exception < ::Exception
end

View file

@ -1,6 +1,69 @@
require "./abstract_negotiator" require "./abstract_negotiator"
class Athena::Negotiation::Negotiator < Athena::Negotiation::AbstractNegotiator class Athena::Negotiation::Negotiator < Athena::Negotiation::AbstractNegotiator
protected def match(accept : ANG::BaseAccept, priority : ANG::BaseAccept, index : Int32) : ANG::AcceptMatch?
return nil if !accept.is_a?(ANG::Accept) || !priority.is_a?(ANG::Accept)
accept_base = accept.base_part
priority_base = priority.base_part
accept_sub = accept.sub_part
priority_sub = priority.sub_part
intercection = accept.parameters.each_with_object({} of String => String) do |(k, v), params|
priority.parameters.tap do |pp|
params[k] = v if pp.has_key?(k) && pp[k] == v
end
end
base_equal = accept_base.downcase == priority_base.downcase
sub_equal = accept_sub.downcase == priority_sub.downcase
if (
(accept_base == "*" || base_equal) &&
(accept_sub == "*" || sub_equal) &&
intercection.size == accept.parameters.size
)
score = 100 * (base_equal ? 1 : 0) + 10 * (sub_equal ? 1 : 0) + intercection.size
return ANG::AcceptMatch.new accept.quality * priority.quality, score, index
end
return nil if !accept_sub.includes?('+') || !priority_sub.includes?('+')
accept_sub, accept_plus = self.split_sub_part accept_sub
priority_sub, priority_plus = self.split_sub_part priority_sub
if (
!(accept_base == "*" || base_equal) ||
!(accept_sub == "*" || priority_sub == "*" || accept_plus == "*" || priority_plus == "*")
)
return nil
end
sub_equal = accept_sub.downcase == priority_sub.downcase
plus_equal = accept_plus.downcase == priority_plus.downcase
if (
(accept_sub == "*" || priority_sub == "*" || sub_equal) &&
(accept_plus == "*" || priority_plus == '*' || plus_equal) &&
intercection.size == accept.parameters.size
)
# TODO: Calculate intercection between each header's parameters
score = 100 * (base_equal ? 1 : 0) + 10 * (sub_equal ? 1 : 0) + (plus_equal ? 1 : 0) + intercection.size
return ANG::AcceptMatch.new accept.quality * priority.quality, score, index
end
nil
end
private def split_sub_part(sub_part : String) : Array(String)
return [sub_part, ""] unless sub_part.includes? '+'
sub_part.split '+', limit: 2
end
private def create_header(header : String) : ANG::BaseAccept private def create_header(header : String) : ANG::BaseAccept
ANG::Accept.new header ANG::Accept.new header
end end