Initial Implementation (#1)

This commit is contained in:
George Dietrich 2020-12-24 00:48:48 -05:00 committed by GitHub
parent 34c8539ead
commit 83eda1298c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1082 additions and 54 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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 <CREATOR_EMAIL>
- George Dietrich <george@dietrich.app>
development_dependencies:
ameba:
github: crystal-ameba/ameba
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("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

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_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

View File

@ -1,7 +0,0 @@
require "./spec_helper"
describe Athena::NAMESPACE_NAME 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_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

View File

@ -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

View File

@ -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

View File

@ -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

129
spec/negotiator_spec.cr Normal file
View File

@ -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

View File

@ -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

View File

@ -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

101
src/abstract_negotiator.cr Normal file
View File

@ -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

45
src/accept.cr Normal file
View File

@ -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

21
src/accept_charset.cr Normal file
View File

@ -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

21
src/accept_encoding.cr Normal file
View File

@ -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

55
src/accept_language.cr Normal file
View File

@ -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

30
src/accept_match.cr Normal file
View File

@ -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

View File

@ -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

103
src/athena-negotiation.cr Normal file
View File

@ -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

50
src/base_accept.cr Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

70
src/negotiator.cr Normal file
View File

@ -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