Commits

Constantin Veretennicov  committed b33641a

Initial commit; v0.1.0.0.

  • Participants
  • Tags 0.1.0.0

Comments (0)

Files changed (14)

+syntax:glob
+bin
+test-results
+*.pidb
+*.userprefs
+*.swp

File CsConneg.sln

+
+Microsoft Visual Studio Solution File, Format Version 10.00
+# Visual Studio 2008
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsConneg", "src\CsConneg.csproj", "{B627A5DC-AC41-4C04-8986-A425CA452B48}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsConneg.Tests", "tests\CsConneg.Tests.csproj", "{27CED2AD-7738-40E2-9916-F366B0DBA1DF}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{27CED2AD-7738-40E2-9916-F366B0DBA1DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{27CED2AD-7738-40E2-9916-F366B0DBA1DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{27CED2AD-7738-40E2-9916-F366B0DBA1DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{27CED2AD-7738-40E2-9916-F366B0DBA1DF}.Release|Any CPU.Build.0 = Release|Any CPU
+		{B627A5DC-AC41-4C04-8986-A425CA452B48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{B627A5DC-AC41-4C04-8986-A425CA452B48}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{B627A5DC-AC41-4C04-8986-A425CA452B48}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{B627A5DC-AC41-4C04-8986-A425CA452B48}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(MonoDevelopProperties) = preSolution
+		StartupItem = tests\CsConneg.Tests.csproj
+	EndGlobalSection
+EndGlobal
+Copyright (c) 2012 Constantin Veretennicov <kveretennicov+csconneg@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+CsConneg aims to implement content negotiation as described in section 1.4 of
+RFC 2616 (http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html).
+
+Current version focuses on media type negotiation. Negotiation of other
+representation aspects (language, charset, encoding) is not covered.
+
+Example of usage:
+
+    void handleRequest(HttpContext http)
+    {
+        new CsConneg.Builder()
+            .MediaHandler(
+                "text/html", delegate
+                {
+                    // Serve HTML.
+                    http.Response.ContentType = "text/html";
+                    http.Response.Write("<html>...</html>");
+                }
+            .MediaHandler(
+                "application/json", delegate
+                {
+                    // Serve JSON.
+                    http.Response.ContentType = "application/json";
+                    http.Response.Write("{spam: 42}");
+                },
+            .NoMatchHandler(
+                delegate
+                {
+                    // Have no acceptable media types to serve.
+                    http.Response.Code = 406; // "Not Acceptable"
+                })
+            .Build()
+            .Dispatch(http.Request.Headers["Accept"] ?? "*/*");
+    }
+

File src/AssemblyInfo.cs

+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+// Information about this assembly is defined by the following attributes. 
+// Change them to the values specific to your project.
+
+[assembly: AssemblyTitle("CsConneg")]
+[assembly: AssemblyDescription("CsConneg")]
+#if DEBUG
+[assembly: AssemblyConfiguration("Debug")]
+#else
+[assembly: AssemblyConfiguration("Release")]
+#endif
+[assembly: AssemblyCopyright(
+@"Copyright (c) 2012 Constantin Veretennicov <kveretennicov+csconneg@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the ""Software""), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.")]
+[assembly: AssemblyCulture("")]
+
+// The assembly version has the format "{Major}.{Minor}.{Build}.{Revision}".
+// The form "{Major}.{Minor}.*" will automatically update the build and revision,
+// and "{Major}.{Minor}.{Build}.*" will update just the revision.
+
+[assembly: AssemblyVersion("0.1.0.0")]
+
+// The following attributes are used to specify the signing key for the assembly, 
+// if desired. See the Mono documentation for more information about signing.
+
+//[assembly: AssemblyDelaySign(false)]
+//[assembly: AssemblyKeyFile("")]

File src/CsConneg.AcceptHeader.cs

+namespace CsConneg
+{
+    using System;
+    using System.Linq;
+    using System.Collections.Generic;
+    using System.Text.RegularExpressions;
+
+    public static class AcceptHeader
+    {
+        public static MediaRange[] Parse(string acceptHeaderValue)
+        {
+            if (acceptHeaderValue == null)
+            {
+                throw new ArgumentNullException("acceptHeaderValue");
+            }
+
+            var formattedMediaRanges = Regex.Split(
+                acceptHeaderValue, @"\s*,\s*");
+            return formattedMediaRanges
+                .Select(s => MediaRange.Parse(s))
+                .ToArray();
+        }
+    }
+}

File src/CsConneg.MediaRange.cs

+namespace CsConneg
+{
+    using System;
+    using System.Linq;
+    using System.Collections.Generic;
+    using System.Text.RegularExpressions;
+    using System.Globalization;
+
+    public class MediaRange
+    {
+        public MediaRange(
+            string primaryType,
+            string subType,
+            KeyValuePair<string, string>[] @params)
+        {
+            if (primaryType == null)
+            {
+                throw new ArgumentNullException("primaryType");
+            }
+            if (subType == null)
+            {
+                throw new ArgumentNullException("subType");
+            }
+            if (primaryType == "*" && subType != "*")
+            {
+                throw new ArgumentException(
+                    "Subtype must be \"*\" if primary type is \"*\".",
+                    "subType");
+            }
+            if (@params == null)
+            {
+                throw new ArgumentNullException("params");
+            }
+
+            this.PrimaryType = primaryType;
+            this.SubType = subType;
+            this.@params = @params;
+        }
+
+        public MediaRange(
+            string primaryType,
+            string subType)
+            : this(primaryType, subType, new KeyValuePair<string, string>[0]) { }
+
+        public static MediaRange Parse(string formattedMediaRange)
+        {
+            var parts = formattedMediaRange.Split(new[] {'/'}, 2);
+            var primaryType = parts[0];
+            var formattedSubTypeWithParams = parts[1];
+            var subTypeWithParams = Regex.Split(
+                formattedSubTypeWithParams, @"\s*;\s*");
+            var subType = subTypeWithParams[0];
+            if (subTypeWithParams.Length > 1)
+            {
+                var @params = new List<KeyValuePair<string, string>>();
+                foreach (var formattedParam in subTypeWithParams.Skip(1))
+                {
+                    var paramParts = formattedParam.Split(new[] { '=' }, 2);
+                    var paramName = paramParts[0];
+                    var paramValue = paramParts[1];
+                    @params.Add(
+                        new KeyValuePair<string, string>(paramName, paramValue));
+                }
+                return new MediaRange(primaryType, subType, @params.ToArray());
+            }
+            return new MediaRange(primaryType, subType);
+        }
+
+        public string PrimaryType { get; private set; }
+        public string SubType { get; private set; }
+        public IEnumerable<KeyValuePair<string, string>> Params { get { return this.@params; }}
+
+        public MediaRange WithoutParams(params string[] paramNamesToExclude)
+        {
+            var filteredParams = this.@params
+                .Where(p => !paramNamesToExclude.Contains(
+                    p.Key, StringComparer.InvariantCultureIgnoreCase))
+                .ToArray();
+            return new MediaRange(
+                this.PrimaryType, this.SubType, filteredParams);
+        }
+
+        public decimal? QualityParamValue
+        {
+            get
+            {
+                var qParam = this.@params
+                    .Where(p => 0 == string.Compare(p.Key, "q", true))
+                    .FirstOrDefault();
+                if (qParam.Key != null)
+                {
+                    return decimal.Parse(
+                        qParam.Value, CultureInfo.InvariantCulture);
+                }
+                return null;
+            }
+        }
+
+        public decimal Match(MediaRange other, bool ignoreQualityParameter)
+        {
+            if (other == null)
+            {
+                throw new ArgumentNullException("other");
+            }
+
+            var accumulatedScore = 0M;
+
+            // Match primary types.
+
+            if (this.PrimaryType == "*")
+            {
+                accumulatedScore += 50;
+            }
+            else if (0 == string.Compare(this.PrimaryType, other.PrimaryType, true))
+            {
+                accumulatedScore += 100;
+            }
+            else
+            {
+                return 0;
+            }
+
+            // Match subtypes.
+
+            if (this.SubType == "*")
+            {
+                accumulatedScore += 5;
+            }
+            else if (0 == string.Compare(this.SubType, other.SubType, true))
+            {
+                accumulatedScore += 10;
+            }
+            else
+            {
+                return 0;
+            }
+
+            // Match parameters.
+
+            IEnumerable<KeyValuePair<string, string>>
+                lhsParams = this.@params,
+                rhsParams = other.Params;
+            if (ignoreQualityParameter)
+            {
+                lhsParams = lhsParams.Where(p => 0 != string.Compare(p.Key, "q", true));
+                rhsParams = rhsParams.Where(p => 0 != string.Compare(p.Key, "q", true));
+            }
+            if (!lhsParams.Any())
+            {
+                // Absence of parameters on lhs media range is interpreted as
+                // "params wildcard", meaning any parameters on rhs will match.
+                accumulatedScore += 0.5M;
+                if (!rhsParams.Any())
+                {
+                    // Still some extra score is assigned to the match when
+                    // both lhs and rhs have no parameters at all.
+                    accumulatedScore += 0.01M;
+                }
+            }
+            else if (paramsAreEqual(lhsParams, rhsParams))
+            {
+                accumulatedScore += 1;
+            }
+            else
+            {
+                return 0;
+            }
+
+            return accumulatedScore;
+        }
+
+        public decimal Match(MediaRange other)
+        {
+            return this.Match(other, true);
+        }
+
+        static bool paramsAreEqual(
+            IEnumerable<KeyValuePair<string, string>> lhs,
+            IEnumerable<KeyValuePair<string, string>> rhs)
+        {
+            // Compare parameters. This is tricky, because parameter values
+            // MAY be case-sensitive, depending on their definition. Here we
+            // use case-insensitive comparison for values.
+
+            // Parameter order is not important for comparison.
+            var lhsParams = lhs
+                .OrderBy(p => p.Key)
+                .ThenBy(p => p.Value)
+                .ToArray();
+            var rhsParams = rhs
+                .OrderBy(p => p.Key)
+                .ThenBy(p => p.Value)
+                .ToArray();
+            if (lhsParams.Length != rhsParams.Length)
+            {
+                return false;
+            }
+            return Enumerable
+                .Range(0, lhsParams.Length)
+                .All(i =>
+                    0 == string.Compare(lhsParams[i].Key, rhsParams[i].Key, true)
+                    && 0 == string.Compare(lhsParams[i].Value, rhsParams[i].Value, true));
+        }
+
+        public bool Equals(MediaRange other)
+        {
+            if (object.ReferenceEquals(other, null))
+            {
+                return false;
+            }
+            var typeAreEqual = 0 == string.Compare(other.PrimaryType, this.PrimaryType, true)
+                && 0 == string.Compare(other.SubType, this.SubType, true);
+            if (!typeAreEqual)
+            {
+                return false;
+            }
+            return paramsAreEqual(this.@params, other.Params);
+        }
+
+        public override bool Equals(object comparand)
+        {
+            if (object.ReferenceEquals(comparand, null))
+            {
+                return false;
+            }
+            if (object.ReferenceEquals(this, comparand))
+            {
+                return true;
+            }
+            var other = comparand as MediaRange;
+            return this.Equals(other);
+        }
+
+        public override int GetHashCode()
+        {
+            // NOTE:
+            // Parameters are excluded from hash calculation. They are not
+            // expected to be commonly used and so hash collisions because of
+            // parameters are expected to be rare.
+            return
+                this.PrimaryType.ToLowerInvariant().GetHashCode() * 397
+                ^ this.SubType.ToLowerInvariant().GetHashCode();
+        }
+
+        public static bool operator ==(MediaRange lhs, MediaRange rhs)
+        {
+            if (object.ReferenceEquals(lhs, null))
+            {
+                if (object.ReferenceEquals(rhs, null))
+                {
+                    return true;
+                }
+                return rhs.Equals(lhs);
+            }
+            return lhs.Equals(rhs);
+        }
+
+        public static bool operator !=(MediaRange lhs, MediaRange rhs)
+        {
+            return !(lhs == rhs);
+        }
+
+        public override string ToString()
+        {
+            var formattedParams = this.@params
+                .Select(p => string.Format(";{0}={1}", p.Key, p.Value))
+                .ToArray();
+            return string.Format(
+                "{0}/{1}{2}",
+                this.PrimaryType, this.SubType, string.Join("", formattedParams));
+        }
+
+        readonly KeyValuePair<string, string>[] @params;
+    }
+}

File src/CsConneg.cs

+namespace CsConneg
+{
+    using System;
+    using System.Linq;
+    using System.Collections.Generic;
+    using System.Text.RegularExpressions;
+    using System.Globalization;
+
+    public delegate void MediaHandlerDelegate(MediaRange matchedMediaRange);
+    public delegate void NoMatchHandlerDelegate(
+        KeyValuePair<MediaRange, MediaHandlerDelegate>[] viableFallbacks);
+
+    public interface IOpenBuilder
+    {
+        IOpenBuilder MediaHandler(string mediaType, MediaHandlerDelegate handler);
+        IOpenBuilder MediaHandler(string[] mediaType, MediaHandlerDelegate handler);
+        IClosedBuilder NoMatchHandler(NoMatchHandlerDelegate noMatchHandler);
+        Dispatcher Build();
+    }
+
+    public interface IClosedBuilder
+    {
+        Dispatcher Build();
+    }
+
+    public class Builder : IOpenBuilder, IClosedBuilder
+    {
+        public Builder()
+        {
+            this.noMatchHandler = delegate {};
+        }
+
+        public IOpenBuilder MediaHandler(string mediaType, MediaHandlerDelegate handler)
+        {
+            var parsedMediaType = MediaRange.Parse(mediaType);
+            if (parsedMediaType.SubType == "*"
+                || parsedMediaType.PrimaryType == "*")
+            {
+                throw new ArgumentException("Media type must be exact.", "mediaType");
+            }
+
+            this.config.Add(
+                new KeyValuePair<MediaRange, MediaHandlerDelegate>(
+                    parsedMediaType, handler ?? delegate {}));
+            return this;
+        }
+
+        public IOpenBuilder MediaHandler(string[] mediaTypes, MediaHandlerDelegate handler)
+        {
+            foreach (var mediaType in mediaTypes)
+            {
+                this.MediaHandler(mediaType, handler);
+            }
+            return this;
+        }
+
+        public IClosedBuilder NoMatchHandler(NoMatchHandlerDelegate noMatchHandler)
+        {
+            this.noMatchHandler = noMatchHandler;
+            return this;
+        }
+
+        public Dispatcher Build()
+        {
+            return new Dispatcher(this.config, this.noMatchHandler);
+        }
+
+        readonly List<KeyValuePair<MediaRange, MediaHandlerDelegate>>
+            config = new List<KeyValuePair<MediaRange, MediaHandlerDelegate>>();
+        NoMatchHandlerDelegate noMatchHandler;
+    }
+
+    public class Dispatcher
+    {
+        internal Dispatcher(
+            IEnumerable<KeyValuePair<MediaRange, MediaHandlerDelegate>> config,
+            NoMatchHandlerDelegate noMatchHandler)
+        {
+            this.config = config.ToArray();
+            this.noMatchHandler = noMatchHandler;
+        }
+
+        public void Dispatch(string acceptHeaderValue)
+        {
+            // Parse media type preferences from client request.
+            var specifiedMediaRanges = AcceptHeader.Parse(acceptHeaderValue);
+            // Prepare available media types for scoring based on client's
+            // media type preferences.
+            var scoredConfig = this.config
+                .Select(
+                    mth => new
+                    {
+                        MediaType = mth.Key,
+                        Handler = mth.Value,
+                        MostSpecificMediaRangeMatchIndex = new int?[1],
+                            // Wrapping index in array to work around
+                            // read-onlyness limitation of anonymous types.
+                    })
+                .ToArray();
+            foreach (var scoredEntry in scoredConfig)
+            {
+                // Find the most specific media range that matches.
+                var mostSpecificMatchScore = 0M;
+                    // Match score of media range that most specifically
+                    // matches this available media type.
+                for (var i = 0; i < specifiedMediaRanges.Length; ++i)
+                {
+                    var mediaRange = specifiedMediaRanges[i];
+                    var matchScore = mediaRange.Match(scoredEntry.MediaType);
+                    if (matchScore > mostSpecificMatchScore)
+                    {
+                        scoredEntry.MostSpecificMediaRangeMatchIndex[0] = i;
+                        mostSpecificMatchScore = matchScore;
+                    }
+                }
+            }
+            // Use a matching handler with the best positive quality,
+            // specificity of match and original order.
+            var rankedMatches = scoredConfig
+                .Where(
+                    se => se.MostSpecificMediaRangeMatchIndex[0].HasValue)
+                .Select(
+                    (se, i) => new
+                    {
+                        OriginalOrder = i,
+                        MatchedMediaRange = specifiedMediaRanges
+                            [se.MostSpecificMediaRangeMatchIndex[0].Value],
+                        Handler = se.Handler,
+                        MediaType = se.MediaType,
+                    })
+                .Where(
+                    e => e.MatchedMediaRange.QualityParamValue != 0)
+                .OrderByDescending(
+                    e => e.MatchedMediaRange.QualityParamValue ?? 1)
+                .ThenByDescending(
+                    e => e.MatchedMediaRange.Match(e.MediaType))
+                .ThenBy(e => e.OriginalOrder);
+            var bestMatch = rankedMatches.FirstOrDefault();
+            if (bestMatch != null)
+            {
+                bestMatch.Handler(bestMatch.MatchedMediaRange);
+            }
+            else
+            {
+                // Could not find any media handlers matching request;
+                // build list of viable fallbacks and pass to no-match handler.
+                var unacceptableMediaRanges = specifiedMediaRanges
+                    .Where(mr => mr.QualityParamValue == 0);
+                var viableFallbacks = this.config
+                    .Where(mrh => !unacceptableMediaRanges.Any(
+                        umr => umr.Match(mrh.Key) != 0))
+                    .ToArray();
+                this.noMatchHandler(viableFallbacks);
+            }
+        }
+
+        readonly KeyValuePair<MediaRange, MediaHandlerDelegate>[] config;
+            // List of (media-type, handler), in order of server preference.
+        readonly NoMatchHandlerDelegate noMatchHandler;
+            // Fallback handler when no match can be found.
+    }
+}

File src/CsConneg.csproj

+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="3.5">
+  <PropertyGroup>
+    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
+    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
+    <ProductVersion>9.0.21022</ProductVersion>
+    <SchemaVersion>2.0</SchemaVersion>
+    <ProjectGuid>{B627A5DC-AC41-4C04-8986-A425CA452B48}</ProjectGuid>
+    <OutputType>Library</OutputType>
+    <RootNamespace>CsConneg</RootNamespace>
+    <AssemblyName>CsConneg</AssemblyName>
+    <TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
+  </PropertyGroup>
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
+    <DebugSymbols>true</DebugSymbols>
+    <DebugType>full</DebugType>
+    <Optimize>false</Optimize>
+    <OutputPath>bin\Debug</OutputPath>
+    <DefineConstants>DEBUG</DefineConstants>
+    <ErrorReport>prompt</ErrorReport>
+    <WarningLevel>4</WarningLevel>
+    <ConsolePause>false</ConsolePause>
+  </PropertyGroup>
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
+    <DebugType>none</DebugType>
+    <Optimize>false</Optimize>
+    <OutputPath>bin\Release</OutputPath>
+    <ErrorReport>prompt</ErrorReport>
+    <WarningLevel>4</WarningLevel>
+    <ConsolePause>false</ConsolePause>
+  </PropertyGroup>
+  <ItemGroup>
+    <Reference Include="System" />
+    <Reference Include="System.Core" />
+  </ItemGroup>
+  <ItemGroup>
+    <Compile Include="AssemblyInfo.cs" />
+    <Compile Include="CsConneg.cs" />
+    <Compile Include="CsConneg.MediaRange.cs" />
+    <Compile Include="CsConneg.AcceptHeader.cs" />
+  </ItemGroup>
+  <Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
+</Project>

File tests/CsConneg.BuilderTests.cs

+namespace CsConneg
+{
+    using System;
+    using System.Linq;
+    using System.IO;
+    using System.Collections.Generic;
+    using System.Text;
+    //
+    using NUnit.Framework;
+    using NUnit.Framework.SyntaxHelpers;
+
+    [TestFixture] public class BuilderTests
+    {
+        [Test, ExpectedException(
+            typeof(ArgumentException),
+            ExpectedMessage="mediaType",
+            MatchType=MessageMatch.Contains)]
+        public void Builder_throws_on_non_exact_media_type()
+        {
+            new CsConneg.Builder()
+                .MediaHandler(
+                    "application/*", delegate {})
+                .Build();
+        }
+    }
+}

File tests/CsConneg.DispatcherTests.cs

+namespace CsConneg
+{
+    using System;
+    using System.Linq;
+    using System.IO;
+    using System.Collections.Generic;
+    using System.Text;
+    //
+    using NUnit.Framework;
+    using NUnit.Framework.SyntaxHelpers;
+
+    [TestFixture] public class DispatcherTests
+    {
+        [Test] public void Match_single_handler()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var dispatcher = new CsConneg.Builder()
+                .MediaHandler(
+                    "application/xml", (mr) =>
+                    {
+                        matchedMediaRange = mr;
+                    })
+                .Build();
+
+            dispatcher.Dispatch("application/xml");
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(new MediaRange("application", "xml")));
+        }
+
+        [Test] public void No_handler()
+        {
+            var dispatcher = new CsConneg.Builder().Build();
+            dispatcher.Dispatch("application/xml");
+        }
+
+        [Test] public void Match_no_handler_due_to_absence_of_media_handlers()
+        {
+            var noMatch = false;
+            new CsConneg.Builder()
+                .NoMatchHandler(
+                    (viableFallbacks) =>
+                    {
+                        noMatch = true;
+                        Assert.That(viableFallbacks, Is.Empty);
+                    })
+                .Build()
+                .Dispatch("application/xml");
+            Assert.IsTrue(noMatch);
+        }
+
+        [Test] public void Match_one_of_handlers_by_exact_match_given_single_choice()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "application/atom+xml;type=feed", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml;type=feed");
+                        })
+                    .MediaHandler(
+                        "application/atom+xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml");
+                        })
+                    .MediaHandler(
+                        "application/xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/xml");
+                        })
+                    .Build()
+                    .Dispatch("application/atom+xml");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(new MediaRange("application", "atom+xml")));
+            Assert.That(
+                sb.ToString().TrimEnd(),
+                Is.EqualTo("application/atom+xml"));
+        }
+
+        [Test] public void Matсh_one_of_handlers_by_exact_match_given_multiple_choices()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "application/atom+xml;type=feed", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml;type=feed");
+                        })
+                    .MediaHandler(
+                        "application/atom+xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml");
+                        })
+                    .MediaHandler(
+                        "application/xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/xml");
+                        })
+                    .Build()
+                    .Dispatch("application/atom+xml, application/xml");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(new MediaRange("application", "atom+xml")));
+            Assert.That(
+                sb.ToString().TrimEnd(),
+                Is.EqualTo("application/atom+xml"));
+        }
+
+        [Test] public void Match_one_of_handlers_by_subtype_wildcard_match_given_multiple_choices()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "application/atom+xml;type=feed", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml;type=feed");
+                        })
+                    .MediaHandler(
+                        "application/atom+xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml");
+                        })
+                    .MediaHandler(
+                        "application/xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/xml");
+                        })
+                    .MediaHandler(
+                        "text/html", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("text/html");
+                        })
+                    .Build()
+                    .Dispatch("application/octet-stream, text/*, image/png");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(new MediaRange("text", "*")));
+            Assert.That(
+                sb.ToString().TrimEnd(),
+                Is.EqualTo("text/html"));
+        }
+
+        [Test] public void Match_one_of_handlers_by_subtype_wildcard_match_given_multiple_choices_and_quality_preferences()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "application/atom+xml;type=feed", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml;type=feed");
+                        })
+                    .MediaHandler(
+                        "application/atom+xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/atom+xml");
+                        })
+                    .MediaHandler(
+                        "application/xml", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/xml");
+                        })
+                    .MediaHandler(
+                        "text/html", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("text/html");
+                        })
+                    .Build()
+                    .Dispatch("application/atom+xml;Q=0.5, text/*;q=0.9, image/png");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(
+                    new MediaRange(
+                        "text", "*",
+                        new[] { new KeyValuePair<string, string>("q", "0.9"), })));
+            Assert.That(
+                sb.ToString().TrimEnd(),
+                Is.EqualTo("text/html"));
+        }
+
+        [Test] public void Match_one_of_handlers_by_subtype_wildcard_match_given_multiple_choices_and_quality_preferences_2()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "application/vnd.spam", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/vnd.spam");
+                        })
+                    .MediaHandler(
+                        "application/vnd.eggs", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("application/vnd.eggs");
+                        })
+                    .MediaHandler(
+                        "text/html", (MediaRange mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("text/html");
+                        })
+                    .Build()
+                    .Dispatch("application/vnd.spam;q=0.1, application/vnd.eggs;q=0.2");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(
+                    new MediaRange(
+                        "application", "vnd.eggs",
+                        new[] { new KeyValuePair<string, string>("q", "0.2"), })));
+            Assert.That(
+                sb.ToString().TrimEnd(),
+                Is.EqualTo("application/vnd.eggs"));
+        }
+
+        [Test] public void Match_one_of_handlers_by_media_type_specificity()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "text/html", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("text/html");
+                        })
+                    .MediaHandler(
+                        "text/html;level=1", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("text/html;level=1");
+                        })
+                    .Build()
+                    .Dispatch(
+                        "text/*;q=0.3, text/html;q=0.7, text/html;level=1,\r\n" +
+                        "text/html;level=2;q=0.4, */*;q=0.5");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(
+                    new MediaRange(
+                        "text", "html", new[]
+                        {
+                            new KeyValuePair<string, string>("level", "1"),
+                        })));
+            Assert.That(
+                sb.ToString().TrimEnd(),
+                Is.EqualTo("text/html;level=1"));
+        }
+
+        [Test] public void Match_parameterized_media_type_to_parameterless_handler()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "text/html;level=3", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("text/html;level=3");
+                        })
+                    .Build()
+                    .Dispatch(
+                        "text/*;q=0.3, text/html;q=0.7, text/html;level=1,\r\n" +
+                        "text/html;level=2;q=0.4, */*;q=0.5");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(
+                    new MediaRange(
+                        "text", "html", new[]
+                        {
+                            new KeyValuePair<string, string>("q", "0.7"),
+                        })));
+            Assert.That(
+                sb.ToString().TrimEnd(),
+                Is.EqualTo("text/html;level=3"));
+        }
+
+        [Test] public void Matсh_no_handler_due_to_zero_quality_hint()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "text/html", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("text/html");
+                        })
+                    .NoMatchHandler(
+                        (viableFallbacks) =>
+                        {
+                            sw.WriteLine("no match");
+                            Assert.That(viableFallbacks, Is.Empty);
+                        })
+                    .Build()
+                    .Dispatch("text/*;q=0, image/png");
+            }
+
+            Assert.That(matchedMediaRange, Is.Null);
+            Assert.That(sb.ToString().TrimEnd(), Is.EqualTo("no match"));
+        }
+
+        [Test] public void No_matсh_handler_receives_list_of_viable_fallbacks()
+        {
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "text/html", delegate
+                        {
+                            Assert.Fail("text/html handler should not be called");
+                        })
+                    .MediaHandler(
+                        "image/jpg", delegate
+                        {
+                            Assert.Fail("image/jpg handler should not be called");
+                        })
+                    .NoMatchHandler(
+                        (viableFallbacks) =>
+                        {
+                            sw.WriteLine("no match");
+                            Assert.That(viableFallbacks, Is.Not.Empty);
+                            Assert.That(
+                                viableFallbacks[0].Key,
+                                Is.EqualTo(new MediaRange("text", "html")));
+                            Assert.That(viableFallbacks.Length, Is.GreaterThan(1));
+                            Assert.That(
+                                viableFallbacks[1].Key,
+                                Is.EqualTo(new MediaRange("image", "jpg")));
+                            Assert.That(viableFallbacks.Length, Is.EqualTo(2));
+                        })
+                    .Build()
+                    .Dispatch("image/png");
+            }
+
+            Assert.That(sb.ToString().TrimEnd(), Is.EqualTo("no match"));
+        }
+
+        [Test] public void No_matсh_handler_can_call_viable_fallback()
+        {
+            MediaRange matchedMediaRange = null;
+
+            var sb = new StringBuilder();
+            using (var sw = new StringWriter(sb))
+            {
+                new CsConneg.Builder()
+                    .MediaHandler(
+                        "image/jpg", (mr) =>
+                        {
+                            matchedMediaRange = mr;
+                            sw.WriteLine("image/jpg");
+                        })
+                    .NoMatchHandler(
+                        (viableFallbacks) =>
+                        {
+                            (viableFallbacks[0].Value)(viableFallbacks[0].Key);
+                        })
+                    .Build()
+                    .Dispatch("image/png");
+            }
+
+            Assert.That(
+                matchedMediaRange,
+                Is.EqualTo(new MediaRange("image", "jpg")));
+            Assert.That(sb.ToString().TrimEnd(), Is.EqualTo("image/jpg"));
+        }
+    }
+}

File tests/CsConneg.MediaRangeTests.cs

+namespace CsConneg
+{
+    using System;
+    using System.Linq;
+    using System.Collections.Generic;
+    //
+    using NUnit.Framework;
+    using NUnit.Framework.SyntaxHelpers;
+
+    [TestFixture] public class MediaRangeTests
+    {
+        [Test, ExpectedException(typeof(ArgumentNullException))]
+        public void Media_range_throws_on_null_primary_type()
+        {
+            new MediaRange(null, "xml", new KeyValuePair<string, string>[0]);
+        }
+
+        [Test, ExpectedException(typeof(ArgumentNullException))]
+        public void Media_range_throws_on_null_subtype()
+        {
+            new MediaRange("application", null, new KeyValuePair<string, string>[0]);
+        }
+
+        [Test, ExpectedException(typeof(ArgumentNullException))]
+        public void Media_range_throws_on_null_param_array()
+        {
+            new MediaRange("application", "xml", null);
+        }
+
+        [Test, ExpectedException(typeof(ArgumentException))]
+        public void Media_range_throws_on_wildcard_primary_type_and_specific_subtype()
+        {
+            new MediaRange("*", "xml");
+        }
+
+        [Test] public void Media_range_parses_correctly()
+        {
+            var cases = new[]
+            {
+                new
+                {
+                    Input = "application/atomsvc+xml",
+                    Expected = new MediaRange("application", "atomsvc+xml"),
+                },
+                new
+                {
+                    Input = "application/atom+xml;type=entry",
+                    Expected = new MediaRange(
+                        "application", "atom+xml",
+                        new[] { new KeyValuePair<string, string>("type", "entry"), }),
+                },
+                new
+                {
+                    Input = "model/vnd.spam;q=0.12;level=spammy;x=\"bacon!\"",
+                    Expected = new MediaRange(
+                        "model", "vnd.spam",
+                        new[]
+                        {
+                            new KeyValuePair<string, string>("q", "0.12"),
+                            new KeyValuePair<string, string>("level", "spammy"),
+                            new KeyValuePair<string, string>("x", "\"bacon!\""),
+                        }),
+                },
+                new
+                {
+                    Input = "text/*;charset=latin-1",
+                    Expected = new MediaRange(
+                        "text", "*",
+                        new[] { new KeyValuePair<string, string>("charset", "latin-1"), }),
+                },
+                new
+                {
+                    Input = "*/*;q=0",
+                    Expected = new MediaRange(
+                        "*", "*",
+                        new[] { new KeyValuePair<string, string>("q", "0"), }),
+                },
+            };
+
+            foreach (var @case in cases)
+            {
+                var parsed = MediaRange.Parse(@case.Input);
+                Assert.That(parsed, Is.EqualTo(@case.Expected));
+            }
+        }
+
+        [Test] public void Media_range_formats_correctly()
+        {
+            var cases = new[]
+            {
+                new
+                {
+                    Input = new MediaRange("application", "atomsvc+xml"),
+                    Expected = "application/atomsvc+xml",
+                },
+                new
+                {
+                    Input = new MediaRange(
+                        "application", "atom+xml",
+                        new[] { new KeyValuePair<string, string>("type", "entry"), }),
+                    Expected = "application/atom+xml;type=entry",
+                },
+                new
+                {
+                    Input = new MediaRange(
+                        "model", "vnd.spam",
+                        new[]
+                        {
+                            new KeyValuePair<string, string>("q", "0.12"),
+                            new KeyValuePair<string, string>("level", "spammy"),
+                            new KeyValuePair<string, string>("x", "\"bacon!\""),
+                        }),
+                    Expected = "model/vnd.spam;q=0.12;level=spammy;x=\"bacon!\"",
+                },
+                new
+                {
+                    Input = new MediaRange(
+                        "text", "*",
+                        new[] { new KeyValuePair<string, string>("charset", "latin-1"), }),
+                    Expected = "text/*;charset=latin-1",
+                },
+                new
+                {
+                    Input = new MediaRange(
+                        "*", "*",
+                        new[] { new KeyValuePair<string, string>("q", "0"), }),
+                    Expected = "*/*;q=0",
+                },
+            };
+
+            foreach (var @case in cases)
+            {
+                Assert.That(@case.Input.ToString(), Is.EqualTo(@case.Expected));
+            }
+        }
+
+        [Test] public void Media_range_compares_correctly_for_equality()
+        {
+            var a1 = new MediaRange("application", "xml");
+            var A1 = new MediaRange("Application", "Xml");
+            var a2 = new MediaRange("application", "xml");
+            var b1 = new MediaRange(
+                "application", "atom+xml",
+                new[]
+                {
+                    new KeyValuePair<string, string>("type", "feed"),
+                    new KeyValuePair<string, string>("q", "0.8"),
+                });
+            var b2 = new MediaRange(
+                "application", "atom+xml",
+                new[]
+                {
+                    new KeyValuePair<string, string>("type", "feed"),
+                    new KeyValuePair<string, string>("q", "0.8"),
+                });
+            var c = new MediaRange(
+                "application", "atom+xml",
+                new[]
+                {
+                    new KeyValuePair<string, string>("type", "feed"),
+                    new KeyValuePair<string, string>("q", "0.9"),
+                });
+            var d = new MediaRange(
+                "application", "atom+xml",
+                new[]
+                {
+                    new KeyValuePair<string, string>("type", "feed"),
+                });
+
+            Assert.That(a1, Is.EqualTo(a2));
+            Assert.That(a1, Is.EqualTo(A1));
+            Assert.That(b1, Is.EqualTo(b2));
+
+            Assert.That(a1, Is.Not.EqualTo(b1));
+            Assert.That(b1, Is.Not.EqualTo(c));
+            Assert.That(b1, Is.Not.EqualTo(d));
+            Assert.That(a1, Is.Not.EqualTo(null));
+
+            Assert.That(a1 == a1);
+            Assert.That(a1 == A1);
+            Assert.That(a1 == a2);
+            Assert.That(a1 != b1);
+            Assert.That(a1 != null);
+            Assert.That(null != a1);
+        }
+
+        [Test] public void Media_range_matching_ignores_q_parameter_by_default()
+        {
+            var textHtmlLevel1Q05 = new MediaRange(
+                "text", "html", new[]
+                {
+                    new KeyValuePair<string, string>("level", "1"),
+                    new KeyValuePair<string, string>("q", "0.5"),
+                });
+            var textHtmlLevel1Q02 = new MediaRange(
+                "text", "html", new[]
+                {
+                    new KeyValuePair<string, string>("level", "1"),
+                    new KeyValuePair<string, string>("q", "0.2"),
+                });
+
+            Assert.That(textHtmlLevel1Q05.Match(textHtmlLevel1Q02, false), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1Q05.Match(textHtmlLevel1Q02, true), Is.GreaterThan(0));
+            Assert.That(textHtmlLevel1Q05.Match(textHtmlLevel1Q02), Is.GreaterThan(0));
+        }
+
+        [Test] public void Media_range_matching_ignores_character_case_of_parameter_names()
+        {
+            var textHtmlCharsetUtf8 = new MediaRange(
+                "text", "html", new[]
+                {
+                    new KeyValuePair<string, string>("charset", "utf-8"),
+                    new KeyValuePair<string, string>("level", "1"),
+                    new KeyValuePair<string, string>("q", "0.5"),
+                });
+            var textHtmlCHARSETUtf8 = new MediaRange(
+                "text", "html", new[]
+                {
+                    new KeyValuePair<string, string>("q", "0.2"),
+                    new KeyValuePair<string, string>("level", "1"),
+                    new KeyValuePair<string, string>("CHARSET", "utf-8"),
+                });
+
+            Assert.That(textHtmlCHARSETUtf8.Match(textHtmlCharsetUtf8), Is.GreaterThan(0));
+            Assert.That(textHtmlCharsetUtf8.Match(textHtmlCHARSETUtf8), Is.GreaterThan(0));
+        }
+
+        [Test] public void Media_range_matching_ignores_character_case_of_parameter_values()
+        {
+            var textHtmlCharsetUTF8 = new MediaRange(
+                "text", "html", new[]
+                {
+                    new KeyValuePair<string, string>("charset", "UTF-8"),
+                    new KeyValuePair<string, string>("q", "0.5"),
+                });
+            var textHtmlCharsetUtf8 = new MediaRange(
+                "text", "html", new[]
+                {
+                    new KeyValuePair<string, string>("q", "0.2"),
+                    new KeyValuePair<string, string>("charset", "utf-8"),
+                });
+
+            Assert.That(textHtmlCharsetUtf8.Match(textHtmlCharsetUTF8), Is.GreaterThan(0));
+            Assert.That(textHtmlCharsetUTF8.Match(textHtmlCharsetUtf8), Is.GreaterThan(0));
+        }
+
+        [Test] public void Media_range_matching_ignores_character_case_of_media_types()
+        {
+            var TEXTHTML = new MediaRange("TEXT", "HTML");
+            var textHtml = new MediaRange("text", "html");
+
+            Assert.That(textHtml.Match(TEXTHTML), Is.GreaterThan(0));
+            Assert.That(TEXTHTML.Match(textHtml), Is.GreaterThan(0));
+        }
+
+        [Test, ExpectedException(typeof(ArgumentNullException))]
+        public void Media_range_matching_throws_on_null()
+        {
+            var any = new MediaRange("*", "*");
+            any.Match(null);
+        }
+
+        [Test] public void Media_range_computes_match_score_correctly()
+        {
+            var any = new MediaRange("*", "*");
+            var textAny = new MediaRange("text", "*");
+            var textPlain = new MediaRange("text", "plain");
+            var textHtml = new MediaRange("text", "html");
+            var textHtmlLevel1 = new MediaRange(
+                "text", "html",
+                new[] { new KeyValuePair<string, string>("level", "1"), });
+            var textHtmlLevel1SpamEggs = new MediaRange(
+                "text", "html",
+                new[]
+                {
+                    new KeyValuePair<string, string>("level", "1"),
+                    new KeyValuePair<string, string>("spam", "eggs"),
+                });
+            var textHtmlLevel1SpamBacon = new MediaRange(
+                "text", "html",
+                new[]
+                {
+                    new KeyValuePair<string, string>("spam", "bacon"),
+                    new KeyValuePair<string, string>("level", "1"),
+                });
+
+            Assert.That(any.Match(any), Is.EqualTo(50 + 5 + 0.5 + 0.01));
+            Assert.That(any.Match(textAny), Is.EqualTo(50 + 5 + 0.5 + 0.01));
+            Assert.That(any.Match(textPlain), Is.EqualTo(50 + 5 + 0.5 + 0.01));
+            Assert.That(any.Match(textHtml), Is.EqualTo(50 + 5 + 0.5 + 0.01));
+            Assert.That(any.Match(textHtmlLevel1), Is.EqualTo(50 + 5 + 0.5));
+            Assert.That(any.Match(textHtmlLevel1SpamBacon), Is.EqualTo(50 + 5 + 0.5));
+            Assert.That(any.Match(textHtmlLevel1SpamEggs), Is.EqualTo(50 + 5 + 0.5));
+
+            Assert.That(textAny.Match(any), Is.EqualTo(0));
+            Assert.That(textAny.Match(textAny), Is.EqualTo(100 + 5 + 0.5 + 0.01));
+            Assert.That(textAny.Match(textPlain), Is.EqualTo(100 + 5 + 0.5 + 0.01));
+            Assert.That(textAny.Match(textHtml), Is.EqualTo(100 + 5 + 0.5 + 0.01));
+            Assert.That(textAny.Match(textHtmlLevel1), Is.EqualTo(100 + 5 + 0.5));
+            Assert.That(textAny.Match(textHtmlLevel1SpamBacon), Is.EqualTo(100 + 5 + 0.5));
+            Assert.That(textAny.Match(textHtmlLevel1SpamEggs), Is.EqualTo(100 + 5 + 0.5));
+
+            Assert.That(textPlain.Match(any), Is.EqualTo(0));
+            Assert.That(textPlain.Match(textAny), Is.EqualTo(0));
+            Assert.That(textPlain.Match(textPlain), Is.EqualTo(100 + 10 + 0.5 + 0.01));
+            Assert.That(textPlain.Match(textHtml), Is.EqualTo(0));
+            Assert.That(textPlain.Match(textHtmlLevel1), Is.EqualTo(0));
+            Assert.That(textPlain.Match(textHtmlLevel1SpamBacon), Is.EqualTo(0));
+            Assert.That(textPlain.Match(textHtmlLevel1SpamEggs), Is.EqualTo(0));
+
+            Assert.That(textHtml.Match(any), Is.EqualTo(0));
+            Assert.That(textHtml.Match(textAny), Is.EqualTo(0));
+            Assert.That(textHtml.Match(textPlain), Is.EqualTo(0));
+            Assert.That(textHtml.Match(textHtml), Is.EqualTo(100 + 10 + 0.5 + 0.01));
+            Assert.That(textHtml.Match(textHtmlLevel1), Is.EqualTo(100 + 10 + 0.5));
+            Assert.That(textHtml.Match(textHtmlLevel1SpamBacon), Is.EqualTo(100 + 10 + 0.5));
+            Assert.That(textHtml.Match(textHtmlLevel1SpamEggs), Is.EqualTo(100 + 10 + 0.5));
+
+            Assert.That(textHtmlLevel1.Match(any), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1.Match(textAny), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1.Match(textPlain), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1.Match(textHtml), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1.Match(textHtmlLevel1), Is.EqualTo(100 + 10 + 1));
+            Assert.That(textHtmlLevel1.Match(textHtmlLevel1SpamBacon), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1.Match(textHtmlLevel1SpamEggs), Is.EqualTo(0));
+
+            Assert.That(textHtmlLevel1SpamBacon.Match(any), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamBacon.Match(textAny), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamBacon.Match(textPlain), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamBacon.Match(textHtml), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamBacon.Match(textHtmlLevel1), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamBacon.Match(textHtmlLevel1SpamBacon), Is.EqualTo(100 + 10 + 1));
+            Assert.That(textHtmlLevel1SpamBacon.Match(textHtmlLevel1SpamEggs), Is.EqualTo(0));
+
+            Assert.That(textHtmlLevel1SpamEggs.Match(any), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamEggs.Match(textAny), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamEggs.Match(textPlain), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamEggs.Match(textHtml), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamEggs.Match(textHtmlLevel1), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamEggs.Match(textHtmlLevel1SpamBacon), Is.EqualTo(0));
+            Assert.That(textHtmlLevel1SpamEggs.Match(textHtmlLevel1SpamEggs), Is.EqualTo(100 + 10 + 1));
+        }
+    }
+}

File tests/CsConneg.ParseAcceptHeaderTests.cs

+namespace CsConneg
+{
+    using System;
+    using System.Linq;
+    using System.IO;
+    using System.Collections.Generic;
+    using System.Text;
+    //
+    using NUnit.Framework;
+    using NUnit.Framework.SyntaxHelpers;
+
+    [TestFixture] public class ParseAcceptHeaderTests
+    {
+        [Test, ExpectedException(
+            typeof(ArgumentNullException),
+            ExpectedMessage="acceptHeaderValue",
+            MatchType=MessageMatch.Contains)]
+        public void Parser_throws_on_null_accept_header()
+        {
+            AcceptHeader.Parse(null);
+        }
+
+        [Test] public void Accept_header_is_parsed_correctly()
+        {
+            var cases = new[]
+            {
+                new
+                {
+                    Input = "application/xml",
+                    Expected = new[] { new MediaRange("application", "xml"), },
+                },
+                new
+                {
+                    Input = "application/atom+xml",
+                    Expected = new[] { new MediaRange("application", "atom+xml"), },
+                },
+                new
+                {
+                    Input = "application/atom+xml; type=feed;q=0.111",
+                    Expected = new[]
+                    {
+                        new MediaRange(
+                            "application", "atom+xml", new[]
+                            {
+                                new KeyValuePair<string, string>("type", "feed"),
+                                new KeyValuePair<string, string>("q", "0.111"),
+                            }),
+                    },
+                },
+                new
+                {
+                    Input = "application/atom+xml;type=feed;q=1, text/*;q=0.5, */*",
+                    Expected = new[]
+                    {
+                        new MediaRange(
+                            "application", "atom+xml",
+                            new[]
+                            {
+                                new KeyValuePair<string, string>("type", "feed"),
+                                new KeyValuePair<string, string>("q", "1"),
+                            }),
+                        new MediaRange(
+                            "text", "*",
+                            new[]
+                            {
+                                new KeyValuePair<string, string>("q", "0.5"),
+                            }),
+                        new MediaRange("*", "*"),
+                    },
+                },
+                new
+                {
+                    Input = "text/*;q=0.3, text/html;q=0.7, text/html;level=1,\r\ntext/html;level=2;q=0.4, */*;q=0.5",
+                    Expected = new[]
+                    {
+                        new MediaRange(
+                            "text", "*",
+                            new[]
+                            {
+                                new KeyValuePair<string, string>("q", "0.3"),
+                            }),
+                        new MediaRange(
+                            "text", "html",
+                            new[]
+                            {
+                                new KeyValuePair<string, string>("q", "0.7"),
+                            }),
+                        new MediaRange(
+                            "text", "html",
+                            new[]
+                            {
+                                new KeyValuePair<string, string>("level", "1"),
+                            }),
+                        new MediaRange(
+                            "text", "html",
+                            new[]
+                            {
+                                new KeyValuePair<string, string>("level", "2"),
+                                new KeyValuePair<string, string>("q", "0.4"),
+                            }),
+                        new MediaRange(
+                            "*", "*",
+                            new[]
+                            {
+                                new KeyValuePair<string, string>("q", "0.5"),
+                            }),
+                    },
+                },
+            };
+
+            foreach (var @case in cases)
+            {
+                var parsed = AcceptHeader.Parse(@case.Input);
+                Assert.That(parsed, Is.EquivalentTo(@case.Expected));
+            }
+        }
+    }
+}

File tests/CsConneg.Tests.csproj

+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="3.5">
+  <PropertyGroup>
+    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
+    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
+    <ProductVersion>9.0.21022</ProductVersion>
+    <SchemaVersion>2.0</SchemaVersion>
+    <ProjectGuid>{27CED2AD-7738-40E2-9916-F366B0DBA1DF}</ProjectGuid>
+    <OutputType>Library</OutputType>
+    <RootNamespace>CsConneg.Tests</RootNamespace>
+    <AssemblyName>CsConneg.Tests</AssemblyName>
+    <TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
+  </PropertyGroup>
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
+    <DebugSymbols>true</DebugSymbols>
+    <DebugType>full</DebugType>
+    <Optimize>false</Optimize>
+    <OutputPath>bin\Debug</OutputPath>
+    <DefineConstants>DEBUG</DefineConstants>
+    <ErrorReport>prompt</ErrorReport>
+    <WarningLevel>4</WarningLevel>
+    <NoWarn>1718</NoWarn>
+  </PropertyGroup>
+  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
+    <DebugType>none</DebugType>
+    <Optimize>false</Optimize>
+    <OutputPath>bin\Release</OutputPath>
+    <ErrorReport>prompt</ErrorReport>
+    <WarningLevel>4</WarningLevel>
+    <NoWarn>1718</NoWarn>
+  </PropertyGroup>
+  <ItemGroup>
+    <Reference Include="System" />
+    <Reference Include="nunit.core, Version=2.4.7.0, Culture=neutral, PublicKeyToken=96d09a1eb7f44a77">
+      <Package>nunit</Package>
+    </Reference>
+    <Reference Include="nunit.framework, Version=2.4.7.0, Culture=neutral, PublicKeyToken=96d09a1eb7f44a77">
+      <Package>nunit</Package>
+    </Reference>
+    <Reference Include="System.Core" />
+  </ItemGroup>
+  <ItemGroup>
+    <Compile Include="CsConneg.DispatcherTests.cs" />
+    <Compile Include="CsConneg.MediaRangeTests.cs" />
+    <Compile Include="CsConneg.ParseAcceptHeaderTests.cs" />
+    <Compile Include="CsConneg.BuilderTests.cs" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\src\CsConneg.csproj">
+      <Project>{B627A5DC-AC41-4C04-8986-A425CA452B48}</Project>
+      <Name>CsConneg</Name>
+    </ProjectReference>
+  </ItemGroup>
+  <Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
+</Project>