diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2296d37..34c5a4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: image: crystallang/crystal:latest-alpine steps: - uses: actions/checkout@v2 + - name: Install Dependencies + run: shards install - name: Specs run: crystal spec --order random --error-on-warnings test_nightly: @@ -40,5 +42,7 @@ jobs: image: crystallang/crystal:nightly-alpine steps: - uses: actions/checkout@v2 + - name: Install Dependencies + run: shards install - name: Specs run: crystal spec --order random --error-on-warnings diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index d63dbbc..095862e 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -14,8 +14,10 @@ jobs: - name: Install Build Dependencies run: apk add --update rsync - uses: actions/checkout@v2 + - name: Install Dependencies + run: shards install - name: Build - run: crystal docs + run: crystal docs lib/athena-spec/src/athena-spec.cr src/athena-negotiation.cr - name: Deploy uses: JamesIves/github-pages-deploy-action@3.7.1 with: diff --git a/LICENSE b/LICENSE index eb807d9..880c5e4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020 CREATOR_NAME +Copyright (c) 2020 George Dietrich 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 ffcb657..eb8264a 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,9 @@ -# README +# Negotiation -Template repo for creating a new Athena component. Scaffolds the Crystal shard's structure as well as define CI etc. +[![CI](https://github.com/athena-framework/negotiation/workflows/CI/badge.svg)](https://github.com/athena-framework/negotiation/actions?query=workflow%3ACI) +[![Latest release](https://img.shields.io/github/release/athena-framework/negotiation.svg)](https://github.com/athena-framework/negotiation/releases) -**NOTE:** This repo assumes the component will be in the `athena-framework` org. If it is to be used outside of the org, be sure to update URLs accordingly. - -1. Find/replace `COMPONENT_NAME` with the name of the component. This is used as the shard's name. E.x. `logger`. - 1.1 Be sure to rename the file in `./src`, and `./spec` as well. - -1. Replace `NAMESPACE_NAME` with the name of the component's namespace. Documentation for this component will be grouped under this. E.x. `Logger`. - -1. Find/replace `CREATOR_NAME` with your Github display name. E.x. `George Dietrich`. - -1. Find/replace `CREATOR_USERNAME` with your Github username. E.x. `blacksmoke16`. - -1. Find/replace `CREATOR_EMAIL` with your desired email - - 5.1 Can remove this if you don't wish to expose an email. - -1. Find/replace `ALIAS_NAME` with the three letter alias for this component; A + 2 letter shortcut to `NAMESPACE_NAME`. E.x. `ALG`. - -1. Find/replace `DESCRIPTION` with a short description of what the component does. - -Delete from here up -# NAMESPACE_NAME - -[![CI](https://github.com/athena-framework/COMPONENT_NAME/workflows/CI/badge.svg)](https://github.com/athena-framework/COMPONENT_NAME/actions?query=workflow%3ACI) -[![Latest release](https://img.shields.io/github/release/athena-framework/COMPONENT_NAME.svg)](https://github.com/athena-framework/COMPONENT_NAME/releases) - -DESCRIPTION +Framework agnostic [content negotiation](https://tools.ietf.org/html/rfc7231#section-5.3) library based on [willdurand/Negotiation](https://github.com/willdurand/Negotiation). ## Installation @@ -35,8 +11,8 @@ DESCRIPTION ```yaml dependencies: - athena-COMPONENT_NAME: - github: athena-framework/COMPONENT_NAME + athena-negotiation: + github: athena-framework/negotiation version: ~> 1.0.0 ``` @@ -44,11 +20,11 @@ dependencies: ## Documentation -Everything is documented in the [API Docs](https://athena-framework.github.io/COMPONENT_NAME/Athena/NAMESPACE_NAME.html). +Everything is documented in the [API Docs](https://athena-framework.github.io/negotiation/Athena/Negotiation.html). ## Contributing -1. Fork it (https://github.com/athena-framework/COMPONENT_NAME/fork) +1. Fork it (https://github.com/athena-framework/negotiation/fork) 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) @@ -56,4 +32,4 @@ Everything is documented in the [API Docs](https://athena-framework.github.io/CO ## Contributors -- [CREATOR_NAME](https://github.com/CREATOR_USERNAME) - creator and maintainer +- [George Dietrich](https://github.com/blacksmoke16) - creator and maintainer diff --git a/shard.yml b/shard.yml index a61d40e..1f5a48e 100644 --- a/shard.yml +++ b/shard.yml @@ -1,4 +1,4 @@ -name: athena-COMPONENT_NAME +name: athena-negotiation version: 0.1.0 @@ -6,17 +6,20 @@ crystal: '>= 0.35.0' license: MIT -repository: https://github.com/athena-framework/COMPONENT_NAME +repository: https://github.com/athena-framework/negotiation -documentation: https://athena-framework.github.io/COMPONENT_NAME/Athena/NAMESPACE_NAME.html +documentation: https://athena-framework.github.io/negotiation/Athena/Negotiation.html description: | - DESCRIPTION. + Framework agnostic content negotiation library. authors: - - CREATOR_NAME + - George Dietrich development_dependencies: ameba: github: crystal-ameba/ameba version: ~> 0.13.0 + athena-spec: + github: athena-framework/spec + version: ~> 0.2.3 diff --git a/spec/accept_language_spec.cr b/spec/accept_language_spec.cr new file mode 100644 index 0000000..2107cfc --- /dev/null +++ b/spec/accept_language_spec.cr @@ -0,0 +1,31 @@ +require "./spec_helper" + +struct AcceptLanguageTest < ASPEC::TestCase + @[DataProvider("accept_value_data_provider")] + def test_accept_value(header : String?, expected : String?) : Nil + ANG::AcceptLanguage.new(header).accept_value.should eq expected + end + + def accept_value_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("header_data_provider")] + def test_get_value(header : String?, expected : String?) : Nil + ANG::AcceptLanguage.new(header).header.should eq expected + end + + def header_data_provider : Tuple + { + {"en;q=0.7", "en;q=0.7"}, + {"en-GB;q=0.8", "en-GB;q=0.8"}, + } + end +end diff --git a/spec/accept_match_spec.cr b/spec/accept_match_spec.cr new file mode 100644 index 0000000..b78c513 --- /dev/null +++ b/spec/accept_match_spec.cr @@ -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 diff --git a/spec/accept_spec.cr b/spec/accept_spec.cr new file mode 100644 index 0000000..2884b0f --- /dev/null +++ b/spec/accept_spec.cr @@ -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_header_data_provider")] + def test_normalized_header(header : String, expected : String) : Nil + ANG::Accept.new(header).normalized_header.should eq expected + end + + def normalized_header_data_provider : Tuple + { + {"text/html ; z=y; a = b; c=d", "text/html; a=b; c=d; z=y"}, + {"application/pdf; q=1; param=p", "application/pdf; param=p"}, + } + end + + @[DataProvider("media_range_data_provider")] + def test_media_range(header : String, expected : String) : Nil + ANG::Accept.new(header).media_range.should eq expected + end + + def media_range_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("header_data_provider")] + def test_accept_value(header : String, expected : String) : Nil + ANG::Accept.new(header).header.should eq expected + end + + def header_data_provider : Tuple + { + {"text/html;hello=world ;q=0.5", "text/html;hello=world ;q=0.5"}, + {"application/pdf", "application/pdf"}, + } + end +end diff --git a/spec/athena-COMPONENT_TEMPLATE_spec.cr b/spec/athena-COMPONENT_TEMPLATE_spec.cr deleted file mode 100644 index 49018fc..0000000 --- a/spec/athena-COMPONENT_TEMPLATE_spec.cr +++ /dev/null @@ -1,7 +0,0 @@ -require "./spec_helper" - -describe Athena::NAMESPACE_NAME do - it "works" do - false.should eq(true) - end -end diff --git a/spec/base_accept_spec.cr b/spec/base_accept_spec.cr new file mode 100644 index 0000000..e3675b9 --- /dev/null +++ b/spec/base_accept_spec.cr @@ -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_header.should eq expected + end + + def build_parameters_data_provider : Tuple + { + {"media/type; xxx = 1.0;level=2;foo=bar", "media/type; foo=bar; level=2; xxx=1.0"}, + } + 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 diff --git a/spec/charset_negotiator_spec.cr b/spec/charset_negotiator_spec.cr new file mode 100644 index 0000000..54938b0 --- /dev/null +++ b/spec/charset_negotiator_spec.cr @@ -0,0 +1,67 @@ +require "./spec_helper" + +struct CharsetNegotiatorTest < NegotiatorTestCase + @negotiator : ANG::CharsetNegotiator + + def initialize + @negotiator = ANG::CharsetNegotiator.new + end + + def test_best_unmatched_header : Nil + @negotiator.best("foo, bar, yo", {"baz"}).should be_nil + end + + def test_best_ignores_missing_content : Nil + accept = @negotiator.best "en; q=0.1, fr; q=0.4, bu; q=1.0", {"en", "fr"} + + accept = accept.should_not be_nil + accept.should be_a ANG::AcceptCharset + accept.charset.should eq "fr" + end + + def test_best_respects_priorities : Nil + accept = @negotiator.best "foo, bar, yo", {"yo"} + accept = accept.should_not be_nil + accept.should be_a ANG::AcceptCharset + accept.charset.should eq "yo" + end + + def test_best_respects_quality : Nil + accept = @negotiator.best "utf-8;q=0.5,iso-8859-1", {"iso-8859-1;q=0.3", "utf-8;q=0.9", "utf-16;q=1.0"} + accept = accept.should_not be_nil + accept.should be_a ANG::AcceptCharset + accept.charset.should eq "utf-8" + end + + @[DataProvider("best_data_provider")] + def test_best(header : String, priorities : Indexable(String), expected : String?) : Nil + accept = @negotiator.best header, priorities + + if accept.nil? + expected.should be_nil + else + accept.should be_a ANG::AcceptCharset + accept.header.should eq expected + end + end + + def best_data_provider : Tuple + php_pear_charset = "ISO-8859-1, Big5;q=0.6,utf-8;q=0.7, *;q=0.5" + php_pear_charset2 = "ISO-8859-1, Big5;q=0.6,utf-8;q=0.7" + + { + {php_pear_charset, {"utf-8", "big5", "iso-8859-1", "shift-jis"}, "iso-8859-1"}, + {php_pear_charset, {"utf-8", "big5", "shift-jis"}, "utf-8"}, + {php_pear_charset, {"Big5", "shift-jis"}, "Big5"}, + {php_pear_charset, {"shift-jis"}, "shift-jis"}, + {php_pear_charset2, {"utf-8", "big5", "iso-8859-1", "shift-jis"}, "iso-8859-1"}, + {php_pear_charset2, {"utf-8", "big5", "shift-jis"}, "utf-8"}, + {php_pear_charset2, {"Big5", "shift-jis"}, "Big5"}, + {"utf-8;q=0.6,iso-8859-5;q=0.9", {"iso-8859-5", "utf-8"}, "iso-8859-5"}, + {"en, *;q=0.9", {"fr"}, "fr"}, + # Quality of source factors + {php_pear_charset, {"iso-8859-1;q=0.5", "utf-8", "utf-16;q=1.0"}, "utf-8"}, + {php_pear_charset, {"iso-8859-1;q=0.8", "utf-8", "utf-16;q=1.0"}, "iso-8859-1;q=0.8"}, + } + end +end diff --git a/spec/encoding_negotiator_spec.cr b/spec/encoding_negotiator_spec.cr new file mode 100644 index 0000000..6c65ad5 --- /dev/null +++ b/spec/encoding_negotiator_spec.cr @@ -0,0 +1,42 @@ +require "./spec_helper" + +struct EncodingNegotiatorTest < NegotiatorTestCase + @negotiator : ANG::EncodingNegotiator + + def initialize + @negotiator = ANG::EncodingNegotiator.new + end + + def test_best_unmatched_header : Nil + @negotiator.best("foo, bar, yo", {"baz"}).should be_nil + end + + def test_best_respects_quality : Nil + accept = @negotiator.best "gzip;q=0.7,identity", {"identity;q=0.5", "gzip;q=0.9"} + accept = accept.should_not be_nil + accept.should be_a ANG::AcceptEncoding + accept.coding.should eq "gzip" + end + + @[DataProvider("best_data_provider")] + def test_best(header : String, priorities : Indexable(String), expected : String?) : Nil + accept = @negotiator.best header, priorities + + if accept.nil? + expected.should be_nil + else + accept.should be_a ANG::AcceptEncoding + accept.header.should eq expected + end + end + + def best_data_provider : Tuple + { + {"gzip;q=1.0, identity; q=0.5, *;q=0", {"identity"}, "identity"}, + {"gzip;q=0.5, identity; q=0.5, *;q=0.7", {"bzip", "foo"}, "bzip"}, + {"gzip;q=0.7, identity; q=0.5, *;q=0.7", {"gzip", "foo"}, "gzip"}, + # Quality of source factors + {"gzip;q=0.7,identity", {"identity;q=0.5", "gzip;q=0.9"}, "gzip;q=0.9"}, + } + end +end diff --git a/spec/language_negotiator_spec.cr b/spec/language_negotiator_spec.cr new file mode 100644 index 0000000..a1a15c6 --- /dev/null +++ b/spec/language_negotiator_spec.cr @@ -0,0 +1,43 @@ +require "./spec_helper" + +struct LanguageNegotiatorTest < NegotiatorTestCase + @negotiator : ANG::LanguageNegotiator + + def initialize + @negotiator = ANG::LanguageNegotiator.new + end + + def test_best_respects_quality : Nil + accept = @negotiator.best "en;q=0.5,de", {"de;q=0.3", "en;q=0.9"} + accept = accept.should_not be_nil + accept.should be_a ANG::AcceptLanguage + accept.language.should eq "en" + end + + @[DataProvider("best_data_provider")] + def test_best(header : String, priorities : Indexable(String), expected : String?) : Nil + accept = @negotiator.best header, priorities + + if accept.nil? + expected.should be_nil + else + accept.should be_a ANG::AcceptLanguage + accept.header.should eq expected + end + end + + def best_data_provider : Tuple + { + {"en, de", {"fr"}, nil}, + {"foo, bar, yo", {"baz", "biz"}, nil}, + {"fr-FR, en;q=0.8", {"en-US", "de-DE"}, "en-US"}, + {"en, *;q=0.9", {"fr"}, "fr"}, + {"foo, bar, yo", {"yo"}, "yo"}, + {"en; q=0.1, fr; q=0.4, bu; q=1.0", {"en", "fr"}, "fr"}, + {"en; q=0.1, fr; q=0.4, fu; q=0.9, de; q=0.2", {"en", "fu"}, "fu"}, + {"fr, zh-Hans-CN;q=0.3", {"fr"}, "fr"}, + # Quality of source factors + {"en;q=0.5,de", {"de;q=0.3", "en;q=0.9"}, "en;q=0.9"}, + } + end +end diff --git a/spec/negotiator_spec.cr b/spec/negotiator_spec.cr new file mode 100644 index 0000000..946d3c2 --- /dev/null +++ b/spec/negotiator_spec.cr @@ -0,0 +1,129 @@ +require "./spec_helper" + +struct NegotiatorTest < NegotiatorTestCase + @negotiator : ANG::Negotiator + + def initialize + @negotiator = ANG::Negotiator.new + end + + def test_best_respects_quality : Nil + accept = @negotiator.best "text/html,text/*;q=0.7", {"text/html;q=0.5", "text/plain;q=0.9"} + accept = accept.should_not be_nil + accept.should be_a ANG::Accept + accept.media_range.should eq "text/plain" + end + + def test_best_invalid_unstrict + @negotiator.best("/qwer", {"foo/bar"}, false).should be_nil + end + + def test_invalid_media_type : Nil + ex = expect_raises ANG::Exceptions::InvalidMediaType, "Invalid media type: '/qwer'." do + @negotiator.best "foo/bar", {"/qwer"} + end + + ex.media_range.should eq "/qwer" + 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.media_range.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 + + def test_ordered_elements_exception_handling : Nil + expect_raises ArgumentError, "The header string should not be empty." do + @negotiator.ordered_elements "" + end + end + + @[DataProvider("test_ordered_elements_data_provider")] + def test_ordered_elements(header : String, expected : Indexable(String)) : Nil + elements = @negotiator.ordered_elements header + + expected.each_with_index do |element, idx| + elements[idx].should be_a ANG::Accept + element.should eq elements[idx].header + end + end + + def test_ordered_elements_data_provider : Tuple + { + {"/qwer", [] of String}, # Invalid + {"text/html, text/xml", {"text/html", "text/xml"}}, # Ordered as given if no quality modifier + {"text/html;q=0.3, text/html;q=0.7", {"text/html;q=0.7", "text/html;q=0.3"}}, # Ordered by quality modifier + {"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=1", "text/html;q=0.7", "*/*;q=0.5", "text/html;level=2;q=0.4", "text/*;q=0.3"}}, # Ordered by quality modifier; one without wins + } + end +end diff --git a/spec/negotiator_test_case.cr b/spec/negotiator_test_case.cr new file mode 100644 index 0000000..d8ccd8d --- /dev/null +++ b/spec/negotiator_test_case.cr @@ -0,0 +1,11 @@ +abstract struct NegotiatorTestCase < ASPEC::TestCase + def test_best_exception_handling : Nil + 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 +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 2514e53..d613a60 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,8 @@ require "spec" -require "../src/athena-COMPONENT_NAME" +require "athena-spec" +require "../src/athena-negotiation" +require "./negotiator_test_case" + +include ASPEC::Methods + +ASPEC.run_all diff --git a/src/abstract_negotiator.cr b/src/abstract_negotiator.cr new file mode 100644 index 0000000..a7c47c4 --- /dev/null +++ b/src/abstract_negotiator.cr @@ -0,0 +1,101 @@ +# Base negotiator type. Implements logic common to all negotiators. +abstract class Athena::Negotiation::AbstractNegotiator + private record OrderKey, quality : Float32, index : Int32, value : String do + include Comparable(self) + + def <=>(other : self) : Int32 + return @index <=> other.index if @quality == other.quality + @quality > other.quality ? -1 : 1 + end + end + + private abstract def create_header(header : String) : ANG::BaseAccept + + # Returns the best `ANG::BaseAccept` type based on the provided *header* value and *priorities*. + # + # See `Athena::Negotiation` for examples. + 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 "The header string should not be empty." if header.blank? + + accepted_headers = Array(ANG::BaseAccept).new + + self.parse_header(header) do |h| + accepted_headers << self.create_header h + rescue ex + raise ex if strict + end + + accepted_priorties = priorities.map &->create_header(String) + + matches = self.find_matches accepted_headers, accepted_priorties + + specific_matches = matches.reduce({} of Int32 => ANG::AcceptMatch) do |acc, match| + ANG::AcceptMatch.reduce acc, match + end.values + + specific_matches.sort! + + match = specific_matches.shift? + + match.nil? ? nil : accepted_priorties[match.index] + end + + # Returns an array of `ANG::BaseAccept` types that the provided *header* allows, ordered so that the `#best` match is first. + # + # See `Athena::Negotiation` for examples. + def ordered_elements(header : String) : Array(ANG::BaseAccept) + raise ArgumentError.new "The header string should not be empty." if header.blank? + + elements = Array(ANG::BaseAccept).new + order_keys = Array(OrderKey).new + + idx = 0 + self.parse_header(header) do |h| + element = self.create_header h + elements << element + order_keys << OrderKey.new element.quality, idx, element.header + rescue ex + # skip + ensure + idx += 1 + end + + order_keys.sort!.map do |ok| + elements[ok.index] + end + end + + protected def match(header : ANG::BaseAccept, priority : ANG::BaseAccept, index : Int32) : ANG::AcceptMatch? + accept_value = header.accept_value + priority_value = priority.accept_value + + equal = accept_value.downcase == priority_value.downcase + + if equal || accept_value == "*" + return ANG::AcceptMatch.new header.quality * priority.quality, 1 * (equal ? 1 : 0), index + end + + nil + end + + private def parse_header(header : String, & : String ->) : Nil + header.scan /(?:[^,\"]*+(?:"[^"]*+\")?)+[^,\"]*+/ do |match| + yield match[0].strip unless match[0].blank? + end + end + + private def find_matches(headers : Array(ANG::BaseAccept), priorities : Indexable(ANG::BaseAccept)) : Array(ANG::AcceptMatch) + matches = [] of ANG::AcceptMatch + + priorities.each_with_index do |priority, idx| + headers.each do |header| + if match = self.match(header, priority, idx) + matches << match + end + end + end + + matches + end +end diff --git a/src/accept.cr b/src/accept.cr new file mode 100644 index 0000000..34d85ec --- /dev/null +++ b/src/accept.cr @@ -0,0 +1,45 @@ +require "./base_accept" + +# Represents an [Accept](https://tools.ietf.org/html/rfc7231#section-5.3.2) header media type. +# +# ``` +# accept = ANG::Accept.new "application/json; q = 0.75; charset = UTF-8" +# +# accept.header # => "application/json; q = 0.9; charset = UTF-8" +# accept.normalized_header # => "application/json; charset=UTF-8" +# accept.parameters # => {"charset" => "UTF-8"} +# accept.quality # => 0.75 +# accept.type # => "application" +# accept.sub_type # => "json" +# ``` +struct Athena::Negotiation::Accept < Athena::Negotiation::BaseAccept + # Returns the type for this `Accept` header. + # E.x. if the `#media_range` is `application/json`, the type would be `application`. + getter type : String + + # Returns the sub type for this `Accept` header. + # E.x. if the `#media_range` is `application/json`, the sub type would be `json`. + getter sub_type : String + + def initialize(value : String) + super value + + @accept_value = "*/*" if @accept_value == "*" + + parts = @accept_value.split '/' + + if parts.size != 2 || !parts[0].presence || !parts[1].presence + raise ANG::Exceptions::InvalidMediaType.new @accept_value + end + + @type = parts[0] + @sub_type = parts[1] + end + + # Returns the media range this `Accept` header represents. + # + # I.e. `#header` minus the `#quality` and `#parameters`. + def media_range : String + @accept_value + end +end diff --git a/src/accept_charset.cr b/src/accept_charset.cr new file mode 100644 index 0000000..bd04412 --- /dev/null +++ b/src/accept_charset.cr @@ -0,0 +1,21 @@ +require "./base_accept" + +# Represents an [Accept-Charset](https://tools.ietf.org/html/rfc7231#section-5.3.3) header character set. +# +# ``` +# accept = ANG::AcceptCharset.new "iso-8859-1; q = 0.5; key=value" +# +# accept.header # => "iso-8859-1; q = 0.5; key=value" +# accept.normalized_header # => "iso-8859-1; key=value" +# accept.parameters # => {"key" => "value"} +# accept.quality # => 0.5 +# accept.charset # => "iso-8859-1" +# ``` +struct Athena::Negotiation::AcceptCharset < Athena::Negotiation::BaseAccept + # Returns the character set this `AcceptCharset` header represents. + # + # I.e. `#header` minus the `#quality` and `#parameters`. + def charset : String + @accept_value + end +end diff --git a/src/accept_encoding.cr b/src/accept_encoding.cr new file mode 100644 index 0000000..5157935 --- /dev/null +++ b/src/accept_encoding.cr @@ -0,0 +1,21 @@ +require "./base_accept" + +# Represents an [Accept-Encoding](https://tools.ietf.org/html/rfc7231#section-5.3.4) header character set. +# +# ``` +# accept = ANG::AcceptEncoding.new "gzip; q = 0.5; key=value" +# +# accept.header # => "gzip-1; q = 0.5; key=value" +# accept.normalized_header # => "gzip-1; key=value" +# accept.parameters # => {"key" => "value"} +# accept.quality # => 0.5 +# accept.coding # => "gzip" +# ``` +struct Athena::Negotiation::AcceptEncoding < Athena::Negotiation::BaseAccept + # Returns the content coding this `AcceptEncoding` header represents. + # + # I.e. `#header` minus the `#quality` and `#parameters`. + def coding : String + @accept_value + end +end diff --git a/src/accept_language.cr b/src/accept_language.cr new file mode 100644 index 0000000..b4c3115 --- /dev/null +++ b/src/accept_language.cr @@ -0,0 +1,55 @@ +require "./base_accept" + +# Represents an [Accept-Language](https://tools.ietf.org/html/rfc7231#section-5.3.5) header character set. +# +# ``` +# accept = ANG::AcceptLanguage.new "zh-Hans-CN; q = 0.3; key=value" +# +# accept.header # => "zh-Hans-CN; q = 0.3; key=value" +# accept.normalized_header # => "zh-Hans-CN; key=value" +# accept.parameters # => {"key" => "value"} +# accept.quality # => 0.3 +# accept.language # => "zh" +# accept.region # => "cn" +# accept.script # => "hans" +# ``` +struct Athena::Negotiation::AcceptLanguage < Athena::Negotiation::BaseAccept + # Returns the language for this `AcceptLanguage` header. + # E.x. if the `#language_range` is `zh-Hans-CN`, the language would be `zh`. + getter language : String + + # Returns the region, if any, for this `AcceptLanguage` header. + # E.x. if the `#language_range` is `zh-Hans-CN`, the language would be `cn` + getter region : String? = nil + + # Returns the script, if any, for this `AcceptLanguage` header. + # E.x. if the `#language_range` is `zh-Hans-CN`, the language would be `hans` + getter script : String? = nil + + def initialize(value : String) + super value + + parts = @accept_value.split '-' + + case parts.size + when 1 + @language = parts[0] + when 2 + @language = parts[0] + @region = parts[1] + when 3 + @language = parts[0] + @script = parts[1] + @region = parts[2] + else + raise ANG::Exceptions::InvalidLanguage.new @accept_value + end + end + + # Returns the language range this `AcceptLanguage` header represents. + # + # I.e. `#header` minus the `#quality` and `#parameters`. + def language_range : String + @accept_value + end +end diff --git a/src/accept_match.cr b/src/accept_match.cr new file mode 100644 index 0000000..2774bee --- /dev/null +++ b/src/accept_match.cr @@ -0,0 +1,30 @@ +# :nodoc: +struct Athena::Negotiation::AcceptMatch + include Comparable(self) + + getter quality : Float32 + getter score : Int32 + getter index : Int32 + + 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 diff --git a/src/athena-COMPONENT_NAME.cr b/src/athena-COMPONENT_NAME.cr deleted file mode 100644 index a778877..0000000 --- a/src/athena-COMPONENT_NAME.cr +++ /dev/null @@ -1,6 +0,0 @@ -# Convenience alias to make referencing `Athena::NAMESPACE_NAME` types easier. -alias ALIAS_NAME = Athena::NAMESPACE_NAME - -module Athena::NAMESPACE_NAME - VERSION = "0.1.0" -end diff --git a/src/athena-negotiation.cr b/src/athena-negotiation.cr new file mode 100644 index 0000000..1b36c62 --- /dev/null +++ b/src/athena-negotiation.cr @@ -0,0 +1,103 @@ +require "./accept" +require "./accept_match" +require "./accept_charset" +require "./accept_encoding" +require "./accept_language" +require "./charset_negotiator" +require "./encoding_negotiator" +require "./language_negotiator" +require "./negotiator" + +require "./exceptions/*" + +# Convenience alias to make referencing `Athena::Negotiation` types easier. +alias ANG = Athena::Negotiation + +# The `Athena::Negotiation` component allows an application to support [content negotiation](https://tools.ietf.org/html/rfc7231#section-5.3). +# The component has no dependencies and is framework agnostic; supporting various negotiators. +# +# ## Usage +# +# The main type of `Athena::Negotiation` is `ANG::AbstractNegotiator` which is used to implement negotiators for each `Accept*` header. +# `Athena::Negotiation` exposes class level getters for each negotiator; that return a lazily initialized singleton instance. +# Each negotiator exposes two methods: `ANG::AbstractNegotiator#best` and `ANG::AbstractNegotiator#ordered_elements`. +# +# ### Media Type +# +# ``` +# negotiator = ANG.negotiator +# +# accept_header = "text/html, application/xhtml+xml, application/xml;q=0.9" +# priorities = ["text/html; charset=UTF-8", "application/json", "application/xml;q=0.5"] +# +# accept = negotiator.best(accept_header, priorities).not_nil! +# +# accept.media_range # => "text/html" +# accept.parameters # => {"charset" => "UTF-8"} +# ``` +# +# The `ANG::Negotiator` type returns an `ANG::Accept`, or `nil` if negotiating the best media type has failed. +# +# ### Character Set +# +# ``` +# negotiator = ANG.charset_negotiator +# +# accept_header = "ISO-8859-1, UTF-8; q=0.9" +# priorities = ["iso-8859-1;q=0.3", "utf-8;q=0.9", "utf-16;q=1.0"] +# +# accept = negotiator.best(accept_header, priorities).not_nil! +# +# accept.charset # => "utf-8" +# accept.quality # => 0.9 +# ``` +# +# The `ANG::CharsetNegotiator` type returns an `ANG::AcceptCharset`, or `nil` if negotiating the best character set has failed. +# +# ### Encoding +# +# ``` +# negotiator = ANG.encoding_negotiator +# +# accept_header = "gzip;q=1.0, identity; q=0.5, *;q=0" +# priorities = ["gzip", "foo"] +# +# accept = negotiator.best(accept_header, priorities).not_nil! +# +# accept.coding # => "gzip" +# ``` +# +# The `ANG::EncodingNegotiator` type returns an `ANG::AcceptEncoding`, or `nil` if negotiating the best character set has failed. +# +# ### Language +# +# ``` +# negotiator = ANG.language_negotiator +# +# accept_header = "en; q=0.1, fr; q=0.4, zh-Hans-CN; q=0.9, de; q=0.2" +# priorities = ["de", "zh-Hans-CN", "en"] +# +# accept = negotiator.best(accept_header, priorities).not_nil! +# +# accept.language # => "zh" +# accept.region # => "cn" +# accept.script # => "hans" +# ``` +# +# The `ANG::LanguageNegotiator` type returns an `ANG::AcceptLanguage`, or `nil` if negotiating the best character set has failed. +module Athena::Negotiation + # Returns a lazily initialized `ANG::Negotiator` singleton instance. + class_getter(negotiator) { ANG::Negotiator.new } + + # Returns a lazily initialized `ANG::CharsetNegotiator` singleton instance. + class_getter(charset_negotiator) { ANG::CharsetNegotiator.new } + + # Returns a lazily initialized `ANG::EncodingNegotiator` singleton instance. + class_getter(encoding_negotiator) { ANG::EncodingNegotiator.new } + + # Returns a lazily initialized `ANG::LanguageNegotiator` singleton instance. + class_getter(language_negotiator) { ANG::LanguageNegotiator.new } + + # Contains all custom exceptions defined within `Athena::Negotiation`. + module Exceptions; end +end diff --git a/src/base_accept.cr b/src/base_accept.cr new file mode 100644 index 0000000..fc9507d --- /dev/null +++ b/src/base_accept.cr @@ -0,0 +1,50 @@ +# Base type for properties/logic all [Accept*](https://tools.ietf.org/html/rfc7231#section-5.3) headers share. +abstract struct Athena::Negotiation::BaseAccept + # Returns the full unaltered header `self` represents. + # E.x. `text/html` or `unicode-1-1;q=0.8` or `zh-Hans-CN`. + getter header : String + + # Returns a normalized version of the `#header`, excluding the `#quality` parameter. + # + # This includes removing extraneous whitespace, and alphabetizing the `#parameters`. + getter normalized_header : String + + # Returns any extension parameters included in the header `self` represents. + # E.x. `charset=UTF-8` or `version=2`. + getter parameters : Hash(String, String) = Hash(String, String).new + + # Returns the [quality value](https://tools.ietf.org/html/rfc7231#section-5.3.1) of the header `self` represents. + getter quality : Float32 = 1.0 + + # Represents the base header value, e.g. `#header` minus the `#quality` and `#parameters`. + # This is exposed as a getter on each subtype to have a more descriptive API. + protected getter accept_value : String + + def initialize(@header : String) + parts = @header.split ';' + @accept_value = parts.shift.strip.downcase + + parts.each do |part| + part = part.split '=' + + # Skip invalid parameters + next unless part.size == 2 + + @parameters[part[0].strip.downcase] = part[1].strip(" \"") + end + + if quality = @parameters.delete "q" + # RFC Only allows max of 3 decimal points. + @quality = quality.to_f32.round 3 + end + + @normalized_header = String.build do |io| + io << @accept_value + + unless @parameters.empty? + io << "; " + @parameters.keys.sort!.join(io, "; ") { |k, join_io| join_io << "#{k}=#{@parameters[k]}" } + end + end + end +end diff --git a/src/charset_negotiator.cr b/src/charset_negotiator.cr new file mode 100644 index 0000000..37aa4c5 --- /dev/null +++ b/src/charset_negotiator.cr @@ -0,0 +1,8 @@ +require "./abstract_negotiator" + +# A `ANG::AbstractNegotiator` implementation to negotiate `ANG::AcceptCharset` headers. +class Athena::Negotiation::CharsetNegotiator < Athena::Negotiation::AbstractNegotiator + private def create_header(header : String) : ANG::BaseAccept + ANG::AcceptCharset.new header + end +end diff --git a/src/encoding_negotiator.cr b/src/encoding_negotiator.cr new file mode 100644 index 0000000..40ec02f --- /dev/null +++ b/src/encoding_negotiator.cr @@ -0,0 +1,8 @@ +require "./abstract_negotiator" + +# A `ANG::AbstractNegotiator` implementation to negotiate `ANG::AcceptEncoding` headers. +class Athena::Negotiation::EncodingNegotiator < Athena::Negotiation::AbstractNegotiator + private def create_header(header : String) : ANG::BaseAccept + ANG::AcceptEncoding.new header + end +end diff --git a/src/exceptions/invalid_language.cr b/src/exceptions/invalid_language.cr new file mode 100644 index 0000000..a8335bd --- /dev/null +++ b/src/exceptions/invalid_language.cr @@ -0,0 +1,11 @@ +require "./negotiation_exception" + +# Represents an invalid `ANG::AcceptLanguage` header. +class Athena::Negotiation::Exceptions::InvalidLanguage < Athena::Negotiation::Exceptions::Negotiation + # Returns the invalid language code. + getter language : String + + def initialize(@language : String, cause : Exception? = nil) + super "Invalid language: '#{@language}'.", cause + end +end diff --git a/src/exceptions/invalid_media_type.cr b/src/exceptions/invalid_media_type.cr new file mode 100644 index 0000000..366b8fd --- /dev/null +++ b/src/exceptions/invalid_media_type.cr @@ -0,0 +1,11 @@ +require "./negotiation_exception" + +# Represents an invalid `ANG::Accept` header. +class Athena::Negotiation::Exceptions::InvalidMediaType < Athena::Negotiation::Exceptions::Negotiation + # Returns the invalid media range. + getter media_range : String + + def initialize(@media_range : String, cause : Exception? = nil) + super "Invalid media type: '#{@media_range}'.", cause + end +end diff --git a/src/exceptions/negotiation_exception.cr b/src/exceptions/negotiation_exception.cr new file mode 100644 index 0000000..8c242fa --- /dev/null +++ b/src/exceptions/negotiation_exception.cr @@ -0,0 +1,4 @@ +# Base type of all `Athena::Negotiation` errors. +# Can be used to rescue any exception originating from `Athena::Negotiation`. +abstract class Athena::Negotiation::Exceptions::Negotiation < ::Exception +end diff --git a/src/language_negotiator.cr b/src/language_negotiator.cr new file mode 100644 index 0000000..c881f6d --- /dev/null +++ b/src/language_negotiator.cr @@ -0,0 +1,27 @@ +require "./abstract_negotiator" + +# A `ANG::AbstractNegotiator` implementation to negotiate `ANG::AcceptLanguage` headers. +class Athena::Negotiation::LanguageNegotiator < Athena::Negotiation::AbstractNegotiator + protected def match(accept : ANG::AcceptLanguage, priority : ANG::AcceptLanguage, index : Int32) : ANG::AcceptMatch? + accept_base = accept.language + priority_base = priority.language + + accept_sub = accept.region + priority_sub = priority.region + + base_equal = accept_base.downcase == priority_base.downcase + sub_equal = accept_sub.try &.downcase == priority_sub.try &.downcase + + if ((accept_base == "*" || base_equal) && (accept_sub.nil? || sub_equal)) + score = 10 * (base_equal ? 1 : 0) + (sub_equal ? 1 : 0) + + return ANG::AcceptMatch.new accept.quality * priority.quality, score, index + end + + nil + end + + private def create_header(header : String) : ANG::BaseAccept + ANG::AcceptLanguage.new header + end +end diff --git a/src/negotiator.cr b/src/negotiator.cr new file mode 100644 index 0000000..c76f350 --- /dev/null +++ b/src/negotiator.cr @@ -0,0 +1,70 @@ +require "./abstract_negotiator" + +# A `ANG::AbstractNegotiator` implementation to negotiate `ANG::Accept` headers. +class Athena::Negotiation::Negotiator < Athena::Negotiation::AbstractNegotiator + # TODO: Make this method less complex. + # + # ameba:disable Metrics/CyclomaticComplexity + protected def match(accept : ANG::Accept, priority : ANG::Accept, index : Int32) : ANG::AcceptMatch? + accept_type = accept.type + priority_type = priority.type + + accept_sub_type = accept.sub_type + priority_sub_type = priority.sub_type + + 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 + + type_equals = accept_type.downcase == priority_type.downcase + sub_type_equals = accept_sub_type.downcase == priority_sub_type.downcase + + if ( + (accept_type == "*" || type_equals) && + (accept_sub_type == "*" || sub_type_equals) && + intercection.size == accept.parameters.size + ) + score = 100 * (type_equals ? 1 : 0) + 10 * (sub_type_equals ? 1 : 0) + intercection.size + + return ANG::AcceptMatch.new accept.quality * priority.quality, score, index + end + + return nil if !accept_sub_type.includes?('+') || !priority_sub_type.includes?('+') + + accept_sub_type, accept_plus = self.split_sub_type accept_sub_type + priority_sub_type, priority_plus = self.split_sub_type priority_sub_type + + if ( + !(accept_type == "*" || type_equals) || + !(accept_sub_type == "*" || priority_sub_type == "*" || accept_plus == "*" || priority_plus == "*") + ) + return nil + end + + sub_type_equals = accept_sub_type.downcase == priority_sub_type.downcase + plus_equals = accept_plus.downcase == priority_plus.downcase + + if ( + (accept_sub_type == "*" || priority_sub_type == "*" || sub_type_equals) && + (accept_plus == "*" || priority_plus == '*' || plus_equals) && + intercection.size == accept.parameters.size + ) + score = 100 * (type_equals ? 1 : 0) + 10 * (sub_type_equals ? 1 : 0) + (plus_equals ? 1 : 0) + intercection.size + return ANG::AcceptMatch.new accept.quality * priority.quality, score, index + end + + nil + end + + private def split_sub_type(sub_type : String) : Array(String) + return [sub_type, ""] unless sub_type.includes? '+' + + sub_type.split '+', limit: 2 + end + + private def create_header(header : String) : ANG::BaseAccept + ANG::Accept.new header + end +end