From 4fb21644fc3b2387e07e3622f2361652d4598c1f Mon Sep 17 00:00:00 2001 From: Chris Ross Date: Wed, 14 Jan 2015 15:41:09 -0800 Subject: [PATCH] Implement strongly typed headers. --- HttpAbstractions.sln | 32 +- .../Microsoft.AspNet.FeatureModel.kproj | 9 +- .../HeaderDictionaryTypeExtensions.cs | 202 +++++ .../Microsoft.AspNet.Http.Extensions.kproj | 9 +- .../QueryBuilder.cs | 3 +- .../RequestHeaders.cs | 259 +++++++ .../ResponseHeaders.cs | 156 ++++ .../UriHelper.cs | 18 +- .../project.json | 3 +- src/Microsoft.AspNet.Http/HttpRequest.cs | 24 - .../Microsoft.AspNet.Http.kproj | 9 +- .../Microsoft.AspNet.HttpFeature.kproj | 9 +- .../Microsoft.AspNet.Owin.kproj | 9 +- .../OwinFeatureCollection.cs | 2 +- .../Collections/FormFileCollection.cs | 9 +- .../Collections/HeaderDictionary.cs | 4 + .../DefaultHttpRequest.cs | 12 - .../FormFeature.cs | 123 +-- .../Infrastructure/ParsingHelpers.cs | 19 - .../Microsoft.AspNet.PipelineCore.kproj | 9 +- .../project.json | 4 +- .../FormReader.cs | 21 +- .../Microsoft.AspNet.WebUtilities.kproj | 9 +- .../project.json | 6 +- .../BaseHeaderParser.cs | 70 ++ .../CacheControlHeaderValue.cs | 603 +++++++++++++++ .../ContentDispositionHeaderValue.cs | 702 ++++++++++++++++++ .../ContentRangeHeaderValue.cs | 397 ++++++++++ .../CookieHeaderParser.cs | 104 +++ .../CookieHeaderValue.cs | 256 +++++++ .../EntityTagHeaderValue.cs | 204 +++++ .../GenericHeaderParser.cs | 23 + src/Microsoft.Net.Http.Headers/HeaderNames.cs | 59 ++ .../HeaderQuality.cs | 18 + .../HeaderUtilities.cs | 232 ++++++ .../HttpHeaderParser.cs | 137 ++++ .../HttpParseResult.cs | 12 + .../HttpRuleParser.cs | 352 +++++++++ .../MediaTypeHeaderValue.cs | 408 ++++++++++ .../MediaTypeHeaderValueComparer.cs | 100 +++ .../Microsoft.Net.Http.Headers.kproj | 23 + .../NameValueHeaderValue.cs | 347 +++++++++ .../NotNullAttribute.cs | 12 + .../ObjectCollection.cs | 56 ++ .../RangeConditionHeaderValue.cs | 166 +++++ .../RangeHeaderValue.cs | 161 ++++ .../RangeItemHeaderValue.cs | 226 ++++++ .../SetCookieHeaderValue.cs | 364 +++++++++ .../StringWithQualityHeaderValue.cs | 225 ++++++ .../StringWithQualityHeaderValueComparer.cs | 71 ++ src/Microsoft.Net.Http.Headers/project.json | 19 + .../HeaderDictionaryTypeExtensionsTest.cs | 206 +++++ ...crosoft.AspNet.Http.Extensions.Tests.kproj | 9 +- .../QueryBuilderTests.cs | 2 +- .../UseWithServicesTests.cs | 5 +- .../DefaultHttpRequestTests.cs | 20 - .../Microsoft.AspNet.WebUtilities.Tests.kproj | 9 +- .../project.json | 4 +- .../CacheControlHeaderValueTest.cs | 599 +++++++++++++++ .../ContentDispositionHeaderValueTest.cs | 609 +++++++++++++++ .../ContentRangeHeaderValueTest.cs | 272 +++++++ .../CookieHeaderValueTest.cs | 229 ++++++ .../DateParserTest.cs | 58 ++ .../EntityTagHeaderValueTest.cs | 315 ++++++++ .../MediaTypeHeaderValueComparerTests.cs | 69 ++ .../MediaTypeHeaderValueTest.cs | 520 +++++++++++++ .../Microsoft.Net.Http.Headers.Tests.kproj | 23 + .../NameValueHeaderValueTest.cs | 394 ++++++++++ .../RangeConditionHeaderValueTest.cs | 174 +++++ .../RangeHeaderValueTest.cs | 183 +++++ .../RangeItemHeaderValueTest.cs | 162 ++++ .../SetCookieHeaderValueTest.cs | 259 +++++++ ...tringWithQualityHeaderValueComparerTest.cs | 64 ++ .../StringWithQualityHeaderValueTest.cs | 339 +++++++++ .../project.json | 18 + 75 files changed, 10676 insertions(+), 173 deletions(-) create mode 100644 src/Microsoft.AspNet.Http.Extensions/HeaderDictionaryTypeExtensions.cs rename src/{Microsoft.AspNet.WebUtilities => Microsoft.AspNet.Http.Extensions}/QueryBuilder.cs (97%) create mode 100644 src/Microsoft.AspNet.Http.Extensions/RequestHeaders.cs create mode 100644 src/Microsoft.AspNet.Http.Extensions/ResponseHeaders.cs rename src/{Microsoft.AspNet.WebUtilities => Microsoft.AspNet.Http.Extensions}/UriHelper.cs (85%) create mode 100644 src/Microsoft.Net.Http.Headers/BaseHeaderParser.cs create mode 100644 src/Microsoft.Net.Http.Headers/CacheControlHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/ContentDispositionHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/ContentRangeHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/CookieHeaderParser.cs create mode 100644 src/Microsoft.Net.Http.Headers/CookieHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/EntityTagHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/GenericHeaderParser.cs create mode 100644 src/Microsoft.Net.Http.Headers/HeaderNames.cs create mode 100644 src/Microsoft.Net.Http.Headers/HeaderQuality.cs create mode 100644 src/Microsoft.Net.Http.Headers/HeaderUtilities.cs create mode 100644 src/Microsoft.Net.Http.Headers/HttpHeaderParser.cs create mode 100644 src/Microsoft.Net.Http.Headers/HttpParseResult.cs create mode 100644 src/Microsoft.Net.Http.Headers/HttpRuleParser.cs create mode 100644 src/Microsoft.Net.Http.Headers/MediaTypeHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/MediaTypeHeaderValueComparer.cs create mode 100644 src/Microsoft.Net.Http.Headers/Microsoft.Net.Http.Headers.kproj create mode 100644 src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/NotNullAttribute.cs create mode 100644 src/Microsoft.Net.Http.Headers/ObjectCollection.cs create mode 100644 src/Microsoft.Net.Http.Headers/RangeConditionHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/RangeHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/RangeItemHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValue.cs create mode 100644 src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValueComparer.cs create mode 100644 src/Microsoft.Net.Http.Headers/project.json create mode 100644 test/Microsoft.AspNet.Http.Extensions.Tests/HeaderDictionaryTypeExtensionsTest.cs rename test/{Microsoft.AspNet.WebUtilities.Tests => Microsoft.AspNet.Http.Extensions.Tests}/QueryBuilderTests.cs (98%) create mode 100644 test/Microsoft.Net.Http.Headers.Tests/CacheControlHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/ContentDispositionHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/ContentRangeHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/CookieHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/DateParserTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/EntityTagHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueComparerTests.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/Microsoft.Net.Http.Headers.Tests.kproj create mode 100644 test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/RangeConditionHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/RangeHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/RangeItemHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueComparerTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueTest.cs create mode 100644 test/Microsoft.Net.Http.Headers.Tests/project.json diff --git a/HttpAbstractions.sln b/HttpAbstractions.sln index cdea46535d..8ae65bd225 100644 --- a/HttpAbstractions.sln +++ b/HttpAbstractions.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 -VisualStudioVersion = 14.0.21916.0 +VisualStudioVersion = 14.0.22410.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A5A15F1C-885A-452A-A731-B0173DDBD913}" EndProject @@ -33,6 +33,10 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.WebUtiliti EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.WebUtilities.Tests", "test\Microsoft.AspNet.WebUtilities.Tests\Microsoft.AspNet.WebUtilities.Tests.kproj", "{93C10E50-BCBB-4D8E-9492-D46E1396225B}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.Net.Http.Headers", "src\Microsoft.Net.Http.Headers\Microsoft.Net.Http.Headers.kproj", "{60AA2FDB-8121-4826-8D00-9A143FEFAF66}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.Net.Http.Headers.Tests", "test\Microsoft.Net.Http.Headers.Tests\Microsoft.Net.Http.Headers.Tests.kproj", "{E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -173,6 +177,30 @@ Global {93C10E50-BCBB-4D8E-9492-D46E1396225B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {93C10E50-BCBB-4D8E-9492-D46E1396225B}.Release|Mixed Platforms.Build.0 = Release|Any CPU {93C10E50-BCBB-4D8E-9492-D46E1396225B}.Release|x86.ActiveCfg = Release|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Debug|x86.ActiveCfg = Debug|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Debug|x86.Build.0 = Debug|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Release|Any CPU.Build.0 = Release|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Release|x86.ActiveCfg = Release|Any CPU + {60AA2FDB-8121-4826-8D00-9A143FEFAF66}.Release|x86.Build.0 = Release|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Debug|x86.Build.0 = Debug|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Release|Any CPU.Build.0 = Release|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Release|x86.ActiveCfg = Release|Any CPU + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -191,5 +219,7 @@ Global {AE25EF21-7F91-4B86-B73E-AF746821D339} = {F31FF137-390C-49BF-A3BD-7C6ED3597C21} {A2FB7838-0031-4FAD-BA3E-83C30B3AF406} = {A5A15F1C-885A-452A-A731-B0173DDBD913} {93C10E50-BCBB-4D8E-9492-D46E1396225B} = {F31FF137-390C-49BF-A3BD-7C6ED3597C21} + {60AA2FDB-8121-4826-8D00-9A143FEFAF66} = {A5A15F1C-885A-452A-A731-B0173DDBD913} + {E6BB7AD1-BD10-4A23-B780-F4A86ADF00D1} = {F31FF137-390C-49BF-A3BD-7C6ED3597C21} EndGlobalSection EndGlobal diff --git a/src/Microsoft.AspNet.FeatureModel/Microsoft.AspNet.FeatureModel.kproj b/src/Microsoft.AspNet.FeatureModel/Microsoft.AspNet.FeatureModel.kproj index 561cd372d7..5484c20589 100644 --- a/src/Microsoft.AspNet.FeatureModel/Microsoft.AspNet.FeatureModel.kproj +++ b/src/Microsoft.AspNet.FeatureModel/Microsoft.AspNet.FeatureModel.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Http.Extensions/HeaderDictionaryTypeExtensions.cs b/src/Microsoft.AspNet.Http.Extensions/HeaderDictionaryTypeExtensions.cs new file mode 100644 index 0000000000..fc4438c018 --- /dev/null +++ b/src/Microsoft.AspNet.Http.Extensions/HeaderDictionaryTypeExtensions.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.AspNet.Http.Headers; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNet.Http +{ + public static class HeaderDictionaryTypeExtensions + { + public static RequestHeaders GetTypedHeaders(this HttpRequest request) + { + return new RequestHeaders(request.Headers); + } + + public static ResponseHeaders GetTypedHeaders(this HttpResponse response) + { + return new ResponseHeaders(response.Headers); + } + + public static DateTimeOffset? GetDate([NotNull] this IHeaderDictionary headers, [NotNull] string name) + { + return headers.Get(name); + } + + public static void Set([NotNull] this IHeaderDictionary headers, [NotNull] string name, object value) + { + if (value == null) + { + headers.Remove(name); + } + else + { + headers[name] = value.ToString(); + } + } + + public static void SetList([NotNull] this IHeaderDictionary headers, [NotNull] string name, IList values) + { + if (values == null || values.Count == 0) + { + headers.Remove(name); + } + else + { + headers.SetValues(name, values.Select(value => value.ToString()).ToArray()); + } + } + + public static void SetDate([NotNull] this IHeaderDictionary headers, [NotNull] string name, DateTimeOffset? value) + { + if (value.HasValue) + { + headers[name] = HeaderUtilities.FormatDate(value.Value); + } + else + { + headers.Remove(name); + } + } + + public static void Append([NotNull] this IHeaderDictionary headers, [NotNull] string name, [NotNull] object value) + { + headers.Append(name, value.ToString()); + } + + public static void AppendList([NotNull] this IHeaderDictionary headers, [NotNull] string name, [NotNull] IList values) + { + headers.AppendValues(name, values.Select(value => value.ToString()).ToArray()); + } + + private static IDictionary KnownParsers = new Dictionary() + { + { typeof(CacheControlHeaderValue), new Func(value => { CacheControlHeaderValue result; return CacheControlHeaderValue.TryParse(value, out result) ? result : null; }) }, + { typeof(ContentDispositionHeaderValue), new Func(value => { ContentDispositionHeaderValue result; return ContentDispositionHeaderValue.TryParse(value, out result) ? result : null; }) }, + { typeof(ContentRangeHeaderValue), new Func(value => { ContentRangeHeaderValue result; return ContentRangeHeaderValue.TryParse(value, out result) ? result : null; }) }, + { typeof(MediaTypeHeaderValue), new Func(value => { MediaTypeHeaderValue result; return MediaTypeHeaderValue.TryParse(value, out result) ? result : null; }) }, + { typeof(RangeConditionHeaderValue), new Func(value => { RangeConditionHeaderValue result; return RangeConditionHeaderValue.TryParse(value, out result) ? result : null; }) }, + { typeof(RangeHeaderValue), new Func(value => { RangeHeaderValue result; return RangeHeaderValue.TryParse(value, out result) ? result : null; }) }, + { typeof(EntityTagHeaderValue), new Func(value => { EntityTagHeaderValue result; return EntityTagHeaderValue.TryParse(value, out result) ? result : null; }) }, + { typeof(DateTimeOffset?), new Func(value => { DateTimeOffset result; return HeaderUtilities.TryParseDate(value, out result) ? result : (DateTimeOffset?)null; }) }, + { typeof(long?), new Func(value => { long result; return HeaderUtilities.TryParseInt64(value, out result) ? result : (long?)null; }) }, + }; + + private static IDictionary KnownListParsers = new Dictionary() + { + { typeof(MediaTypeHeaderValue), new Func, IList>(value => { IList result; return MediaTypeHeaderValue.TryParseList(value, out result) ? result : null; }) }, + { typeof(StringWithQualityHeaderValue), new Func, IList>(value => { IList result; return StringWithQualityHeaderValue.TryParseList(value, out result) ? result : null; }) }, + { typeof(CookieHeaderValue), new Func, IList>(value => { IList result; return CookieHeaderValue.TryParseList(value, out result) ? result : null; }) }, + { typeof(EntityTagHeaderValue), new Func, IList>(value => { IList result; return EntityTagHeaderValue.TryParseList(value, out result) ? result : null; }) }, + { typeof(SetCookieHeaderValue), new Func, IList>(value => { IList result; return SetCookieHeaderValue.TryParseList(value, out result) ? result : null; }) }, + }; + + public static T Get([NotNull] this IHeaderDictionary headers, string name) + { + object temp; + if (KnownParsers.TryGetValue(typeof(T), out temp)) + { + var func = (Func)temp; + return func(headers[name]); + } + + var value = headers[name]; + if (string.IsNullOrWhiteSpace(value)) + { + return default(T); + } + + return GetViaReflection(value); + } + + public static IList GetList([NotNull] this IHeaderDictionary headers, string name) + { + object temp; + if (KnownListParsers.TryGetValue(typeof(T), out temp)) + { + var func = (Func, IList>)temp; + return func(headers.GetValues(name)); + } + + var values = headers.GetValues(name); + if (values == null || !values.Any()) + { + return null; + } + + return GetListViaReflection(values); + } + + private static T GetViaReflection(string value) + { + // TODO: Cache the reflected type for later? Only if success? + var type = typeof(T); + var method = type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(methodInfo => + { + if (string.Equals("TryParse", methodInfo.Name, StringComparison.Ordinal) + && methodInfo.ReturnParameter.ParameterType.Equals(typeof(bool))) + { + var methodParams = methodInfo.GetParameters(); + return methodParams.Length == 2 + && methodParams[0].ParameterType.Equals(typeof(string)) + && methodParams[1].IsOut + && methodParams[1].ParameterType.Equals(type.MakeByRefType()); + } + return false; + }).FirstOrDefault(); + + if (method == null) + { + throw new NotSupportedException(string.Format( + "The given type '{0}' does not have a TryParse method with the required signature 'public static bool TryParse(string, out {0}).", nameof(T))); + } + + var parameters = new object[] { value, null }; + var success = (bool)method.Invoke(null, parameters); + if (success) + { + return (T)parameters[1]; + } + return default(T); + } + + private static IList GetListViaReflection(IList values) + { + // TODO: Cache the reflected type for later? Only if success? + var type = typeof(T); + var method = type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(methodInfo => + { + if (string.Equals("TryParseList", methodInfo.Name, StringComparison.Ordinal) + && methodInfo.ReturnParameter.ParameterType.Equals(typeof(Boolean))) + { + var methodParams = methodInfo.GetParameters(); + return methodParams.Length == 2 + && methodParams[0].ParameterType.Equals(typeof(IList)) + && methodParams[1].IsOut + && methodParams[1].ParameterType.Equals(typeof(IList).MakeByRefType()); + } + return false; + }).FirstOrDefault(); + + if (method == null) + { + throw new NotSupportedException(string.Format( + "The given type '{0}' does not have a TryParseList method with the required signature 'public static bool TryParseList(IList, out IList<{0}>).", nameof(T))); + } + + var parameters = new object[] { values, null }; + var success = (bool)method.Invoke(null, parameters); + if (success) + { + return (IList)parameters[1]; + } + return null; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Http.Extensions/Microsoft.AspNet.Http.Extensions.kproj b/src/Microsoft.AspNet.Http.Extensions/Microsoft.AspNet.Http.Extensions.kproj index a9b64e3c08..b431b00760 100644 --- a/src/Microsoft.AspNet.Http.Extensions/Microsoft.AspNet.Http.Extensions.kproj +++ b/src/Microsoft.AspNet.Http.Extensions/Microsoft.AspNet.Http.Extensions.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.WebUtilities/QueryBuilder.cs b/src/Microsoft.AspNet.Http.Extensions/QueryBuilder.cs similarity index 97% rename from src/Microsoft.AspNet.WebUtilities/QueryBuilder.cs rename to src/Microsoft.AspNet.Http.Extensions/QueryBuilder.cs index 28cdfa85fe..a4afbba272 100644 --- a/src/Microsoft.AspNet.WebUtilities/QueryBuilder.cs +++ b/src/Microsoft.AspNet.Http.Extensions/QueryBuilder.cs @@ -5,9 +5,8 @@ using System; using System.Collections; using System.Collections.Generic; using System.Text; -using Microsoft.AspNet.Http; -namespace Microsoft.AspNet.WebUtilities +namespace Microsoft.AspNet.Http.Extensions { // The IEnumerable interface is required for the collection initialization syntax: new QueryBuilder() { { "key", "value" } }; public class QueryBuilder : IEnumerable> diff --git a/src/Microsoft.AspNet.Http.Extensions/RequestHeaders.cs b/src/Microsoft.AspNet.Http.Extensions/RequestHeaders.cs new file mode 100644 index 0000000000..eeeef90b24 --- /dev/null +++ b/src/Microsoft.AspNet.Http.Extensions/RequestHeaders.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNet.Http.Headers +{ + public class RequestHeaders + { + public RequestHeaders([NotNull] IHeaderDictionary headers) + { + Headers = headers; + } + + public IHeaderDictionary Headers { get; private set; } + + public IList Accept + { + get + { + return Headers.GetList(HeaderNames.Accept); + } + set + { + Headers.SetList(HeaderNames.Accept, value); + } + } + + public IList AcceptCharset + { + get + { + return Headers.GetList(HeaderNames.AcceptCharset); + } + set + { + Headers.SetList(HeaderNames.AcceptCharset, value); + } + } + + public IList AcceptEncoding + { + get + { + return Headers.GetList(HeaderNames.AcceptEncoding); + } + set + { + Headers.SetList(HeaderNames.AcceptEncoding, value); + } + } + + public IList AcceptLanguage + { + get + { + return Headers.GetList(HeaderNames.AcceptLanguage); + } + set + { + Headers.SetList(HeaderNames.AcceptLanguage, value); + } + } + + public CacheControlHeaderValue CacheControl + { + get + { + return Headers.Get(HeaderNames.CacheControl); + } + set + { + Headers.Set(HeaderNames.CacheControl, value); + } + } + + public ContentDispositionHeaderValue ContentDisposition + { + get + { + return Headers.Get(HeaderNames.ContentDisposition); + } + set + { + Headers.Set(HeaderNames.ContentDisposition, value); + } + } + + public long? ContentLength + { + get + { + return Headers.Get(HeaderNames.ContentLength); + } + set + { + Headers.Set(HeaderNames.ContentLength, value.HasValue ? HeaderUtilities.FormatInt64(value.Value) : null); + } + } + + public ContentRangeHeaderValue ContentRange + { + get + { + return Headers.Get(HeaderNames.ContentRange); + } + set + { + Headers.Set(HeaderNames.ContentRange, value); + } + } + + public MediaTypeHeaderValue ContentType + { + get + { + return Headers.Get(HeaderNames.ContentType); + } + set + { + Headers.Set(HeaderNames.ContentType, value); + } + } + + public IList Cookie + { + get + { + return Headers.GetList(HeaderNames.Cookie); + } + set + { + Headers.SetList(HeaderNames.Cookie, value); + } + } + + public DateTimeOffset? Date + { + get + { + return Headers.GetDate(HeaderNames.Date); + } + set + { + Headers.SetDate(HeaderNames.Date, value); + } + } + + public DateTimeOffset? Expires + { + get + { + return Headers.GetDate(HeaderNames.Expires); + } + set + { + Headers.SetDate(HeaderNames.Expires, value); + } + } + + public HostString Host + { + get + { + return HostString.FromUriComponent(Headers[HeaderNames.Host]); + } + set + { + Headers[HeaderNames.Host] = value.ToUriComponent(); + } + } + + public IList IfMatch + { + get + { + return Headers.GetList(HeaderNames.IfMatch); + } + set + { + Headers.SetList(HeaderNames.IfMatch, value); + } + } + + public DateTimeOffset? IfModifiedSince + { + get + { + return Headers.GetDate(HeaderNames.IfModifiedSince); + } + set + { + Headers.SetDate(HeaderNames.IfModifiedSince, value); + } + } + + public IList IfNoneMatch + { + get + { + return Headers.GetList(HeaderNames.IfNoneMatch); + } + set + { + Headers.SetList(HeaderNames.IfNoneMatch, value); + } + } + + public RangeConditionHeaderValue IfRange + { + get + { + return Headers.Get(HeaderNames.IfRange); + } + set + { + Headers.Set(HeaderNames.IfRange, value); + } + } + + public DateTimeOffset? IfUnmodifiedSince + { + get + { + return Headers.GetDate(HeaderNames.IfUnmodifiedSince); + } + set + { + Headers.SetDate(HeaderNames.IfUnmodifiedSince, value); + } + } + + public DateTimeOffset? LastModified + { + get + { + return Headers.GetDate(HeaderNames.LastModified); + } + set + { + Headers.SetDate(HeaderNames.LastModified, value); + } + } + + public RangeHeaderValue Range + { + get + { + return Headers.Get(HeaderNames.Range); + } + set + { + Headers.Set(HeaderNames.Range, value); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Http.Extensions/ResponseHeaders.cs b/src/Microsoft.AspNet.Http.Extensions/ResponseHeaders.cs new file mode 100644 index 0000000000..4169f237a7 --- /dev/null +++ b/src/Microsoft.AspNet.Http.Extensions/ResponseHeaders.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNet.Http.Extensions; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNet.Http.Headers +{ + public class ResponseHeaders + { + public ResponseHeaders([NotNull] IHeaderDictionary headers) + { + Headers = headers; + } + + public IHeaderDictionary Headers { get; private set; } + + public CacheControlHeaderValue CacheControl + { + get + { + return Headers.Get(HeaderNames.CacheControl); + } + set + { + Headers.Set(HeaderNames.CacheControl, value); + } + } + + public ContentDispositionHeaderValue ContentDisposition + { + get + { + return Headers.Get(HeaderNames.ContentDisposition); + } + set + { + Headers.Set(HeaderNames.ContentDisposition, value); + } + } + + public long? ContentLength + { + get + { + return Headers.Get(HeaderNames.ContentLength); + } + set + { + Headers.Set(HeaderNames.ContentLength, value.HasValue ? HeaderUtilities.FormatInt64(value.Value) : null); + } + } + + public ContentRangeHeaderValue ContentRange + { + get + { + return Headers.Get(HeaderNames.ContentRange); + } + set + { + Headers.Set(HeaderNames.ContentRange, value); + } + } + + public MediaTypeHeaderValue ContentType + { + get + { + return Headers.Get(HeaderNames.ContentType); + } + set + { + Headers.Set(HeaderNames.ContentType, value); + } + } + + public DateTimeOffset? Date + { + get + { + return Headers.GetDate(HeaderNames.Date); + } + set + { + Headers.SetDate(HeaderNames.Date, value); + } + } + + public EntityTagHeaderValue ETag + { + get + { + return Headers.Get(HeaderNames.ETag); + } + set + { + Headers.Set(HeaderNames.ETag, value); + } + } + public DateTimeOffset? Expires + { + get + { + return Headers.GetDate(HeaderNames.Expires); + } + set + { + Headers.SetDate(HeaderNames.Expires, value); + } + } + + public DateTimeOffset? LastModified + { + get + { + return Headers.GetDate(HeaderNames.LastModified); + } + set + { + Headers.SetDate(HeaderNames.LastModified, value); + } + } + + public UriHelper Location + { + get + { + Uri uri; + if (Uri.TryCreate(Headers[HeaderNames.Location], UriKind.RelativeOrAbsolute, out uri)) + { + return new UriHelper(uri); + } + return null; + } + set + { + Headers.Set(HeaderNames.Location, value); + } + } + + public IList SetCookie + { + get + { + return Headers.GetList(HeaderNames.SetCookie); + } + set + { + Headers.SetList(HeaderNames.SetCookie, value); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.WebUtilities/UriHelper.cs b/src/Microsoft.AspNet.Http.Extensions/UriHelper.cs similarity index 85% rename from src/Microsoft.AspNet.WebUtilities/UriHelper.cs rename to src/Microsoft.AspNet.Http.Extensions/UriHelper.cs index 491c599d20..e271d41380 100644 --- a/src/Microsoft.AspNet.WebUtilities/UriHelper.cs +++ b/src/Microsoft.AspNet.Http.Extensions/UriHelper.cs @@ -1,7 +1,9 @@ -using System; -using Microsoft.AspNet.Http; +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -namespace Microsoft.AspNet.WebUtilities +using System; + +namespace Microsoft.AspNet.Http.Extensions { /// /// A helper class for constructing encoded Uris for use in headers and other Uris. @@ -44,6 +46,11 @@ namespace Microsoft.AspNet.WebUtilities public FragmentString Fragment { get; set; } + public bool IsFullUri + { + get { return !string.IsNullOrEmpty(Scheme) && Host.HasValue; } + } + // Always returns at least '/' public string GetPartialUri() { @@ -67,6 +74,11 @@ namespace Microsoft.AspNet.WebUtilities return Scheme + "://" + Host + path + Query + Fragment; } + public override string ToString() + { + return IsFullUri ? GetFullUri() : GetPartialUri(); + } + public static string Create(PathString pathBase, PathString path = new PathString(), QueryString query = new QueryString(), diff --git a/src/Microsoft.AspNet.Http.Extensions/project.json b/src/Microsoft.AspNet.Http.Extensions/project.json index cbc45a6ddc..be234399ec 100644 --- a/src/Microsoft.AspNet.Http.Extensions/project.json +++ b/src/Microsoft.AspNet.Http.Extensions/project.json @@ -3,7 +3,8 @@ "description": "ASP.NET 5 common extension methods for HTTP abstractions and IApplicationBuilder.", "dependencies": { "Microsoft.AspNet.Http": "1.0.0-*", - "Microsoft.Framework.DependencyInjection": "1.0.0-*" + "Microsoft.Framework.DependencyInjection": "1.0.0-*", + "Microsoft.Net.Http.Headers": "1.0.0-*" }, "frameworks" : { "aspnet50" : { diff --git a/src/Microsoft.AspNet.Http/HttpRequest.cs b/src/Microsoft.AspNet.Http/HttpRequest.cs index 8048152a08..5ab2010a59 100644 --- a/src/Microsoft.AspNet.Http/HttpRequest.cs +++ b/src/Microsoft.AspNet.Http/HttpRequest.cs @@ -93,30 +93,6 @@ namespace Microsoft.AspNet.Http /// The Content-Type header. public abstract string ContentType { get; set; } - /// - /// Gets or sets the Cache-Control header. - /// - /// The Cache-Control header. - // (TODO header conventions?) public abstract string CacheControl { get; set; } - - /// - /// Gets or sets the Media-Type header. - /// - /// The Media-Type header. - // (TODO header conventions?) public abstract string MediaType { get; set; } - - /// - /// Gets or set the Accept header. - /// - /// The Accept header. - public abstract string Accept { get; set; } - - /// - /// Gets or set the Accept-Charset header. - /// - /// The Accept-Charset header. - public abstract string AcceptCharset { get; set; } - /// /// Gets or set the owin.RequestBody Stream. /// diff --git a/src/Microsoft.AspNet.Http/Microsoft.AspNet.Http.kproj b/src/Microsoft.AspNet.Http/Microsoft.AspNet.Http.kproj index 1ccadbc30a..3a68cce3bc 100644 --- a/src/Microsoft.AspNet.Http/Microsoft.AspNet.Http.kproj +++ b/src/Microsoft.AspNet.Http/Microsoft.AspNet.Http.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.HttpFeature/Microsoft.AspNet.HttpFeature.kproj b/src/Microsoft.AspNet.HttpFeature/Microsoft.AspNet.HttpFeature.kproj index 25a0e7caf2..90f93a0f7b 100644 --- a/src/Microsoft.AspNet.HttpFeature/Microsoft.AspNet.HttpFeature.kproj +++ b/src/Microsoft.AspNet.HttpFeature/Microsoft.AspNet.HttpFeature.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Owin/Microsoft.AspNet.Owin.kproj b/src/Microsoft.AspNet.Owin/Microsoft.AspNet.Owin.kproj index b0acecf075..4c7801eada 100644 --- a/src/Microsoft.AspNet.Owin/Microsoft.AspNet.Owin.kproj +++ b/src/Microsoft.AspNet.Owin/Microsoft.AspNet.Owin.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Owin/OwinFeatureCollection.cs b/src/Microsoft.AspNet.Owin/OwinFeatureCollection.cs index 434817129d..3332221be8 100644 --- a/src/Microsoft.AspNet.Owin/OwinFeatureCollection.cs +++ b/src/Microsoft.AspNet.Owin/OwinFeatureCollection.cs @@ -294,7 +294,7 @@ namespace Microsoft.AspNet.Owin public bool ContainsKey(Type key) { // Does this type implement the requested interface? - if (key.GetTypeInfo().IsAssignableFrom(this.GetType().GetTypeInfo())) + if (key.GetTypeInfo().IsAssignableFrom(GetType().GetTypeInfo())) { // Check for conditional features if (key == typeof(IHttpSendFileFeature)) diff --git a/src/Microsoft.AspNet.PipelineCore/Collections/FormFileCollection.cs b/src/Microsoft.AspNet.PipelineCore/Collections/FormFileCollection.cs index 10aad2bfa2..2ef4d29476 100644 --- a/src/Microsoft.AspNet.PipelineCore/Collections/FormFileCollection.cs +++ b/src/Microsoft.AspNet.PipelineCore/Collections/FormFileCollection.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using Microsoft.AspNet.Http; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNet.PipelineCore.Collections { @@ -26,11 +26,10 @@ namespace Microsoft.AspNet.PipelineCore.Collections private static string GetName(string contentDisposition) { - // TODO: Strongly typed headers will take care of this // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" - var offset = contentDisposition.IndexOf("name=\"") + "name=\"".Length; - var key = contentDisposition.Substring(offset, contentDisposition.IndexOf("\"", offset) - offset); // Remove quotes - return key; + ContentDispositionHeaderValue cd; + ContentDispositionHeaderValue.TryParse(contentDisposition, out cd); + return HeaderUtilities.RemoveQuotes(cd?.Name); } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.PipelineCore/Collections/HeaderDictionary.cs b/src/Microsoft.AspNet.PipelineCore/Collections/HeaderDictionary.cs index dfd54393cc..bf362c94ed 100644 --- a/src/Microsoft.AspNet.PipelineCore/Collections/HeaderDictionary.cs +++ b/src/Microsoft.AspNet.PipelineCore/Collections/HeaderDictionary.cs @@ -16,6 +16,10 @@ namespace Microsoft.AspNet.PipelineCore.Collections /// public class HeaderDictionary : IHeaderDictionary { + public HeaderDictionary() : this(new Dictionary(StringComparer.OrdinalIgnoreCase)) + { + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Microsoft.AspNet.PipelineCore/DefaultHttpRequest.cs b/src/Microsoft.AspNet.PipelineCore/DefaultHttpRequest.cs index da839c1f16..d95c37b9c3 100644 --- a/src/Microsoft.AspNet.PipelineCore/DefaultHttpRequest.cs +++ b/src/Microsoft.AspNet.PipelineCore/DefaultHttpRequest.cs @@ -150,18 +150,6 @@ namespace Microsoft.AspNet.PipelineCore set { Headers[Constants.Headers.ContentType] = value; } } - public override string Accept - { - get { return Headers[Constants.Headers.Accept]; } - set { Headers[Constants.Headers.Accept] = value; } - } - - public override string AcceptCharset - { - get { return Headers[Constants.Headers.AcceptCharset]; } - set { Headers[Constants.Headers.AcceptCharset] = value; } - } - public override bool HasFormContentType { get { return FormFeature.HasFormContentType; } diff --git a/src/Microsoft.AspNet.PipelineCore/FormFeature.cs b/src/Microsoft.AspNet.PipelineCore/FormFeature.cs index 8d20871688..0180728be2 100644 --- a/src/Microsoft.AspNet.PipelineCore/FormFeature.cs +++ b/src/Microsoft.AspNet.PipelineCore/FormFeature.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Microsoft.AspNet.Http; using Microsoft.AspNet.PipelineCore.Collections; using Microsoft.AspNet.WebUtilities; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNet.PipelineCore { @@ -28,6 +29,16 @@ namespace Microsoft.AspNet.PipelineCore _request = request; } + private MediaTypeHeaderValue ContentType + { + get + { + MediaTypeHeaderValue mt; + MediaTypeHeaderValue.TryParse(_request.ContentType, out mt); + return mt; + } + } + public bool HasFormContentType { get @@ -38,7 +49,8 @@ namespace Microsoft.AspNet.PipelineCore return true; } - return HasApplicationFormContentType() || HasMultipartFormContentType(); + var conentType = ContentType; + return HasApplicationFormContentType(conentType) || HasMultipartFormContentType(conentType); } } @@ -82,23 +94,25 @@ namespace Microsoft.AspNet.PipelineCore // Some of these code paths use StreamReader which does not support cancellation tokens. using (cancellationToken.Register(_request.HttpContext.Abort)) { + var contentType = ContentType; // Check the content-type - if (HasApplicationFormContentType()) + if (HasApplicationFormContentType(contentType)) { - // TODO: Read the charset from the content-type header after we get strongly typed headers - formFields = await FormReader.ReadFormAsync(_request.Body, cancellationToken); + var encoding = FilterEncoding(contentType.Encoding); + formFields = await FormReader.ReadFormAsync(_request.Body, encoding, cancellationToken); } - else if (HasMultipartFormContentType()) + else if (HasMultipartFormContentType(contentType)) { var formAccumulator = new KeyValueAccumulator(StringComparer.OrdinalIgnoreCase); - var boundary = GetBoundary(_request.ContentType); + var boundary = GetBoundary(contentType); var multipartReader = new MultipartReader(boundary, _request.Body); var section = await multipartReader.ReadNextSectionAsync(cancellationToken); while (section != null) { var headers = new HeaderDictionary(section.Headers); - var contentDisposition = headers["Content-Disposition"]; + ContentDispositionHeaderValue contentDisposition; + ContentDispositionHeaderValue.TryParse(headers.Get(HeaderNames.ContentDisposition), out contentDisposition); if (HasFileContentDisposition(contentDisposition)) { // Find the end @@ -116,12 +130,11 @@ namespace Microsoft.AspNet.PipelineCore // // value - // TODO: Strongly typed headers will take care of this - var offset = contentDisposition.IndexOf("name=") + "name=".Length; - var key = contentDisposition.Substring(offset + 1, contentDisposition.Length - offset - 2); // Remove quotes - - // TODO: Read the charset from the content-disposition header after we get strongly typed headers - using (var reader = new StreamReader(section.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) + var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name); + MediaTypeHeaderValue mediaType; + MediaTypeHeaderValue.TryParse(headers.Get(HeaderNames.ContentType), out mediaType); + var encoding = FilterEncoding(mediaType?.Encoding); + using (var reader = new StreamReader(section.Body, encoding, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) { var value = await reader.ReadToEndAsync(); formAccumulator.Append(key, value); @@ -129,7 +142,7 @@ namespace Microsoft.AspNet.PipelineCore } else { - System.Diagnostics.Debug.Assert(false, "Unrecognized content-disposition for this section: " + contentDisposition); + System.Diagnostics.Debug.Assert(false, "Unrecognized content-disposition for this section: " + headers.Get(HeaderNames.ContentDisposition)); } section = await multipartReader.ReadNextSectionAsync(cancellationToken); @@ -143,48 +156,50 @@ namespace Microsoft.AspNet.PipelineCore return Form; } - private bool HasApplicationFormContentType() + private Encoding FilterEncoding(Encoding encoding) { - // TODO: Strongly typed headers will take care of this for us - // Content-Type: application/x-www-form-urlencoded; charset=utf-8 - var contentType = _request.ContentType; - return !string.IsNullOrEmpty(contentType) && contentType.IndexOf("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) >= 0; - } - - private bool HasMultipartFormContentType() - { - // TODO: Strongly typed headers will take care of this for us - // Content-Type: multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq - var contentType = _request.ContentType; - return !string.IsNullOrEmpty(contentType) && contentType.IndexOf("multipart/form-data", StringComparison.OrdinalIgnoreCase) >= 0; - } - - private bool HasFormDataContentDisposition(string contentDisposition) - { - // TODO: Strongly typed headers will take care of this for us - // Content-Disposition: form-data; name="key"; - return !string.IsNullOrEmpty(contentDisposition) && contentDisposition.Contains("form-data") && !contentDisposition.Contains("filename="); - } - - private bool HasFileContentDisposition(string contentDisposition) - { - // TODO: Strongly typed headers will take care of this for us - // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" - return !string.IsNullOrEmpty(contentDisposition) && contentDisposition.Contains("form-data") && contentDisposition.Contains("filename="); - } - - // Content-Type: multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq - private static string GetBoundary(string contentType) - { - // TODO: Strongly typed headers will take care of this for us - // TODO: Limit the length of boundary we accept. The spec says ~70 chars. - var elements = contentType.Split(' '); - var element = elements.Where(entry => entry.StartsWith("boundary=")).First(); - var boundary = element.Substring("boundary=".Length); - // Remove quotes - if (boundary.Length >= 2 && boundary[0] == '"' && boundary[boundary.Length - 1] == '"') + // UTF-7 is insecure and should not be honored. UTF-8 will succeed for most cases. + if (encoding == null || Encoding.UTF7.Equals(encoding)) { - boundary = boundary.Substring(1, boundary.Length - 2); + return Encoding.UTF8; + } + return encoding; + } + + private bool HasApplicationFormContentType(MediaTypeHeaderValue contentType) + { + // Content-Type: application/x-www-form-urlencoded; charset=utf-8 + return contentType != null && contentType.MediaType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase); + } + + private bool HasMultipartFormContentType(MediaTypeHeaderValue contentType) + { + // Content-Type: multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq + return contentType != null && contentType.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase); + } + + private bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition) + { + // Content-Disposition: form-data; name="key"; + return contentDisposition != null && contentDisposition.DispositionType.Equals("form-data") + && string.IsNullOrEmpty(contentDisposition.FileName) && string.IsNullOrEmpty(contentDisposition.FileNameStar); + } + + private bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition) + { + // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" + return contentDisposition != null && contentDisposition.DispositionType.Equals("form-data") + && (!string.IsNullOrEmpty(contentDisposition.FileName) || !string.IsNullOrEmpty(contentDisposition.FileNameStar)); + } + + // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq" + // TODO: Limit the length of boundary we accept. The spec says ~70 chars. + private static string GetBoundary(MediaTypeHeaderValue contentType) + { + var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary); + if (string.IsNullOrWhiteSpace(boundary)) + { + throw new InvalidOperationException("Missing content-type boundary."); } return boundary; } diff --git a/src/Microsoft.AspNet.PipelineCore/Infrastructure/ParsingHelpers.cs b/src/Microsoft.AspNet.PipelineCore/Infrastructure/ParsingHelpers.cs index e0ed2c7333..48dab300ac 100644 --- a/src/Microsoft.AspNet.PipelineCore/Infrastructure/ParsingHelpers.cs +++ b/src/Microsoft.AspNet.PipelineCore/Infrastructure/ParsingHelpers.cs @@ -518,25 +518,6 @@ namespace Microsoft.AspNet.PipelineCore.Infrastructure request.HttpContext.Items[key] = value; } - internal static IDictionary GetCookies(HttpRequest request) - { - var cookies = GetItem>(request, "Microsoft.Owin.Cookies#dictionary"); - if (cookies == null) - { - cookies = new Dictionary(StringComparer.Ordinal); - SetItem(request, "Microsoft.Owin.Cookies#dictionary", cookies); - } - - string text = GetHeader(request.Headers, "Cookie"); - if (GetItem(request, "Microsoft.Owin.Cookies#text") != text) - { - cookies.Clear(); - ParseDelimited(text, SemicolonAndComma, AddCookieCallback, cookies); - SetItem(request, "Microsoft.Owin.Cookies#text", text); - } - return cookies; - } - internal static void ParseCookies(string cookiesHeader, IDictionary cookiesCollection) { ParseDelimited(cookiesHeader, SemicolonAndComma, AddCookieCallback, cookiesCollection); diff --git a/src/Microsoft.AspNet.PipelineCore/Microsoft.AspNet.PipelineCore.kproj b/src/Microsoft.AspNet.PipelineCore/Microsoft.AspNet.PipelineCore.kproj index 3a0556def8..96b305e9b6 100644 --- a/src/Microsoft.AspNet.PipelineCore/Microsoft.AspNet.PipelineCore.kproj +++ b/src/Microsoft.AspNet.PipelineCore/Microsoft.AspNet.PipelineCore.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.PipelineCore/project.json b/src/Microsoft.AspNet.PipelineCore/project.json index 6b1a0de735..8eaabc9a33 100644 --- a/src/Microsoft.AspNet.PipelineCore/project.json +++ b/src/Microsoft.AspNet.PipelineCore/project.json @@ -6,7 +6,9 @@ "Microsoft.AspNet.FeatureModel": "1.0.0-*", "Microsoft.AspNet.Http": "1.0.0-*", "Microsoft.AspNet.HttpFeature": "1.0.0-*", - "Microsoft.AspNet.WebUtilities": "1.0.0-*" + "Microsoft.AspNet.WebUtilities": "1.0.0-*", + "Microsoft.Net.Http.Headers": "1.0.0-*" + }, "frameworks": { "aspnet50": {}, diff --git a/src/Microsoft.AspNet.WebUtilities/FormReader.cs b/src/Microsoft.AspNet.WebUtilities/FormReader.cs index 7c6a034f78..1682e7c5b2 100644 --- a/src/Microsoft.AspNet.WebUtilities/FormReader.cs +++ b/src/Microsoft.AspNet.WebUtilities/FormReader.cs @@ -26,10 +26,9 @@ namespace Microsoft.AspNet.WebUtilities _reader = new StringReader(data); } - // TODO: Encoding - public FormReader([NotNull] Stream stream) + public FormReader([NotNull] Stream stream, [NotNull] Encoding encoding) { - _reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024 * 2, leaveOpen: true); + _reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, bufferSize: 1024 * 2, leaveOpen: true); } // Format: key1=value1&key2=value2 @@ -168,11 +167,21 @@ namespace Microsoft.AspNet.WebUtilities /// /// Parses an HTTP form body. /// - /// The HTTP form body to parse. + /// The HTTP form body to parse. /// The collection containing the parsed HTTP form body. - public static async Task> ReadFormAsync(Stream stream, CancellationToken cancellationToken = new CancellationToken()) + public static Task> ReadFormAsync(Stream stream, CancellationToken cancellationToken = new CancellationToken()) { - var reader = new FormReader(stream); + return ReadFormAsync(stream, Encoding.UTF8, cancellationToken); + } + + /// + /// Parses an HTTP form body. + /// + /// The HTTP form body to parse. + /// The collection containing the parsed HTTP form body. + public static async Task> ReadFormAsync(Stream stream, Encoding encoding, CancellationToken cancellationToken = new CancellationToken()) + { + var reader = new FormReader(stream, encoding); var accumulator = new KeyValueAccumulator(StringComparer.OrdinalIgnoreCase); var pair = await reader.ReadNextPairAsync(cancellationToken); diff --git a/src/Microsoft.AspNet.WebUtilities/Microsoft.AspNet.WebUtilities.kproj b/src/Microsoft.AspNet.WebUtilities/Microsoft.AspNet.WebUtilities.kproj index 75dd8975e7..efc8a50504 100644 --- a/src/Microsoft.AspNet.WebUtilities/Microsoft.AspNet.WebUtilities.kproj +++ b/src/Microsoft.AspNet.WebUtilities/Microsoft.AspNet.WebUtilities.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.WebUtilities/project.json b/src/Microsoft.AspNet.WebUtilities/project.json index 963fcc3178..e4e0e0cb08 100644 --- a/src/Microsoft.AspNet.WebUtilities/project.json +++ b/src/Microsoft.AspNet.WebUtilities/project.json @@ -2,15 +2,17 @@ "version": "1.0.0-*", "description": "ASP.NET 5 common helper methods such as URL encoding.", "dependencies": { - "Microsoft.AspNet.Http": "1.0.0-*" }, "frameworks": { "aspnet50": { }, "aspnetcore50": { "dependencies": { + "System.Collections": "4.0.10-beta-*", "System.Diagnostics.Debug": "4.0.10-beta-*", + "System.IO": "4.0.10-beta-*", "System.IO.FileSystem": "4.0.0-beta-*", - "System.Runtime": "4.0.20-beta-*" + "System.Runtime": "4.0.20-beta-*", + "System.Runtime.Extensions": "4.0.10-beta-*" } } } diff --git a/src/Microsoft.Net.Http.Headers/BaseHeaderParser.cs b/src/Microsoft.Net.Http.Headers/BaseHeaderParser.cs new file mode 100644 index 0000000000..679394e42d --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/BaseHeaderParser.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Net.Http.Headers +{ + internal abstract class BaseHeaderParser : HttpHeaderParser + { + protected BaseHeaderParser(bool supportsMultipleValues) + : base(supportsMultipleValues) + { + } + + protected abstract int GetParsedValueLength(string value, int startIndex, out T parsedValue); + + public sealed override bool TryParseValue(string value, ref int index, out T parsedValue) + { + parsedValue = default(T); + + // If multiple values are supported (i.e. list of values), then accept an empty string: The header may + // be added multiple times to the request/response message. E.g. + // Accept: text/xml; q=1 + // Accept: + // Accept: text/plain; q=0.2 + if (string.IsNullOrEmpty(value) || (index == value.Length)) + { + return SupportsMultipleValues; + } + + var separatorFound = false; + var current = HeaderUtilities.GetNextNonEmptyOrWhitespaceIndex(value, index, SupportsMultipleValues, + out separatorFound); + + if (separatorFound && !SupportsMultipleValues) + { + return false; // leading separators not allowed if we don't support multiple values. + } + + if (current == value.Length) + { + if (SupportsMultipleValues) + { + index = current; + } + return SupportsMultipleValues; + } + + T result = default(T); + var length = GetParsedValueLength(value, current, out result); + + if (length == 0) + { + return false; + } + + current = current + length; + current = HeaderUtilities.GetNextNonEmptyOrWhitespaceIndex(value, current, SupportsMultipleValues, + out separatorFound); + + // If we support multiple values and we've not reached the end of the string, then we must have a separator. + if ((separatorFound && !SupportsMultipleValues) || (!separatorFound && (current < value.Length))) + { + return false; + } + + index = current; + parsedValue = result; + return true; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/CacheControlHeaderValue.cs b/src/Microsoft.Net.Http.Headers/CacheControlHeaderValue.cs new file mode 100644 index 0000000000..1de23f206f --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/CacheControlHeaderValue.cs @@ -0,0 +1,603 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + public class CacheControlHeaderValue + { + private const string MaxAgeString = "max-age"; + private const string MaxStaleString = "max-stale"; + private const string MinFreshString = "min-fresh"; + private const string MustRevalidateString = "must-revalidate"; + private const string NoCacheString = "no-cache"; + private const string NoStoreString = "no-store"; + private const string NoTransformString = "no-transform"; + private const string OnlyIfCachedString = "only-if-cached"; + private const string PrivateString = "private"; + private const string ProxyRevalidateString = "proxy-revalidate"; + private const string PublicString = "public"; + private const string SharedMaxAgeString = "s-maxage"; + + // The Cache-Control header is special: It is a header supporting a list of values, but we represent the list + // as _one_ instance of CacheControlHeaderValue. I.e we set 'SupportsMultipleValues' to 'true' since it is + // OK to have multiple Cache-Control headers in a request/response message. However, after parsing all + // Cache-Control headers, only one instance of CacheControlHeaderValue is created (if all headers contain valid + // values, otherwise we may have multiple strings containing the invalid values). + private static readonly HttpHeaderParser Parser + = new GenericHeaderParser(true, GetCacheControlLength); + + private static readonly Action CheckIsValidTokenAction = CheckIsValidToken; + + private bool _noCache; + private ICollection _noCacheHeaders; + private bool _noStore; + private TimeSpan? _maxAge; + private TimeSpan? _sharedMaxAge; + private bool _maxStale; + private TimeSpan? _maxStaleLimit; + private TimeSpan? _minFresh; + private bool _noTransform; + private bool _onlyIfCached; + private bool _public; + private bool _private; + private ICollection _privateHeaders; + private bool _mustRevalidate; + private bool _proxyRevalidate; + private ICollection _extensions; + + public CacheControlHeaderValue() + { + // This type is unique in that there is no single required parameter. + } + + public bool NoCache + { + get { return _noCache; } + set { _noCache = value; } + } + + public ICollection NoCacheHeaders + { + get + { + if (_noCacheHeaders == null) + { + _noCacheHeaders = new ObjectCollection(CheckIsValidTokenAction); + } + return _noCacheHeaders; + } + } + + public bool NoStore + { + get { return _noStore; } + set { _noStore = value; } + } + + public TimeSpan? MaxAge + { + get { return _maxAge; } + set { _maxAge = value; } + } + + public TimeSpan? SharedMaxAge + { + get { return _sharedMaxAge; } + set { _sharedMaxAge = value; } + } + + public bool MaxStale + { + get { return _maxStale; } + set { _maxStale = value; } + } + + public TimeSpan? MaxStaleLimit + { + get { return _maxStaleLimit; } + set { _maxStaleLimit = value; } + } + + public TimeSpan? MinFresh + { + get { return _minFresh; } + set { _minFresh = value; } + } + + public bool NoTransform + { + get { return _noTransform; } + set { _noTransform = value; } + } + + public bool OnlyIfCached + { + get { return _onlyIfCached; } + set { _onlyIfCached = value; } + } + + public bool Public + { + get { return _public; } + set { _public = value; } + } + + public bool Private + { + get { return _private; } + set { _private = value; } + } + + public ICollection PrivateHeaders + { + get + { + if (_privateHeaders == null) + { + _privateHeaders = new ObjectCollection(CheckIsValidTokenAction); + } + return _privateHeaders; + } + } + + public bool MustRevalidate + { + get { return _mustRevalidate; } + set { _mustRevalidate = value; } + } + + public bool ProxyRevalidate + { + get { return _proxyRevalidate; } + set { _proxyRevalidate = value; } + } + + public ICollection Extensions + { + get + { + if (_extensions == null) + { + _extensions = new ObjectCollection(); + } + return _extensions; + } + } + + public override string ToString() + { + var sb = new StringBuilder(); + + AppendValueIfRequired(sb, _noStore, NoStoreString); + AppendValueIfRequired(sb, _noTransform, NoTransformString); + AppendValueIfRequired(sb, _onlyIfCached, OnlyIfCachedString); + AppendValueIfRequired(sb, _public, PublicString); + AppendValueIfRequired(sb, _mustRevalidate, MustRevalidateString); + AppendValueIfRequired(sb, _proxyRevalidate, ProxyRevalidateString); + + if (_noCache) + { + AppendValueWithSeparatorIfRequired(sb, NoCacheString); + if ((_noCacheHeaders != null) && (_noCacheHeaders.Count > 0)) + { + sb.Append("=\""); + AppendValues(sb, _noCacheHeaders); + sb.Append('\"'); + } + } + + if (_maxAge.HasValue) + { + AppendValueWithSeparatorIfRequired(sb, MaxAgeString); + sb.Append('='); + sb.Append(((int)_maxAge.Value.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo)); + } + + if (_sharedMaxAge.HasValue) + { + AppendValueWithSeparatorIfRequired(sb, SharedMaxAgeString); + sb.Append('='); + sb.Append(((int)_sharedMaxAge.Value.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo)); + } + + if (_maxStale) + { + AppendValueWithSeparatorIfRequired(sb, MaxStaleString); + if (_maxStaleLimit.HasValue) + { + sb.Append('='); + sb.Append(((int)_maxStaleLimit.Value.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo)); + } + } + + if (_minFresh.HasValue) + { + AppendValueWithSeparatorIfRequired(sb, MinFreshString); + sb.Append('='); + sb.Append(((int)_minFresh.Value.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo)); + } + + if (_private) + { + AppendValueWithSeparatorIfRequired(sb, PrivateString); + if ((_privateHeaders != null) && (_privateHeaders.Count > 0)) + { + sb.Append("=\""); + AppendValues(sb, _privateHeaders); + sb.Append('\"'); + } + } + + NameValueHeaderValue.ToString(_extensions, ',', false, sb); + + return sb.ToString(); + } + + public override bool Equals(object obj) + { + var other = obj as CacheControlHeaderValue; + + if (other == null) + { + return false; + } + + if ((_noCache != other._noCache) || (_noStore != other._noStore) || (_maxAge != other._maxAge) || + (_sharedMaxAge != other._sharedMaxAge) || (_maxStale != other._maxStale) || + (_maxStaleLimit != other._maxStaleLimit) || (_minFresh != other._minFresh) || + (_noTransform != other._noTransform) || (_onlyIfCached != other._onlyIfCached) || + (_public != other._public) || (_private != other._private) || + (_mustRevalidate != other._mustRevalidate) || (_proxyRevalidate != other._proxyRevalidate)) + { + return false; + } + + if (!HeaderUtilities.AreEqualCollections(_noCacheHeaders, other._noCacheHeaders, + StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + if (!HeaderUtilities.AreEqualCollections(_privateHeaders, other._privateHeaders, + StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + if (!HeaderUtilities.AreEqualCollections(_extensions, other._extensions)) + { + return false; + } + + return true; + } + + public override int GetHashCode() + { + // Use a different bit for bool fields: bool.GetHashCode() will return 0 (false) or 1 (true). So we would + // end up having the same hash code for e.g. two instances where one has only noCache set and the other + // only noStore. + int result = _noCache.GetHashCode() ^ (_noStore.GetHashCode() << 1) ^ (_maxStale.GetHashCode() << 2) ^ + (_noTransform.GetHashCode() << 3) ^ (_onlyIfCached.GetHashCode() << 4) ^ + (_public.GetHashCode() << 5) ^ (_private.GetHashCode() << 6) ^ + (_mustRevalidate.GetHashCode() << 7) ^ (_proxyRevalidate.GetHashCode() << 8); + + // XOR the hashcode of timespan values with different numbers to make sure two instances with the same + // timespan set on different fields result in different hashcodes. + result = result ^ (_maxAge.HasValue ? _maxAge.Value.GetHashCode() ^ 1 : 0) ^ + (_sharedMaxAge.HasValue ? _sharedMaxAge.Value.GetHashCode() ^ 2 : 0) ^ + (_maxStaleLimit.HasValue ? _maxStaleLimit.Value.GetHashCode() ^ 4 : 0) ^ + (_minFresh.HasValue ? _minFresh.Value.GetHashCode() ^ 8 : 0); + + if ((_noCacheHeaders != null) && (_noCacheHeaders.Count > 0)) + { + foreach (var noCacheHeader in _noCacheHeaders) + { + result = result ^ StringComparer.OrdinalIgnoreCase.GetHashCode(noCacheHeader); + } + } + + if ((_privateHeaders != null) && (_privateHeaders.Count > 0)) + { + foreach (var privateHeader in _privateHeaders) + { + result = result ^ StringComparer.OrdinalIgnoreCase.GetHashCode(privateHeader); + } + } + + if ((_extensions != null) && (_extensions.Count > 0)) + { + foreach (var extension in _extensions) + { + result = result ^ extension.GetHashCode(); + } + } + + return result; + } + + public static CacheControlHeaderValue Parse(string input) + { + int index = 0; + // Cache-Control is unusual because there are no required values so the parser will succeed for an empty string, but still return null. + var result = Parser.ParseValue(input, ref index); + if (result == null) + { + throw new FormatException("No cache directives found."); + } + return result; + } + + public static bool TryParse(string input, out CacheControlHeaderValue parsedValue) + { + int index = 0; + // Cache-Control is unusual because there are no required values so the parser will succeed for an empty string, but still return null. + if (Parser.TryParseValue(input, ref index, out parsedValue) && parsedValue != null) + { + return true; + } + parsedValue = null; + return false; + } + + private static int GetCacheControlLength(string input, int startIndex, out CacheControlHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Cache-Control header consists of a list of name/value pairs, where the value is optional. So use an + // instance of NameValueHeaderParser to parse the string. + var current = startIndex; + NameValueHeaderValue nameValue = null; + var nameValueList = new List(); + while (current < input.Length) + { + if (!NameValueHeaderValue.MultipleValueParser.TryParseValue(input, ref current, out nameValue)) + { + return 0; + } + + nameValueList.Add(nameValue as NameValueHeaderValue); + } + + // If we get here, we were able to successfully parse the string as list of name/value pairs. Now analyze + // the name/value pairs. + + // Cache-Control is a header supporting lists of values. However, expose the header as an instance of + // CacheControlHeaderValue. + var result = new CacheControlHeaderValue(); + + if (!TrySetCacheControlValues(result, nameValueList)) + { + return 0; + } + + parsedValue = result; + + // If we get here we successfully parsed the whole string. + return input.Length - startIndex; + } + + private static bool TrySetCacheControlValues(CacheControlHeaderValue cc, + List nameValueList) + { + foreach (NameValueHeaderValue nameValue in nameValueList) + { + var success = true; + string name = nameValue.Name.ToLowerInvariant(); + + switch (name) + { + case NoCacheString: + success = TrySetOptionalTokenList(nameValue, ref cc._noCache, ref cc._noCacheHeaders); + break; + + case NoStoreString: + success = TrySetTokenOnlyValue(nameValue, ref cc._noStore); + break; + + case MaxAgeString: + success = TrySetTimeSpan(nameValue, ref cc._maxAge); + break; + + case MaxStaleString: + success = ((nameValue.Value == null) || TrySetTimeSpan(nameValue, ref cc._maxStaleLimit)); + if (success) + { + cc._maxStale = true; + } + break; + + case MinFreshString: + success = TrySetTimeSpan(nameValue, ref cc._minFresh); + break; + + case NoTransformString: + success = TrySetTokenOnlyValue(nameValue, ref cc._noTransform); + break; + + case OnlyIfCachedString: + success = TrySetTokenOnlyValue(nameValue, ref cc._onlyIfCached); + break; + + case PublicString: + success = TrySetTokenOnlyValue(nameValue, ref cc._public); + break; + + case PrivateString: + success = TrySetOptionalTokenList(nameValue, ref cc._private, ref cc._privateHeaders); + break; + + case MustRevalidateString: + success = TrySetTokenOnlyValue(nameValue, ref cc._mustRevalidate); + break; + + case ProxyRevalidateString: + success = TrySetTokenOnlyValue(nameValue, ref cc._proxyRevalidate); + break; + + case SharedMaxAgeString: + success = TrySetTimeSpan(nameValue, ref cc._sharedMaxAge); + break; + + default: + cc.Extensions.Add(nameValue); // success is always true + break; + } + + if (!success) + { + return false; + } + } + + return true; + } + + private static bool TrySetTokenOnlyValue(NameValueHeaderValue nameValue, ref bool boolField) + { + if (nameValue.Value != null) + { + return false; + } + + boolField = true; + return true; + } + + private static bool TrySetOptionalTokenList(NameValueHeaderValue nameValue, ref bool boolField, + ref ICollection destination) + { + Contract.Requires(nameValue != null); + + if (nameValue.Value == null) + { + boolField = true; + return true; + } + + // We need the string to be at least 3 chars long: 2x quotes and at least 1 character. Also make sure we + // have a quoted string. Note that NameValueHeaderValue will never have leading/trailing whitespaces. + var valueString = nameValue.Value; + if ((valueString.Length < 3) || (valueString[0] != '\"') || (valueString[valueString.Length - 1] != '\"')) + { + return false; + } + + // We have a quoted string. Now verify that the string contains a list of valid tokens separated by ','. + var current = 1; // skip the initial '"' character. + var maxLength = valueString.Length - 1; // -1 because we don't want to parse the final '"'. + var separatorFound = false; + var originalValueCount = destination == null ? 0 : destination.Count; + while (current < maxLength) + { + current = HeaderUtilities.GetNextNonEmptyOrWhitespaceIndex(valueString, current, true, + out separatorFound); + + if (current == maxLength) + { + break; + } + + var tokenLength = HttpRuleParser.GetTokenLength(valueString, current); + + if (tokenLength == 0) + { + // We already skipped whitespaces and separators. If we don't have a token it must be an invalid + // character. + return false; + } + + if (destination == null) + { + destination = new ObjectCollection(CheckIsValidTokenAction); + } + + destination.Add(valueString.Substring(current, tokenLength)); + + current = current + tokenLength; + } + + // After parsing a valid token list, we expect to have at least one value + if ((destination != null) && (destination.Count > originalValueCount)) + { + boolField = true; + return true; + } + + return false; + } + + private static bool TrySetTimeSpan(NameValueHeaderValue nameValue, ref TimeSpan? timeSpan) + { + Contract.Requires(nameValue != null); + + if (nameValue.Value == null) + { + return false; + } + + int seconds; + if (!HeaderUtilities.TryParseInt32(nameValue.Value, out seconds)) + { + return false; + } + + timeSpan = new TimeSpan(0, 0, seconds); + + return true; + } + + private static void AppendValueIfRequired(StringBuilder sb, bool appendValue, string value) + { + if (appendValue) + { + AppendValueWithSeparatorIfRequired(sb, value); + } + } + + private static void AppendValueWithSeparatorIfRequired(StringBuilder sb, string value) + { + if (sb.Length > 0) + { + sb.Append(", "); + } + sb.Append(value); + } + + private static void AppendValues(StringBuilder sb, IEnumerable values) + { + var first = true; + foreach (string value in values) + { + if (first) + { + first = false; + } + else + { + sb.Append(", "); + } + + sb.Append(value); + } + } + + private static void CheckIsValidToken(string item) + { + HeaderUtilities.CheckValidToken(item, "item"); + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/ContentDispositionHeaderValue.cs b/src/Microsoft.Net.Http.Headers/ContentDispositionHeaderValue.cs new file mode 100644 index 0000000000..8a96da2efd --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/ContentDispositionHeaderValue.cs @@ -0,0 +1,702 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + // Note this is for use both in HTTP (https://tools.ietf.org/html/rfc6266) and MIME (https://tools.ietf.org/html/rfc2183) + public class ContentDispositionHeaderValue + { + private const string FileNameString = "filename"; + private const string NameString = "name"; + private const string FileNameStarString = "filename*"; + private const string CreationDateString = "creation-date"; + private const string ModificationDateString = "modification-date"; + private const string ReadDateString = "read-date"; + private const string SizeString = "size"; + + private static readonly HttpHeaderParser Parser + = new GenericHeaderParser(false, GetDispositionTypeLength); + + // Use list instead of dictionary since we may have multiple parameters with the same name. + private ICollection _parameters; + private string _dispositionType; + + private ContentDispositionHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public ContentDispositionHeaderValue(string dispositionType) + { + CheckDispositionTypeFormat(dispositionType, "dispositionType"); + _dispositionType = dispositionType; + } + + public string DispositionType + { + get { return _dispositionType; } + set + { + CheckDispositionTypeFormat(value, "value"); + _dispositionType = value; + } + } + + public ICollection Parameters + { + get + { + if (_parameters == null) + { + _parameters = new ObjectCollection(); + } + return _parameters; + } + } + + // Helpers to access specific parameters in the list + + public string Name + { + get { return GetName(NameString); } + set { SetName(NameString, value); } + } + + public string FileName + { + get { return GetName(FileNameString); } + set { SetName(FileNameString, value); } + } + + public string FileNameStar + { + get { return GetName(FileNameStarString); } + set { SetName(FileNameStarString, value); } + } + + public DateTimeOffset? CreationDate + { + get { return GetDate(CreationDateString); } + set { SetDate(CreationDateString, value); } + } + + public DateTimeOffset? ModificationDate + { + get { return GetDate(ModificationDateString); } + set { SetDate(ModificationDateString, value); } + } + + public DateTimeOffset? ReadDate + { + get { return GetDate(ReadDateString); } + set { SetDate(ReadDateString, value); } + } + + public long? Size + { + get + { + var sizeParameter = NameValueHeaderValue.Find(_parameters, SizeString); + ulong value; + if (sizeParameter != null) + { + string sizeString = sizeParameter.Value; + if (UInt64.TryParse(sizeString, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + return (long)value; + } + } + return null; + } + set + { + var sizeParameter = NameValueHeaderValue.Find(_parameters, SizeString); + if (value == null) + { + // Remove parameter + if (sizeParameter != null) + { + _parameters.Remove(sizeParameter); + } + } + else if (value < 0) + { + throw new ArgumentOutOfRangeException("value"); + } + else if (sizeParameter != null) + { + sizeParameter.Value = value.Value.ToString(CultureInfo.InvariantCulture); + } + else + { + string sizeString = value.Value.ToString(CultureInfo.InvariantCulture); + _parameters.Add(new NameValueHeaderValue(SizeString, sizeString)); + } + } + } + + /// + /// Sets both FileName and FileNameStar using encodings appropriate for HTTP headers. + /// + /// + public void SetHttpFileName(string fileName) + { + if (!string.IsNullOrEmpty(fileName)) + { + FileName = Sanatize(fileName); + } + else + { + FileName = fileName; + } + FileNameStar = fileName; + } + + /// + /// Sets the FileName parameter using encodings appropriate for MIME headers. + /// The FileNameStar paraemter is removed. + /// + /// + public void SetMimeFileName(string fileName) + { + FileNameStar = null; + FileName = fileName; + } + + public override string ToString() + { + return _dispositionType + NameValueHeaderValue.ToString(_parameters, ';', true); + } + + public override bool Equals(object obj) + { + var other = obj as ContentDispositionHeaderValue; + + if (other == null) + { + return false; + } + + return (string.Compare(_dispositionType, other._dispositionType, StringComparison.OrdinalIgnoreCase) == 0) && + HeaderUtilities.AreEqualCollections(_parameters, other._parameters); + } + + public override int GetHashCode() + { + // The dispositionType string is case-insensitive. + return StringComparer.OrdinalIgnoreCase.GetHashCode(_dispositionType) ^ NameValueHeaderValue.GetHashCode(_parameters); + } + + public static ContentDispositionHeaderValue Parse(string input) + { + var index = 0; + return Parser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out ContentDispositionHeaderValue parsedValue) + { + var index = 0; + return Parser.TryParseValue(input, ref index, out parsedValue); + } + + private static int GetDispositionTypeLength(string input, int startIndex, out ContentDispositionHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Caller must remove leading whitespaces. If not, we'll return 0. + string dispositionType = null; + var dispositionTypeLength = GetDispositionTypeExpressionLength(input, startIndex, out dispositionType); + + if (dispositionTypeLength == 0) + { + return 0; + } + + var current = startIndex + dispositionTypeLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + var contentDispositionHeader = new ContentDispositionHeaderValue(); + contentDispositionHeader._dispositionType = dispositionType; + + // If we're not done and we have a parameter delimiter, then we have a list of parameters. + if ((current < input.Length) && (input[current] == ';')) + { + current++; // skip delimiter. + int parameterLength = NameValueHeaderValue.GetNameValueListLength(input, current, ';', + contentDispositionHeader.Parameters); + + parsedValue = contentDispositionHeader; + return current + parameterLength - startIndex; + } + + // We have a ContentDisposition header without parameters. + parsedValue = contentDispositionHeader; + return current - startIndex; + } + + private static int GetDispositionTypeExpressionLength(string input, int startIndex, out string dispositionType) + { + Contract.Requires((input != null) && (input.Length > 0) && (startIndex < input.Length)); + + // This method just parses the disposition type string, it does not parse parameters. + dispositionType = null; + + // Parse the disposition type, i.e. in content-disposition string + // "; param1=value1; param2=value2" + var typeLength = HttpRuleParser.GetTokenLength(input, startIndex); + + if (typeLength == 0) + { + return 0; + } + + dispositionType = input.Substring(startIndex, typeLength); + return typeLength; + } + + private static void CheckDispositionTypeFormat(string dispositionType, string parameterName) + { + if (string.IsNullOrEmpty(dispositionType)) + { + throw new ArgumentException("An empty string is not allowed.", parameterName); + } + + // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed. + string tempDispositionType; + var dispositionTypeLength = GetDispositionTypeExpressionLength(dispositionType, 0, out tempDispositionType); + if ((dispositionTypeLength == 0) || (tempDispositionType.Length != dispositionType.Length)) + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, + "Invalid disposition type '{0}'.", dispositionType)); + } + } + + // Gets a parameter of the given name and attempts to extract a date. + // Returns null if the parameter is not present or the format is incorrect. + private DateTimeOffset? GetDate(string parameter) + { + var dateParameter = NameValueHeaderValue.Find(_parameters, parameter); + if (dateParameter != null) + { + string dateString = dateParameter.Value; + // Should have quotes, remove them. + if (IsQuoted(dateString)) + { + dateString = dateString.Substring(1, dateString.Length - 2); + } + DateTimeOffset date; + if (HttpRuleParser.TryStringToDate(dateString, out date)) + { + return date; + } + } + return null; + } + + // Add the given parameter to the list. Remove if date is null. + private void SetDate(string parameter, DateTimeOffset? date) + { + var dateParameter = NameValueHeaderValue.Find(_parameters, parameter); + if (date == null) + { + // Remove parameter + if (dateParameter != null) + { + _parameters.Remove(dateParameter); + } + } + else + { + // Must always be quoted + var dateString = string.Format(CultureInfo.InvariantCulture, "\"{0}\"", HttpRuleParser.DateToString(date.Value)); + if (dateParameter != null) + { + dateParameter.Value = dateString; + } + else + { + Parameters.Add(new NameValueHeaderValue(parameter, dateString)); + } + } + } + + // Gets a parameter of the given name and attempts to decode it if necessary. + // Returns null if the parameter is not present or the raw value if the encoding is incorrect. + private string GetName(string parameter) + { + var nameParameter = NameValueHeaderValue.Find(_parameters, parameter); + if (nameParameter != null) + { + string result; + // filename*=utf-8'lang'%7FMyString + if (parameter.EndsWith("*", StringComparison.Ordinal)) + { + if (TryDecode5987(nameParameter.Value, out result)) + { + return result; + } + return null; // Unrecognized encoding + } + + // filename="=?utf-8?B?BDFSDFasdfasdc==?=" + if (TryDecodeMime(nameParameter.Value, out result)) + { + return result; + } + // May not have been encoded + return nameParameter.Value; + } + return null; + } + + // Add/update the given parameter in the list, encoding if necessary. + // Remove if value is null/Empty + private void SetName(string parameter, string value) + { + var nameParameter = NameValueHeaderValue.Find(_parameters, parameter); + if (string.IsNullOrEmpty(value)) + { + // Remove parameter + if (nameParameter != null) + { + _parameters.Remove(nameParameter); + } + } + else + { + var processedValue = string.Empty; + if (parameter.EndsWith("*", StringComparison.Ordinal)) + { + processedValue = Encode5987(value); + } + else + { + processedValue = EncodeAndQuoteMime(value); + } + + if (nameParameter != null) + { + nameParameter.Value = processedValue; + } + else + { + Parameters.Add(new NameValueHeaderValue(parameter, processedValue)); + } + } + } + + // Returns input for decoding failures, as the content might not be encoded + private string EncodeAndQuoteMime(string input) + { + var result = input; + var needsQuotes = false; + // Remove bounding quotes, they'll get re-added later + if (IsQuoted(result)) + { + result = result.Substring(1, result.Length - 2); + needsQuotes = true; + } + + if (RequiresEncoding(result)) + { + needsQuotes = true; // Encoded data must always be quoted, the equals signs are invalid in tokens + result = EncodeMime(result); // =?utf-8?B?asdfasdfaesdf?= + } + else if (!needsQuotes && HttpRuleParser.GetTokenLength(result, 0) != result.Length) + { + needsQuotes = true; + } + + if (needsQuotes) + { + // '\' and '"' must be escaped in a quoted string + result = result.Replace(@"\", @"\\"); + result = result.Replace(@"""", @"\"""); + // Re-add quotes "value" + result = string.Format(CultureInfo.InvariantCulture, "\"{0}\"", result); + } + return result; + } + + // Replaces characters not suitable for HTTP headers with '_' rather than MIME encoding them. + private string Sanatize(string input) + { + var result = input; + + if (RequiresEncoding(result)) + { + var builder = new StringBuilder(result.Length); + for (int i = 0; i < result.Length; i++) + { + var c = result[i]; + if ((int)c > 0x7f) + { + c = '_'; // Replace out-of-range characters + } + builder.Append(c); + } + result = builder.ToString(); + } + + return result; + } + + // Returns true if the value starts and ends with a quote + private bool IsQuoted(string value) + { + Contract.Assert(value != null); + + return value.Length > 1 && value.StartsWith("\"", StringComparison.Ordinal) + && value.EndsWith("\"", StringComparison.Ordinal); + } + + // tspecials are required to be in a quoted string. Only non-ascii needs to be encoded. + private bool RequiresEncoding(string input) + { + Contract.Assert(input != null); + + foreach (char c in input) + { + if ((int)c > 0x7f) + { + return true; + } + } + return false; + } + + // Encode using MIME encoding + private string EncodeMime(string input) + { + var buffer = Encoding.UTF8.GetBytes(input); + var encodedName = Convert.ToBase64String(buffer); + return string.Format(CultureInfo.InvariantCulture, "=?utf-8?B?{0}?=", encodedName); + } + + // Attempt to decode MIME encoded strings + private bool TryDecodeMime(string input, out string output) + { + Contract.Assert(input != null); + + output = null; + var processedInput = input; + // Require quotes, min of "=?e?b??=" + if (!IsQuoted(processedInput) || processedInput.Length < 10) + { + return false; + } + var parts = processedInput.Split('?'); + // "=, encodingName, encodingType, encodedData, =" + if (parts.Length != 5 || parts[0] != "\"=" || parts[4] != "=\"" || parts[2].ToLowerInvariant() != "b") + { + // Not encoded. + // This does not support multi-line encoding. + // Only base64 encoding is supported, not quoted printable + return false; + } + + try + { + var encoding = Encoding.GetEncoding(parts[1]); + var bytes = Convert.FromBase64String(parts[3]); + output = encoding.GetString(bytes); + return true; + } + catch (ArgumentException) + { + // Unknown encoding or bad characters + } + catch (FormatException) + { + // Bad base64 decoding + } + return false; + } + + // Encode a string using RFC 5987 encoding + // encoding'lang'PercentEncodedSpecials + private string Encode5987(string input) + { + var builder = new StringBuilder("UTF-8\'\'"); + foreach (char c in input) + { + // attr-char = ALPHA / DIGIT / "!" / "#" / "$" / "&" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // ; token except ( "*" / "'" / "%" ) + if (c > 0x7F) // Encodes as multiple utf-8 bytes + { + var bytes = Encoding.UTF8.GetBytes(c.ToString()); + foreach (byte b in bytes) + { + HexEscape(builder, (char)b); + } + } + else if (!HttpRuleParser.IsTokenChar(c) || c == '*' || c == '\'' || c == '%') + { + // ASCII - Only one encoded byte + HexEscape(builder, c); + } + else + { + builder.Append(c); + } + } + return builder.ToString(); + } + + private static readonly char[] HexUpperChars = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' }; + + private static void HexEscape(StringBuilder builder, char c) + { + builder.Append('%'); + builder.Append(HexUpperChars[(c & 0xf0) >> 4]); + builder.Append(HexUpperChars[c & 0xf]); + } + + // Attempt to decode using RFC 5987 encoding. + // encoding'language'my%20string + private bool TryDecode5987(string input, out string output) + { + output = null; + var parts = input.Split('\''); + if (parts.Length != 3) + { + return false; + } + + var decoded = new StringBuilder(); + try + { + var encoding = Encoding.GetEncoding(parts[0]); + + var dataString = parts[2]; + var unescapedBytes = new byte[dataString.Length]; + var unescapedBytesCount = 0; + for (var index = 0; index < dataString.Length; index++) + { + if (IsHexEncoding(dataString, index)) // %FF + { + // Unescape and cache bytes, multi-byte characters must be decoded all at once + unescapedBytes[unescapedBytesCount++] = HexUnescape(dataString, ref index); + index--; // HexUnescape did +=3; Offset the for loop's ++ + } + else + { + if (unescapedBytesCount > 0) + { + // Decode any previously cached bytes + decoded.Append(encoding.GetString(unescapedBytes, 0, unescapedBytesCount)); + unescapedBytesCount = 0; + } + decoded.Append(dataString[index]); // Normal safe character + } + } + + if (unescapedBytesCount > 0) + { + // Decode any previously cached bytes + decoded.Append(encoding.GetString(unescapedBytes, 0, unescapedBytesCount)); + } + } + catch (ArgumentException) + { + return false; // Unknown encoding or bad characters + } + + output = decoded.ToString(); + return true; + } + + private static bool IsHexEncoding(string pattern, int index) + { + if ((pattern.Length - index) < 3) + { + return false; + } + if ((pattern[index] == '%') && IsEscapedAscii(pattern[index + 1], pattern[index + 2])) + { + return true; + } + return false; + } + + private static bool IsEscapedAscii(char digit, char next) + { + if (!(((digit >= '0') && (digit <= '9')) + || ((digit >= 'A') && (digit <= 'F')) + || ((digit >= 'a') && (digit <= 'f')))) + { + return false; + } + + if (!(((next >= '0') && (next <= '9')) + || ((next >= 'A') && (next <= 'F')) + || ((next >= 'a') && (next <= 'f')))) + { + return false; + } + + return true; + } + + private static byte HexUnescape(string pattern, ref int index) + { + if ((index < 0) || (index >= pattern.Length)) + { + throw new ArgumentOutOfRangeException("index"); + } + if ((pattern[index] == '%') + && (pattern.Length - index >= 3)) + { + var ret = UnEscapeAscii(pattern[index + 1], pattern[index + 2]); + index += 3; + return ret; + } + return (byte)pattern[index++]; + } + + internal static byte UnEscapeAscii(char digit, char next) + { + if (!(((digit >= '0') && (digit <= '9')) + || ((digit >= 'A') && (digit <= 'F')) + || ((digit >= 'a') && (digit <= 'f')))) + { + throw new ArgumentException(); + } + + var res = (digit <= '9') + ? ((int)digit - (int)'0') + : (((digit <= 'F') + ? ((int)digit - (int)'A') + : ((int)digit - (int)'a')) + + 10); + + if (!(((next >= '0') && (next <= '9')) + || ((next >= 'A') && (next <= 'F')) + || ((next >= 'a') && (next <= 'f')))) + { + throw new ArgumentException(); + } + + return (byte)((res << 4) + ((next <= '9') + ? ((int)next - (int)'0') + : (((next <= 'F') + ? ((int)next - (int)'A') + : ((int)next - (int)'a')) + + 10))); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/ContentRangeHeaderValue.cs b/src/Microsoft.Net.Http.Headers/ContentRangeHeaderValue.cs new file mode 100644 index 0000000000..53fd996b9f --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/ContentRangeHeaderValue.cs @@ -0,0 +1,397 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + public class ContentRangeHeaderValue + { + private static readonly HttpHeaderParser Parser + = new GenericHeaderParser(false, GetContentRangeLength); + + private string _unit; + private long? _from; + private long? _to; + private long? _length; + + private ContentRangeHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public ContentRangeHeaderValue(long from, long to, long length) + { + // Scenario: "Content-Range: bytes 12-34/5678" + + if (length < 0) + { + throw new ArgumentOutOfRangeException("length"); + } + if ((to < 0) || (to > length)) + { + throw new ArgumentOutOfRangeException("to"); + } + if ((from < 0) || (from > to)) + { + throw new ArgumentOutOfRangeException("from"); + } + + _from = from; + _to = to; + _length = length; + _unit = HeaderUtilities.BytesUnit; + } + + public ContentRangeHeaderValue(long length) + { + // Scenario: "Content-Range: bytes */1234" + + if (length < 0) + { + throw new ArgumentOutOfRangeException("length"); + } + + _length = length; + _unit = HeaderUtilities.BytesUnit; + } + + public ContentRangeHeaderValue(long from, long to) + { + // Scenario: "Content-Range: bytes 12-34/*" + + if (to < 0) + { + throw new ArgumentOutOfRangeException("to"); + } + if ((from < 0) || (from > to)) + { + throw new ArgumentOutOfRangeException("from"); + } + + _from = from; + _to = to; + _unit = HeaderUtilities.BytesUnit; + } + + public string Unit + { + get { return _unit; } + set + { + HeaderUtilities.CheckValidToken(value, "value"); + _unit = value; + } + } + + public long? From + { + get { return _from; } + } + + public long? To + { + get { return _to; } + } + + public long? Length + { + get { return _length; } + } + + public bool HasLength // e.g. "Content-Range: bytes 12-34/*" + { + get { return _length != null; } + } + + public bool HasRange // e.g. "Content-Range: bytes */1234" + { + get { return _from != null; } + } + + public override bool Equals(object obj) + { + var other = obj as ContentRangeHeaderValue; + + if (other == null) + { + return false; + } + + return ((_from == other._from) && (_to == other._to) && (_length == other._length) && + (string.Compare(_unit, other._unit, StringComparison.OrdinalIgnoreCase) == 0)); + } + + public override int GetHashCode() + { + var result = StringComparer.OrdinalIgnoreCase.GetHashCode(_unit); + + if (HasRange) + { + result = result ^ _from.GetHashCode() ^ _to.GetHashCode(); + } + + if (HasLength) + { + result = result ^ _length.GetHashCode(); + } + + return result; + } + + public override string ToString() + { + var sb = new StringBuilder(_unit); + sb.Append(' '); + + if (HasRange) + { + sb.Append(_from.Value.ToString(NumberFormatInfo.InvariantInfo)); + sb.Append('-'); + sb.Append(_to.Value.ToString(NumberFormatInfo.InvariantInfo)); + } + else + { + sb.Append('*'); + } + + sb.Append('/'); + if (HasLength) + { + sb.Append(_length.Value.ToString(NumberFormatInfo.InvariantInfo)); + } + else + { + sb.Append('*'); + } + + return sb.ToString(); + } + + public static ContentRangeHeaderValue Parse(string input) + { + var index = 0; + return Parser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out ContentRangeHeaderValue parsedValue) + { + var index = 0; + return Parser.TryParseValue(input, ref index, out parsedValue); + } + + private static int GetContentRangeLength(string input, int startIndex, out ContentRangeHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Parse the unit string: in ' -/' + var unitLength = HttpRuleParser.GetTokenLength(input, startIndex); + + if (unitLength == 0) + { + return 0; + } + + var unit = input.Substring(startIndex, unitLength); + var current = startIndex + unitLength; + var separatorLength = HttpRuleParser.GetWhitespaceLength(input, current); + + if (separatorLength == 0) + { + return 0; + } + + current = current + separatorLength; + + if (current == input.Length) + { + return 0; + } + + // Read range values and in ' -/' + var fromStartIndex = current; + var fromLength = 0; + var toStartIndex = 0; + var toLength = 0; + if (!TryGetRangeLength(input, ref current, out fromLength, out toStartIndex, out toLength)) + { + return 0; + } + + // After the range is read we expect the length separator '/' + if ((current == input.Length) || (input[current] != '/')) + { + return 0; + } + + current++; // Skip '/' separator + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if (current == input.Length) + { + return 0; + } + + // We may not have a length (e.g. 'bytes 1-2/*'). But if we do, parse the length now. + var lengthStartIndex = current; + var lengthLength = 0; + if (!TryGetLengthLength(input, ref current, out lengthLength)) + { + return 0; + } + + if (!TryCreateContentRange(input, unit, fromStartIndex, fromLength, toStartIndex, toLength, + lengthStartIndex, lengthLength, out parsedValue)) + { + return 0; + } + + return current - startIndex; + } + + private static bool TryGetLengthLength(string input, ref int current, out int lengthLength) + { + lengthLength = 0; + + if (input[current] == '*') + { + current++; + } + else + { + // Parse length value: in ' -/' + lengthLength = HttpRuleParser.GetNumberLength(input, current, false); + + if ((lengthLength == 0) || (lengthLength > HttpRuleParser.MaxInt64Digits)) + { + return false; + } + + current = current + lengthLength; + } + + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + return true; + } + + private static bool TryGetRangeLength(string input, ref int current, out int fromLength, out int toStartIndex, out int toLength) + { + fromLength = 0; + toStartIndex = 0; + toLength = 0; + + // Check if we have a value like 'bytes */133'. If yes, skip the range part and continue parsing the + // length separator '/'. + if (input[current] == '*') + { + current++; + } + else + { + // Parse first range value: in ' -/' + fromLength = HttpRuleParser.GetNumberLength(input, current, false); + + if ((fromLength == 0) || (fromLength > HttpRuleParser.MaxInt64Digits)) + { + return false; + } + + current = current + fromLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // After the first value, the '-' character must follow. + if ((current == input.Length) || (input[current] != '-')) + { + // We need a '-' character otherwise this can't be a valid range. + return false; + } + + current++; // skip the '-' character + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if (current == input.Length) + { + return false; + } + + // Parse second range value: in ' -/' + toStartIndex = current; + toLength = HttpRuleParser.GetNumberLength(input, current, false); + + if ((toLength == 0) || (toLength > HttpRuleParser.MaxInt64Digits)) + { + return false; + } + + current = current + toLength; + } + + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + return true; + } + + private static bool TryCreateContentRange(string input, string unit, int fromStartIndex, int fromLength, + int toStartIndex, int toLength, int lengthStartIndex, int lengthLength, out ContentRangeHeaderValue parsedValue) + { + parsedValue = null; + + long from = 0; + if ((fromLength > 0) && !HeaderUtilities.TryParseInt64(input.Substring(fromStartIndex, fromLength), out from)) + { + return false; + } + + long to = 0; + if ((toLength > 0) && !HeaderUtilities.TryParseInt64(input.Substring(toStartIndex, toLength), out to)) + { + return false; + } + + // 'from' must not be greater than 'to' + if ((fromLength > 0) && (toLength > 0) && (from > to)) + { + return false; + } + + long length = 0; + if ((lengthLength > 0) && !HeaderUtilities.TryParseInt64(input.Substring(lengthStartIndex, lengthLength), + out length)) + { + return false; + } + + // 'from' and 'to' must be less than 'length' + if ((toLength > 0) && (lengthLength > 0) && (to >= length)) + { + return false; + } + + var result = new ContentRangeHeaderValue(); + result._unit = unit; + + if (fromLength > 0) + { + result._from = from; + result._to = to; + } + + if (lengthLength > 0) + { + result._length = length; + } + + parsedValue = result; + return true; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/CookieHeaderParser.cs b/src/Microsoft.Net.Http.Headers/CookieHeaderParser.cs new file mode 100644 index 0000000000..7c5be8753c --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/CookieHeaderParser.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics.Contracts; + +namespace Microsoft.Net.Http.Headers +{ + internal class CookieHeaderParser : HttpHeaderParser + { + // The Cache-Control header is special: It is a header supporting a list of values, but we represent the list + // as _one_ instance of CacheControlHeaderValue. I.e we set 'SupportsMultipleValues' to 'true' since it is + // OK to have multiple Cache-Control headers in a request/response message. However, after parsing all + // Cache-Control headers, only one instance of CacheControlHeaderValue is created (if all headers contain valid + // values, otherwise we may have multiple strings containing the invalid values). + internal CookieHeaderParser(bool supportsMultipleValues) + : base(supportsMultipleValues) + { + } + + public sealed override bool TryParseValue(string value, ref int index, out CookieHeaderValue parsedValue) + { + parsedValue = null; + + // If multiple values are supported (i.e. list of values), then accept an empty string: The header may + // be added multiple times to the request/response message. E.g. + // Accept: text/xml; q=1 + // Accept: + // Accept: text/plain; q=0.2 + if (string.IsNullOrEmpty(value) || (index == value.Length)) + { + return SupportsMultipleValues; + } + + var separatorFound = false; + var current = GetNextNonEmptyOrWhitespaceIndex(value, index, SupportsMultipleValues, out separatorFound); + + if (separatorFound && !SupportsMultipleValues) + { + return false; // leading separators not allowed if we don't support multiple values. + } + + if (current == value.Length) + { + if (SupportsMultipleValues) + { + index = current; + } + return SupportsMultipleValues; + } + + CookieHeaderValue result = null; + int length = CookieHeaderValue.GetCookieLength(value, current, out result); + + if (length == 0) + { + return false; + } + + current = current + length; + current = GetNextNonEmptyOrWhitespaceIndex(value, current, SupportsMultipleValues, out separatorFound); + + // If we support multiple values and we've not reached the end of the string, then we must have a separator. + if ((separatorFound && !SupportsMultipleValues) || (!separatorFound && (current < value.Length))) + { + return false; + } + + index = current; + parsedValue = result; + return true; + } + + private static int GetNextNonEmptyOrWhitespaceIndex(string input, int startIndex, bool skipEmptyValues, out bool separatorFound) + { + Contract.Requires(input != null); + Contract.Requires(startIndex <= input.Length); // it's OK if index == value.Length. + + separatorFound = false; + var current = startIndex + HttpRuleParser.GetWhitespaceLength(input, startIndex); + + if ((current == input.Length) || (input[current] != ',' && input[current] != ';')) + { + return current; + } + + // If we have a separator, skip the separator and all following whitespaces. If we support + // empty values, continue until the current character is neither a separator nor a whitespace. + separatorFound = true; + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if (skipEmptyValues) + { + while ((current < input.Length) && (input[current] == ',') && (input[current] == ';')) + { + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + } + } + + return current; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/CookieHeaderValue.cs b/src/Microsoft.Net.Http.Headers/CookieHeaderValue.cs new file mode 100644 index 0000000000..c4c1207380 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/CookieHeaderValue.cs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + // http://tools.ietf.org/html/rfc6265 + public class CookieHeaderValue + { + private static readonly CookieHeaderParser SingleValueParser = new CookieHeaderParser(supportsMultipleValues: false); + private static readonly CookieHeaderParser MultipleValueParser = new CookieHeaderParser(supportsMultipleValues: true); + + private string _name; + private string _value; + + private CookieHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public CookieHeaderValue([NotNull] string name) + : this(name, string.Empty) + { + } + + public CookieHeaderValue([NotNull] string name, [NotNull] string value) + { + Name = name; + Value = value; + } + + public string Name + { + get { return _name; } + set + { + CheckNameFormat(value, "name"); + _name = value; + } + } + + public string Value + { + get { return _value; } + set + { + CheckValueFormat(value, "value"); + _value = value; + } + } + + // name="val ue"; + public override string ToString() + { + var header = new StringBuilder(); + + header.Append(_name); + header.Append("="); + header.Append(_value); + + return header.ToString(); + } + + private static void AppendSegment(StringBuilder builder, string name, string value) + { + builder.Append("; "); + builder.Append(name); + if (value != null) + { + builder.Append("="); + builder.Append(value); + } + } + + public static CookieHeaderValue Parse(string input) + { + var index = 0; + return SingleValueParser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out CookieHeaderValue parsedValue) + { + var index = 0; + return SingleValueParser.TryParseValue(input, ref index, out parsedValue); + } + + public static IList ParseList(IList inputs) + { + return MultipleValueParser.ParseValues(inputs); + } + + public static bool TryParseList(IList inputs, out IList parsedValues) + { + return MultipleValueParser.TryParseValues(inputs, out parsedValues); + } + + // name=value; name="value" + internal static int GetCookieLength(string input, int startIndex, out CookieHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + var offset = startIndex; + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (offset >= input.Length)) + { + return 0; + } + + var result = new CookieHeaderValue(); + + // The caller should have already consumed any leading whitespace, commas, etc.. + + // Name=value; + + // Name + var itemLength = HttpRuleParser.GetTokenLength(input, offset); + if (itemLength == 0) + { + return 0; + } + result._name = input.Substring(offset, itemLength); + offset += itemLength; + + // = (no spaces) + if (!ReadEqualsSign(input, ref offset)) + { + return 0; + } + + string value; + // value or "quoted value" + itemLength = GetCookieValueLength(input, offset, out value); + // The value may be empty + result._value = input.Substring(offset, itemLength); + offset += itemLength; + + parsedValue = result; + return offset - startIndex; + } + + // cookie-value = *cookie-octet / ( DQUOTE* cookie-octet DQUOTE ) + // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + // ; US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, and backslash + internal static int GetCookieValueLength(string input, int startIndex, out string value) + { + Contract.Requires(input != null); + Contract.Requires(startIndex >= 0); + Contract.Ensures((Contract.Result() >= 0) && (Contract.Result() <= (input.Length - startIndex))); + + value = null; + if (startIndex >= input.Length) + { + return 0; + } + var inQuotes = false; + var offset = startIndex; + + if (input[offset] == '"') + { + inQuotes = true; + offset++; + } + + while (offset < input.Length) + { + var c = input[offset]; + if (!IsCookieValueChar(c)) + { + break; + } + + offset++; + } + + if (inQuotes) + { + if (offset == input.Length || input[offset] != '"') + { + return 0; // Missing final quote + } + offset++; + } + + int length = offset - startIndex; + if (length == 0) + { + return 0; + } + + value = input.Substring(startIndex, length); + return length; + } + + private static bool ReadEqualsSign(string input, ref int offset) + { + // = (no spaces) + if (offset >= input.Length || input[offset] != '=') + { + return false; + } + offset++; + return true; + } + + // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + // ; US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, and backslash + private static bool IsCookieValueChar(char c) + { + if (c < 0x21 || c > 0x7E) + { + return false; + } + return !(c == '"' || c == ',' || c == ';' || c == '\\'); + } + + internal static void CheckNameFormat([NotNull] string name, string parameterName) + { + if (HttpRuleParser.GetTokenLength(name, 0) != name.Length) + { + throw new ArgumentException("Invalid cookie name: " + name, parameterName); + } + } + + internal static void CheckValueFormat([NotNull] string value, string parameterName) + { + string temp; + if (GetCookieValueLength(value, 0, out temp) != value.Length) + { + throw new ArgumentException("Invalid cookie value: " + value, parameterName); + } + } + + public override bool Equals(object obj) + { + var other = obj as CookieHeaderValue; + + if (other == null) + { + return false; + } + + return string.Equals(_name, other._name, StringComparison.OrdinalIgnoreCase) + && string.Equals(_value, other._value, StringComparison.OrdinalIgnoreCase); + } + + public override int GetHashCode() + { + return _name.GetHashCode() ^ _value.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/EntityTagHeaderValue.cs b/src/Microsoft.Net.Http.Headers/EntityTagHeaderValue.cs new file mode 100644 index 0000000000..af10ff345e --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/EntityTagHeaderValue.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; + +namespace Microsoft.Net.Http.Headers +{ + public class EntityTagHeaderValue + { + // Note that the ETag header does not allow a * but we're not that strict: We allow both '*' and ETag values in a single value. + // We can't guarantee that a single parsed value will be used directly in an ETag header. + private static readonly HttpHeaderParser SingleValueParser + = new GenericHeaderParser(false, GetEntityTagLength); + // Note that if multiple ETag values are allowed (e.g. 'If-Match', 'If-None-Match'), according to the RFC + // the value must either be '*' or a list of ETag values. It's not allowed to have both '*' and a list of + // ETag values. We're not that strict: We allow both '*' and ETag values in a list. If the server sends such + // an invalid list, we want to be able to represent it using the corresponding header property. + private static readonly HttpHeaderParser MultipleValueParser + = new GenericHeaderParser(true, GetEntityTagLength); + + private static EntityTagHeaderValue AnyType; + + private string _tag; + private bool _isWeak; + + private EntityTagHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public EntityTagHeaderValue(string tag) + : this(tag, false) + { + } + + public EntityTagHeaderValue(string tag, bool isWeak) + { + if (string.IsNullOrEmpty(tag)) + { + throw new ArgumentException("An empty string is not allowed.", "tag"); + } + + int length = 0; + if (!isWeak && string.Equals(tag, "*", StringComparison.Ordinal)) + { + // * is valid, but W/* isn't. + _tag = tag; + } + else if ((HttpRuleParser.GetQuotedStringLength(tag, 0, out length) != HttpParseResult.Parsed) || + (length != tag.Length)) + { + // Note that we don't allow 'W/' prefixes for weak ETags in the 'tag' parameter. If the user wants to + // add a weak ETag, he can set 'isWeak' to true. + throw new FormatException("Invalid ETag name"); + } + + _tag = tag; + _isWeak = isWeak; + } + + public static EntityTagHeaderValue Any + { + get + { + if (AnyType == null) + { + AnyType = new EntityTagHeaderValue(); + AnyType._tag = "*"; + AnyType._isWeak = false; + } + return AnyType; + } + } + + public string Tag + { + get { return _tag; } + } + + public bool IsWeak + { + get { return _isWeak; } + } + + public override string ToString() + { + if (_isWeak) + { + return "W/" + _tag; + } + return _tag; + } + + public override bool Equals(object obj) + { + var other = obj as EntityTagHeaderValue; + + if (other == null) + { + return false; + } + + // Since the tag is a quoted-string we treat it case-sensitive. + return ((_isWeak == other._isWeak) && (string.CompareOrdinal(_tag, other._tag) == 0)); + } + + public override int GetHashCode() + { + // Since the tag is a quoted-string we treat it case-sensitive. + return _tag.GetHashCode() ^ _isWeak.GetHashCode(); + } + + public static EntityTagHeaderValue Parse(string input) + { + var index = 0; + return SingleValueParser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out EntityTagHeaderValue parsedValue) + { + var index = 0; + return SingleValueParser.TryParseValue(input, ref index, out parsedValue); + } + + public static IList ParseList(IList inputs) + { + return MultipleValueParser.ParseValues(inputs); + } + + public static bool TryParseList(IList inputs, out IList parsedValues) + { + return MultipleValueParser.TryParseValues(inputs, out parsedValues); + } + + internal static int GetEntityTagLength(string input, int startIndex, out EntityTagHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Caller must remove leading whitespaces. If not, we'll return 0. + var isWeak = false; + var current = startIndex; + + var firstChar = input[startIndex]; + if (firstChar == '*') + { + // We have '*' value, indicating "any" ETag. + parsedValue = Any; + current++; + } + else + { + // The RFC defines 'W/' as prefix, but we'll be flexible and also accept lower-case 'w'. + if ((firstChar == 'W') || (firstChar == 'w')) + { + current++; + // We need at least 3 more chars: the '/' character followed by two quotes. + if ((current + 2 >= input.Length) || (input[current] != '/')) + { + return 0; + } + isWeak = true; + current++; // we have a weak-entity tag. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + } + + var tagStartIndex = current; + var tagLength = 0; + if (HttpRuleParser.GetQuotedStringLength(input, current, out tagLength) != HttpParseResult.Parsed) + { + return 0; + } + + parsedValue = new EntityTagHeaderValue(); + if (tagLength == input.Length) + { + // Most of the time we'll have strong ETags without leading/trailing whitespaces. + Contract.Assert(startIndex == 0); + Contract.Assert(!isWeak); + parsedValue._tag = input; + parsedValue._isWeak = false; + } + else + { + parsedValue._tag = input.Substring(tagStartIndex, tagLength); + parsedValue._isWeak = isWeak; + } + + current = current + tagLength; + } + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + return current - startIndex; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/GenericHeaderParser.cs b/src/Microsoft.Net.Http.Headers/GenericHeaderParser.cs new file mode 100644 index 0000000000..c8e5f933e2 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/GenericHeaderParser.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Net.Http.Headers +{ + internal sealed class GenericHeaderParser : BaseHeaderParser + { + internal delegate int GetParsedValueLengthDelegate(string value, int startIndex, out T parsedValue); + + private GetParsedValueLengthDelegate _getParsedValueLength; + + internal GenericHeaderParser(bool supportsMultipleValues, [NotNull] GetParsedValueLengthDelegate getParsedValueLength) + : base(supportsMultipleValues) + { + _getParsedValueLength = getParsedValueLength; + } + + protected override int GetParsedValueLength(string value, int startIndex, out T parsedValue) + { + return _getParsedValueLength(value, startIndex, out parsedValue); + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/HeaderNames.cs b/src/Microsoft.Net.Http.Headers/HeaderNames.cs new file mode 100644 index 0000000000..53f1d53dc9 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/HeaderNames.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Net.Http.Headers +{ + public static class HeaderNames + { + public const string Accept = "Accept"; + public const string AcceptCharset = "Accept-Charset"; + public const string AcceptEncoding = "Accept-Encoding"; + public const string AcceptLanguage = "Accept-Language"; + public const string AcceptRanges = "Accept-Ranges"; + public const string Age = "Age"; + public const string Allow = "Allow"; + public const string Authorization = "Authorization"; + public const string CacheControl = "Cache-Control"; + public const string Connection = "Connection"; + public const string ContentDisposition = "Content-Disposition"; + public const string ContentEncoding = "Content-Encoding"; + public const string ContentLanguage = "Content-Language"; + public const string ContentLength = "Content-Length"; + public const string ContentLocation = "Content-Location"; + public const string ContentMD5 = "ContentMD5"; + public const string ContentRange = "Content-Range"; + public const string ContentType = "Content-Type"; + public const string Cookie = "Cookie"; + public const string Date = "Date"; + public const string ETag = "ETag"; + public const string Expires = "Expires"; + public const string Expect = "Expect"; + public const string From = "From"; + public const string Host = "Host"; + public const string IfMatch = "If-Match"; + public const string IfModifiedSince = "If-Modified-Since"; + public const string IfNoneMatch = "If-None-Match"; + public const string IfRange = "If-Range"; + public const string IfUnmodifiedSince = "If-Unmodified-Since"; + public const string LastModified = "Last-Modified"; + public const string Location = "Location"; + public const string MaxForwards = "Max-Forwards"; + public const string Pragma = "Pragma"; + public const string ProxyAuthenticate = "Proxy-Authenticate"; + public const string ProxyAuthorization = "Proxy-Authorization"; + public const string Range = "Range"; + public const string Referer = "Referer"; + public const string RetryAfter = "Retry-After"; + public const string Server = "Server"; + public const string SetCookie = "Set-Cookie"; + public const string TE = "TE"; + public const string Trailer = "Trailer"; + public const string TransferEncoding = "Transfer-Encoding"; + public const string Upgrade = "Upgrade"; + public const string UserAgent = "User-Agent"; + public const string Vary = "Vary"; + public const string Via = "Via"; + public const string Warning = "Warning"; + public const string WWWAuthenticate = "WWW-Authenticate"; + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/HeaderQuality.cs b/src/Microsoft.Net.Http.Headers/HeaderQuality.cs new file mode 100644 index 0000000000..c5247e1aca --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/HeaderQuality.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Net.Http.Headers +{ + public static class HeaderQuality + { + /// + /// Quality factor to indicate a perfect match. + /// + public const double Match = 1.0; + + /// + /// Quality factor to indicate no match. + /// + public const double NoMatch = 0.0; + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/HeaderUtilities.cs b/src/Microsoft.Net.Http.Headers/HeaderUtilities.cs new file mode 100644 index 0000000000..aaf2d57470 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/HeaderUtilities.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Globalization; + +namespace Microsoft.Net.Http.Headers +{ + public static class HeaderUtilities + { + private const string QualityName = "q"; + internal const string BytesUnit = "bytes"; + + internal static void SetQuality(ICollection parameters, double? value) + { + Contract.Requires(parameters != null); + + var qualityParameter = NameValueHeaderValue.Find(parameters, QualityName); + if (value.HasValue) + { + // Note that even if we check the value here, we can't prevent a user from adding an invalid quality + // value using Parameters.Add(). Even if we would prevent the user from adding an invalid value + // using Parameters.Add() he could always add invalid values using HttpHeaders.AddWithoutValidation(). + // So this check is really for convenience to show users that they're trying to add an invalid + // value. + if ((value < 0) || (value > 1)) + { + throw new ArgumentOutOfRangeException("value"); + } + + var qualityString = ((double)value).ToString("0.0##", NumberFormatInfo.InvariantInfo); + if (qualityParameter != null) + { + qualityParameter.Value = qualityString; + } + else + { + parameters.Add(new NameValueHeaderValue(QualityName, qualityString)); + } + } + else + { + // Remove quality parameter + if (qualityParameter != null) + { + parameters.Remove(qualityParameter); + } + } + } + + internal static double? GetQuality(ICollection parameters) + { + Contract.Requires(parameters != null); + + var qualityParameter = NameValueHeaderValue.Find(parameters, QualityName); + if (qualityParameter != null) + { + // Note that the RFC requires decimal '.' regardless of the culture. I.e. using ',' as decimal + // separator is considered invalid (even if the current culture would allow it). + double qualityValue; + if (double.TryParse(qualityParameter.Value, NumberStyles.AllowDecimalPoint, + NumberFormatInfo.InvariantInfo, out qualityValue)) + { + return qualityValue; + } + } + return null; + } + + internal static void CheckValidToken(string value, string parameterName) + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException("An empty string is not allowed.", parameterName); + } + + if (HttpRuleParser.GetTokenLength(value, 0) != value.Length) + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, "Invalid token '{0}.", value)); + } + } + + internal static void CheckValidQuotedString(string value, string parameterName) + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException("An empty string is not allowed.", parameterName); + } + + int length; + if ((HttpRuleParser.GetQuotedStringLength(value, 0, out length) != HttpParseResult.Parsed) || + (length != value.Length)) // no trailing spaces allowed + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, "Invalid quoted string '{0}'.", value)); + } + } + + internal static bool AreEqualCollections(ICollection x, ICollection y) + { + return AreEqualCollections(x, y, null); + } + + internal static bool AreEqualCollections(ICollection x, ICollection y, IEqualityComparer comparer) + { + if (x == null) + { + return (y == null) || (y.Count == 0); + } + + if (y == null) + { + return (x.Count == 0); + } + + if (x.Count != y.Count) + { + return false; + } + + if (x.Count == 0) + { + return true; + } + + // We have two unordered lists. So comparison is an O(n*m) operation which is expensive. Usually + // headers have 1-2 parameters (if any), so this comparison shouldn't be too expensive. + var alreadyFound = new bool[x.Count]; + var i = 0; + foreach (var xItem in x) + { + Contract.Assert(xItem != null); + + i = 0; + var found = false; + foreach (var yItem in y) + { + if (!alreadyFound[i]) + { + if (((comparer == null) && xItem.Equals(yItem)) || + ((comparer != null) && comparer.Equals(xItem, yItem))) + { + alreadyFound[i] = true; + found = true; + break; + } + } + i++; + } + + if (!found) + { + return false; + } + } + + // Since we never re-use a "found" value in 'y', we expecte 'alreadyFound' to have all fields set to 'true'. + // Otherwise the two collections can't be equal and we should not get here. + Contract.Assert(Contract.ForAll(alreadyFound, value => { return value; }), + "Expected all values in 'alreadyFound' to be true since collections are considered equal."); + + return true; + } + + internal static int GetNextNonEmptyOrWhitespaceIndex(string input, int startIndex, bool skipEmptyValues, + out bool separatorFound) + { + Contract.Requires(input != null); + Contract.Requires(startIndex <= input.Length); // it's OK if index == value.Length. + + separatorFound = false; + var current = startIndex + HttpRuleParser.GetWhitespaceLength(input, startIndex); + + if ((current == input.Length) || (input[current] != ',')) + { + return current; + } + + // If we have a separator, skip the separator and all following whitespaces. If we support + // empty values, continue until the current character is neither a separator nor a whitespace. + separatorFound = true; + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if (skipEmptyValues) + { + while ((current < input.Length) && (input[current] == ',')) + { + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + } + } + + return current; + } + + internal static bool TryParseInt32(string value, out int result) + { + return int.TryParse(value, NumberStyles.None, NumberFormatInfo.InvariantInfo, out result); + } + + public static bool TryParseInt64(string value, out long result) + { + return long.TryParse(value, NumberStyles.None, NumberFormatInfo.InvariantInfo, out result); + } + + public static string FormatInt64(long value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + public static bool TryParseDate(string input, out DateTimeOffset result) + { + return HttpRuleParser.TryStringToDate(input, out result); + } + + public static string FormatDate(DateTimeOffset dateTime) + { + return HttpRuleParser.DateToString(dateTime); + } + + public static string RemoveQuotes(string input) + { + if (!string.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"') + { + input = input.Substring(1, input.Length - 2); + } + return input; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/HttpHeaderParser.cs b/src/Microsoft.Net.Http.Headers/HttpHeaderParser.cs new file mode 100644 index 0000000000..af19f3421c --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/HttpHeaderParser.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Globalization; + +namespace Microsoft.Net.Http.Headers +{ + internal abstract class HttpHeaderParser + { + private bool _supportsMultipleValues; + + protected HttpHeaderParser(bool supportsMultipleValues) + { + _supportsMultipleValues = supportsMultipleValues; + } + + public bool SupportsMultipleValues + { + get { return _supportsMultipleValues; } + } + + // If a parser supports multiple values, a call to ParseValue/TryParseValue should return a value for 'index' + // pointing to the next non-whitespace character after a delimiter. E.g. if called with a start index of 0 + // for string "value , second_value", then after the call completes, 'index' must point to 's', i.e. the first + // non-whitespace after the separator ','. + public abstract bool TryParseValue(string value, ref int index, out T parsedValue); + + public T ParseValue(string value, ref int index) + { + // Index may be value.Length (e.g. both 0). This may be allowed for some headers (e.g. Accept but not + // allowed by others (e.g. Content-Length). The parser has to decide if this is valid or not. + Contract.Requires((value == null) || ((index >= 0) && (index <= value.Length))); + + // If a parser returns 'null', it means there was no value, but that's valid (e.g. "Accept: "). The caller + // can ignore the value. + T result = default(T); + if (!TryParseValue(value, ref index, out result)) + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, "Invalid value '{0}'.", + value == null ? "" : value.Substring(index))); + } + return result; + } + + public virtual bool TryParseValues(IList values, out IList parsedValues) + { + Contract.Assert(_supportsMultipleValues); + // If a parser returns an empty list, it means there was no value, but that's valid (e.g. "Accept: "). The caller + // can ignore the value. + parsedValues = null; + var results = new List(); + if (values == null) + { + return false; + } + foreach (var value in values) + { + int index = 0; + + while (!string.IsNullOrEmpty(value) && index < value.Length) + { + T output; + if (TryParseValue(value, ref index, out output)) + { + // The entry may not contain an actual value, like " , " + if (output != null) + { + results.Add(output); + } + } + else + { + return false; + } + } + } + if (results.Count > 0) + { + parsedValues = results; + return true; + } + return false; + } + + public IList ParseValues(IList values) + { + Contract.Assert(_supportsMultipleValues); + // If a parser returns an empty list, it means there was no value, but that's valid (e.g. "Accept: "). The caller + // can ignore the value. + var parsedValues = new List(); + if (values == null) + { + return parsedValues; + } + foreach (var value in values) + { + int index = 0; + + while (!string.IsNullOrEmpty(value) && index < value.Length) + { + T output; + if (TryParseValue(value, ref index, out output)) + { + // The entry may not contain an actual value, like " , " + if (output != null) + { + parsedValues.Add(output); + } + } + else + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, "Invalid values '{0}'.", + values == null ? "" : value.Substring(index))); + } + } + } + return parsedValues; + } + + // If ValueType is a custom header value type (e.g. NameValueHeaderValue) it implements ToString() correctly. + // However for existing types like int, byte[], DateTimeOffset we can't override ToString(). Therefore the + // parser provides a ToString() virtual method that can be overridden by derived types to correctly serialize + // values (e.g. byte[] to Base64 encoded string). + // The default implementation is to just call ToString() on the value itself which is the right thing to do + // for most headers (custom types, string, etc.). + public virtual string ToString(object value) + { + Contract.Requires(value != null); + + return value.ToString(); + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/HttpParseResult.cs b/src/Microsoft.Net.Http.Headers/HttpParseResult.cs new file mode 100644 index 0000000000..76713cf1cc --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/HttpParseResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Net.Http.Headers +{ + internal enum HttpParseResult + { + Parsed, + NotParsed, + InvalidFormat, + } +} diff --git a/src/Microsoft.Net.Http.Headers/HttpRuleParser.cs b/src/Microsoft.Net.Http.Headers/HttpRuleParser.cs new file mode 100644 index 0000000000..66f6361da7 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/HttpRuleParser.cs @@ -0,0 +1,352 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + internal static class HttpRuleParser + { + private static readonly bool[] TokenChars; + private const int MaxNestedCount = 5; + private static readonly string[] DateFormats = new string[] { + // "r", // RFC 1123, required output format but too strict for input + "ddd, d MMM yyyy H:m:s 'GMT'", // RFC 1123 (r, except it allows both 1 and 01 for date and time) + "ddd, d MMM yyyy H:m:s", // RFC 1123, no zone - assume GMT + "d MMM yyyy H:m:s 'GMT'", // RFC 1123, no day-of-week + "d MMM yyyy H:m:s", // RFC 1123, no day-of-week, no zone + "ddd, d MMM yy H:m:s 'GMT'", // RFC 1123, short year + "ddd, d MMM yy H:m:s", // RFC 1123, short year, no zone + "d MMM yy H:m:s 'GMT'", // RFC 1123, no day-of-week, short year + "d MMM yy H:m:s", // RFC 1123, no day-of-week, short year, no zone + + "dddd, d'-'MMM'-'yy H:m:s 'GMT'", // RFC 850 + "dddd, d'-'MMM'-'yy H:m:s", // RFC 850 no zone + "ddd MMM d H:m:s yyyy", // ANSI C's asctime() format + + "ddd, d MMM yyyy H:m:s zzz", // RFC 5322 + "ddd, d MMM yyyy H:m:s", // RFC 5322 no zone + "d MMM yyyy H:m:s zzz", // RFC 5322 no day-of-week + "d MMM yyyy H:m:s", // RFC 5322 no day-of-week, no zone + }; + + internal const char CR = '\r'; + internal const char LF = '\n'; + internal const char SP = ' '; + internal const char Tab = '\t'; + internal const int MaxInt64Digits = 19; + internal const int MaxInt32Digits = 10; + + // iso-8859-1, Western European (ISO) + internal static readonly Encoding DefaultHttpEncoding = Encoding.GetEncoding(28591); + + static HttpRuleParser() + { + // token = 1* + // CTL = + + TokenChars = new bool[128]; // everything is false + + for (int i = 33; i < 127; i++) // skip Space (32) & DEL (127) + { + TokenChars[i] = true; + } + + // remove separators: these are not valid token characters + TokenChars[(byte)'('] = false; + TokenChars[(byte)')'] = false; + TokenChars[(byte)'<'] = false; + TokenChars[(byte)'>'] = false; + TokenChars[(byte)'@'] = false; + TokenChars[(byte)','] = false; + TokenChars[(byte)';'] = false; + TokenChars[(byte)':'] = false; + TokenChars[(byte)'\\'] = false; + TokenChars[(byte)'"'] = false; + TokenChars[(byte)'/'] = false; + TokenChars[(byte)'['] = false; + TokenChars[(byte)']'] = false; + TokenChars[(byte)'?'] = false; + TokenChars[(byte)'='] = false; + TokenChars[(byte)'{'] = false; + TokenChars[(byte)'}'] = false; + } + + internal static bool IsTokenChar(char character) + { + // Must be between 'space' (32) and 'DEL' (127) + if (character > 127) + { + return false; + } + + return TokenChars[character]; + } + + [Pure] + internal static int GetTokenLength(string input, int startIndex) + { + Contract.Requires(input != null); + Contract.Ensures((Contract.Result() >= 0) && (Contract.Result() <= (input.Length - startIndex))); + + if (startIndex >= input.Length) + { + return 0; + } + + var current = startIndex; + + while (current < input.Length) + { + if (!IsTokenChar(input[current])) + { + return current - startIndex; + } + current++; + } + return input.Length - startIndex; + } + + internal static int GetWhitespaceLength(string input, int startIndex) + { + Contract.Requires(input != null); + Contract.Ensures((Contract.Result() >= 0) && (Contract.Result() <= (input.Length - startIndex))); + + if (startIndex >= input.Length) + { + return 0; + } + + var current = startIndex; + + char c; + while (current < input.Length) + { + c = input[current]; + + if ((c == SP) || (c == Tab)) + { + current++; + continue; + } + + if (c == CR) + { + // If we have a #13 char, it must be followed by #10 and then at least one SP or HT. + if ((current + 2 < input.Length) && (input[current + 1] == LF)) + { + char spaceOrTab = input[current + 2]; + if ((spaceOrTab == SP) || (spaceOrTab == Tab)) + { + current += 3; + continue; + } + } + } + + return current - startIndex; + } + + // All characters between startIndex and the end of the string are LWS characters. + return input.Length - startIndex; + } + + internal static int GetNumberLength(string input, int startIndex, bool allowDecimal) + { + Contract.Requires(input != null); + Contract.Requires((startIndex >= 0) && (startIndex < input.Length)); + Contract.Ensures((Contract.Result() >= 0) && (Contract.Result() <= (input.Length - startIndex))); + + var current = startIndex; + char c; + + // If decimal values are not allowed, we pretend to have read the '.' character already. I.e. if a dot is + // found in the string, parsing will be aborted. + var haveDot = !allowDecimal; + + // The RFC doesn't allow decimal values starting with dot. I.e. value ".123" is invalid. It must be in the + // form "0.123". Also, there are no negative values defined in the RFC. So we'll just parse non-negative + // values. + // The RFC only allows decimal dots not ',' characters as decimal separators. Therefore value "1,23" is + // considered invalid and must be represented as "1.23". + if (input[current] == '.') + { + return 0; + } + + while (current < input.Length) + { + c = input[current]; + if ((c >= '0') && (c <= '9')) + { + current++; + } + else if (!haveDot && (c == '.')) + { + // Note that value "1." is valid. + haveDot = true; + current++; + } + else + { + break; + } + } + + return current - startIndex; + } + + internal static HttpParseResult GetQuotedStringLength(string input, int startIndex, out int length) + { + var nestedCount = 0; + return GetExpressionLength(input, startIndex, '"', '"', false, ref nestedCount, out length); + } + + // quoted-pair = "\" CHAR + // CHAR = + internal static HttpParseResult GetQuotedPairLength(string input, int startIndex, out int length) + { + Contract.Requires(input != null); + Contract.Requires((startIndex >= 0) && (startIndex < input.Length)); + Contract.Ensures((Contract.ValueAtReturn(out length) >= 0) && + (Contract.ValueAtReturn(out length) <= (input.Length - startIndex))); + + length = 0; + + if (input[startIndex] != '\\') + { + return HttpParseResult.NotParsed; + } + + // Quoted-char has 2 characters. Check wheter there are 2 chars left ('\' + char) + // If so, check whether the character is in the range 0-127. If not, it's an invalid value. + if ((startIndex + 2 > input.Length) || (input[startIndex + 1] > 127)) + { + return HttpParseResult.InvalidFormat; + } + + // We don't care what the char next to '\' is. + length = 2; + return HttpParseResult.Parsed; + } + + internal static string DateToString(DateTimeOffset dateTime) + { + // Format according to RFC1123; 'r' uses invariant info (DateTimeFormatInfo.InvariantInfo) + return dateTime.ToUniversalTime().ToString("r", CultureInfo.InvariantCulture); + } + + internal static bool TryStringToDate(string input, out DateTimeOffset result) + { + // Try the various date formats in the order listed above. + // We should accept a wide verity of common formats, but only output RFC 1123 style dates. + if (DateTimeOffset.TryParseExact(input, DateFormats, DateTimeFormatInfo.InvariantInfo, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal, out result)) + { + return true; + } + + return false; + } + + // TEXT = + // LWS = [CRLF] 1*( SP | HT ) + // CTL = + // + // Since we don't really care about the content of a quoted string or comment, we're more tolerant and + // allow these characters. We only want to find the delimiters ('"' for quoted string and '(', ')' for comment). + // + // 'nestedCount': Comments can be nested. We allow a depth of up to 5 nested comments, i.e. something like + // "(((((comment)))))". If we wouldn't define a limit an attacker could send a comment with hundreds of nested + // comments, resulting in a stack overflow exception. In addition having more than 1 nested comment (if any) + // is unusual. + private static HttpParseResult GetExpressionLength(string input, int startIndex, char openChar, + char closeChar, bool supportsNesting, ref int nestedCount, out int length) + { + Contract.Requires(input != null); + Contract.Requires((startIndex >= 0) && (startIndex < input.Length)); + Contract.Ensures((Contract.Result() != HttpParseResult.Parsed) || + (Contract.ValueAtReturn(out length) > 0)); + + length = 0; + + if (input[startIndex] != openChar) + { + return HttpParseResult.NotParsed; + } + + var current = startIndex + 1; // Start parsing with the character next to the first open-char + while (current < input.Length) + { + // Only check whether we have a quoted char, if we have at least 3 characters left to read (i.e. + // quoted char + closing char). Otherwise the closing char may be considered part of the quoted char. + var quotedPairLength = 0; + if ((current + 2 < input.Length) && + (GetQuotedPairLength(input, current, out quotedPairLength) == HttpParseResult.Parsed)) + { + // We ignore invalid quoted-pairs. Invalid quoted-pairs may mean that it looked like a quoted pair, + // but we actually have a quoted-string: e.g. "\ü" ('\' followed by a char >127 - quoted-pair only + // allows ASCII chars after '\'; qdtext allows both '\' and >127 chars). + current = current + quotedPairLength; + continue; + } + + // If we support nested expressions and we find an open-char, then parse the nested expressions. + if (supportsNesting && (input[current] == openChar)) + { + nestedCount++; + try + { + // Check if we exceeded the number of nested calls. + if (nestedCount > MaxNestedCount) + { + return HttpParseResult.InvalidFormat; + } + + var nestedLength = 0; + HttpParseResult nestedResult = GetExpressionLength(input, current, openChar, closeChar, + supportsNesting, ref nestedCount, out nestedLength); + + switch (nestedResult) + { + case HttpParseResult.Parsed: + current += nestedLength; // add the length of the nested expression and continue. + break; + + case HttpParseResult.NotParsed: + Contract.Assert(false, "'NotParsed' is unexpected: We started nested expression " + + "parsing, because we found the open-char. So either it's a valid nested " + + "expression or it has invalid format."); + break; + + case HttpParseResult.InvalidFormat: + // If the nested expression is invalid, we can't continue, so we fail with invalid format. + return HttpParseResult.InvalidFormat; + + default: + Contract.Assert(false, "Unknown enum result: " + nestedResult); + break; + } + } + finally + { + nestedCount--; + } + } + + if (input[current] == closeChar) + { + length = current - startIndex + 1; + return HttpParseResult.Parsed; + } + current++; + } + + // We didn't see the final quote, therefore we have an invalid expression string. + return HttpParseResult.InvalidFormat; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/MediaTypeHeaderValue.cs b/src/Microsoft.Net.Http.Headers/MediaTypeHeaderValue.cs new file mode 100644 index 0000000000..1fa7535452 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/MediaTypeHeaderValue.cs @@ -0,0 +1,408 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + public class MediaTypeHeaderValue + { + private const string CharsetString = "charset"; + private const string BoundaryString = "boundary"; + + private static readonly HttpHeaderParser SingleValueParser + = new GenericHeaderParser(false, GetMediaTypeLength); + private static readonly HttpHeaderParser MultipleValueParser + = new GenericHeaderParser(true, GetMediaTypeLength); + + // Use list instead of dictionary since we may have multiple parameters with the same name. + private ICollection _parameters; + private string _mediaType; + + private MediaTypeHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public MediaTypeHeaderValue(string mediaType) + { + CheckMediaTypeFormat(mediaType, "mediaType"); + _mediaType = mediaType; + } + + public MediaTypeHeaderValue(string mediaType, double quality) + : this(mediaType) + { + Quality = quality; + } + + public string Charset + { + get + { + return NameValueHeaderValue.Find(_parameters, CharsetString)?.Value; + } + set + { + // We don't prevent a user from setting whitespace-only charsets. Like we can't prevent a user from + // setting a non-existing charset. + var charsetParameter = NameValueHeaderValue.Find(_parameters, CharsetString); + if (string.IsNullOrEmpty(value)) + { + // Remove charset parameter + if (charsetParameter != null) + { + _parameters.Remove(charsetParameter); + } + } + else + { + if (charsetParameter != null) + { + charsetParameter.Value = value; + } + else + { + Parameters.Add(new NameValueHeaderValue(CharsetString, value)); + } + } + } + } + + public Encoding Encoding + { + get + { + var charset = Charset; + if (!string.IsNullOrWhiteSpace(charset)) + { + try + { + return Encoding.GetEncoding(charset); + } + catch (ArgumentException) + { + // Invalid or not supported + } + } + return null; + } + set + { + if (value == null) + { + Charset = null; + } + else + { + Charset = value.WebName; + } + } + } + + public string Boundary + { + get + { + return NameValueHeaderValue.Find(_parameters, BoundaryString)?.Value; + } + set + { + var boundaryParameter = NameValueHeaderValue.Find(_parameters, BoundaryString); + if (string.IsNullOrEmpty(value)) + { + // Remove charset parameter + if (boundaryParameter != null) + { + _parameters.Remove(boundaryParameter); + } + } + else + { + if (boundaryParameter != null) + { + boundaryParameter.Value = value; + } + else + { + Parameters.Add(new NameValueHeaderValue(BoundaryString, value)); + } + } + } + } + + public ICollection Parameters + { + get + { + if (_parameters == null) + { + _parameters = new ObjectCollection(); + } + return _parameters; + } + } + + public double? Quality + { + get { return HeaderUtilities.GetQuality(Parameters); } + set { HeaderUtilities.SetQuality(Parameters, value); } + } + + public string MediaType + { + get { return _mediaType; } + set + { + CheckMediaTypeFormat(value, "value"); + _mediaType = value; + } + } + + public string Type + { + get + { + return _mediaType.Substring(0, _mediaType.IndexOf('/')); + } + } + + public string SubType + { + get + { + return _mediaType.Substring(_mediaType.IndexOf('/') + 1); + } + } + + /// + /// MediaType = "*/*" + /// + public bool MatchesAllTypes + { + get + { + return MediaType.Equals("*/*", StringComparison.Ordinal); + } + } + + /// + /// SubType = "*" + /// + public bool MatchesAllSubTypes + { + get + { + return SubType.Equals("*", StringComparison.Ordinal); + } + } + + public bool IsSubsetOf(MediaTypeHeaderValue otherMediaType) + { + if (otherMediaType == null) + { + return false; + } + + if (!Type.Equals(otherMediaType.Type, StringComparison.OrdinalIgnoreCase)) + { + if (!otherMediaType.MatchesAllTypes) + { + return false; + } + } + else if (!SubType.Equals(otherMediaType.SubType, StringComparison.OrdinalIgnoreCase)) + { + if (!otherMediaType.MatchesAllSubTypes) + { + return false; + } + } + + if (Parameters != null) + { + if (Parameters.Count != 0 && (otherMediaType.Parameters == null || otherMediaType.Parameters.Count == 0)) + { + return false; + } + + // Make sure all parameters listed locally are listed in the other one. The other one may have additional parameters. + foreach (var param in _parameters) + { + var otherParam = NameValueHeaderValue.Find(otherMediaType._parameters, param.Name); + if (otherParam == null) + { + return false; + } + if (!string.Equals(param.Value, otherParam.Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + } + + return true; + } + + public override string ToString() + { + return _mediaType + NameValueHeaderValue.ToString(_parameters, ';', true); + } + + public override bool Equals(object obj) + { + var other = obj as MediaTypeHeaderValue; + + if (other == null) + { + return false; + } + + return (string.Compare(_mediaType, other._mediaType, StringComparison.OrdinalIgnoreCase) == 0) && + HeaderUtilities.AreEqualCollections(_parameters, other._parameters); + } + + public override int GetHashCode() + { + // The media-type string is case-insensitive. + return StringComparer.OrdinalIgnoreCase.GetHashCode(_mediaType) ^ NameValueHeaderValue.GetHashCode(_parameters); + } + + public static MediaTypeHeaderValue Parse(string input) + { + var index = 0; + return SingleValueParser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out MediaTypeHeaderValue parsedValue) + { + var index = 0; + return SingleValueParser.TryParseValue(input, ref index, out parsedValue); + } + + public static IList ParseList(IList inputs) + { + return MultipleValueParser.ParseValues(inputs); + } + + public static bool TryParseList(IList inputs, out IList parsedValues) + { + return MultipleValueParser.TryParseValues(inputs, out parsedValues); + } + + private static int GetMediaTypeLength(string input, int startIndex, out MediaTypeHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Caller must remove leading whitespaces. If not, we'll return 0. + string mediaType = null; + var mediaTypeLength = MediaTypeHeaderValue.GetMediaTypeExpressionLength(input, startIndex, out mediaType); + + if (mediaTypeLength == 0) + { + return 0; + } + + var current = startIndex + mediaTypeLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + MediaTypeHeaderValue mediaTypeHeader = null; + + // If we're not done and we have a parameter delimiter, then we have a list of parameters. + if ((current < input.Length) && (input[current] == ';')) + { + mediaTypeHeader = new MediaTypeHeaderValue(); + mediaTypeHeader._mediaType = mediaType; + + current++; // skip delimiter. + var parameterLength = NameValueHeaderValue.GetNameValueListLength(input, current, ';', + mediaTypeHeader.Parameters); + + parsedValue = mediaTypeHeader; + return current + parameterLength - startIndex; + } + + // We have a media type without parameters. + mediaTypeHeader = new MediaTypeHeaderValue(); + mediaTypeHeader._mediaType = mediaType; + parsedValue = mediaTypeHeader; + return current - startIndex; + } + + private static int GetMediaTypeExpressionLength(string input, int startIndex, out string mediaType) + { + Contract.Requires((input != null) && (input.Length > 0) && (startIndex < input.Length)); + + // This method just parses the "type/subtype" string, it does not parse parameters. + mediaType = null; + + // Parse the type, i.e. in media type string "/; param1=value1; param2=value2" + var typeLength = HttpRuleParser.GetTokenLength(input, startIndex); + + if (typeLength == 0) + { + return 0; + } + + var current = startIndex + typeLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // Parse the separator between type and subtype + if ((current >= input.Length) || (input[current] != '/')) + { + return 0; + } + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // Parse the subtype, i.e. in media type string "/; param1=value1; param2=value2" + var subtypeLength = HttpRuleParser.GetTokenLength(input, current); + + if (subtypeLength == 0) + { + return 0; + } + + // If there are no whitespaces between and in / get the media type using + // one Substring call. Otherwise get substrings for and and combine them. + var mediatTypeLength = current + subtypeLength - startIndex; + if (typeLength + subtypeLength + 1 == mediatTypeLength) + { + mediaType = input.Substring(startIndex, mediatTypeLength); + } + else + { + mediaType = input.Substring(startIndex, typeLength) + "/" + input.Substring(current, subtypeLength); + } + + return mediatTypeLength; + } + + private static void CheckMediaTypeFormat(string mediaType, string parameterName) + { + if (string.IsNullOrEmpty(mediaType)) + { + throw new ArgumentException("An empty string is not allowed.", parameterName); + } + + // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed. + // Also no LWS between type and subtype are allowed. + string tempMediaType; + var mediaTypeLength = GetMediaTypeExpressionLength(mediaType, 0, out tempMediaType); + if ((mediaTypeLength == 0) || (tempMediaType.Length != mediaType.Length)) + { + throw new FormatException(string.Format(CultureInfo.InvariantCulture, "Invalid media type '{0}'.", mediaType)); + } + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/MediaTypeHeaderValueComparer.cs b/src/Microsoft.Net.Http.Headers/MediaTypeHeaderValueComparer.cs new file mode 100644 index 0000000000..0125b2dbc4 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/MediaTypeHeaderValueComparer.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Net.Http.Headers +{ + /// + /// Implementation of that can compare accept media type header fields + /// based on their quality values (a.k.a q-values). + /// + public class MediaTypeHeaderValueComparer : IComparer + { + private static readonly MediaTypeHeaderValueComparer _mediaTypeComparer = + new MediaTypeHeaderValueComparer(); + + private MediaTypeHeaderValueComparer() + { + } + + public static MediaTypeHeaderValueComparer QualityComparer + { + get { return _mediaTypeComparer; } + } + + /// + /// + /// Performs comparisons based on the arguments' quality values + /// (aka their "q-value"). Values with identical q-values are considered equal (i.e. the result is 0) + /// with the exception that subtype wildcards are considered less than specific media types and full + /// wildcards are considered less than subtype wildcards. This allows callers to sort a sequence of + /// following their q-values in the order of specific + /// media types, subtype wildcards, and last any full wildcards. + /// + public int Compare(MediaTypeHeaderValue mediaType1, MediaTypeHeaderValue mediaType2) + { + if (object.ReferenceEquals(mediaType1, mediaType2)) + { + return 0; + } + + var returnValue = CompareBasedOnQualityFactor(mediaType1, mediaType2); + + if (returnValue == 0) + { + if (!mediaType1.Type.Equals(mediaType2.Type, StringComparison.OrdinalIgnoreCase)) + { + if (mediaType1.MatchesAllTypes) + { + return -1; + } + else if (mediaType2.MatchesAllTypes) + { + return 1; + } + else if (mediaType1.MatchesAllSubTypes && !mediaType2.MatchesAllSubTypes) + { + return -1; + } + else if (!mediaType1.MatchesAllSubTypes && mediaType2.MatchesAllSubTypes) + { + return 1; + } + } + else if (!mediaType1.SubType.Equals(mediaType2.SubType, StringComparison.OrdinalIgnoreCase)) + { + if (mediaType1.MatchesAllSubTypes) + { + return -1; + } + else if (mediaType2.MatchesAllSubTypes) + { + return 1; + } + } + } + + return returnValue; + } + + private static int CompareBasedOnQualityFactor(MediaTypeHeaderValue mediaType1, + MediaTypeHeaderValue mediaType2) + { + var mediaType1Quality = mediaType1.Quality ?? HeaderQuality.Match; + var mediaType2Quality = mediaType2.Quality ?? HeaderQuality.Match; + var qualityDifference = mediaType1Quality - mediaType2Quality; + if (qualityDifference < 0) + { + return -1; + } + else if (qualityDifference > 0) + { + return 1; + } + + return 0; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/Microsoft.Net.Http.Headers.kproj b/src/Microsoft.Net.Http.Headers/Microsoft.Net.Http.Headers.kproj new file mode 100644 index 0000000000..f6e3ec7676 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/Microsoft.Net.Http.Headers.kproj @@ -0,0 +1,23 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 60aa2fdb-8121-4826-8d00-9a143fefaf66 + Microsoft.Net.Http.Headers + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs b/src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs new file mode 100644 index 0000000000..fc943f7822 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + // According to the RFC, in places where a "parameter" is required, the value is mandatory + // (e.g. Media-Type, Accept). However, we don't introduce a dedicated type for it. So NameValueHeaderValue supports + // name-only values in addition to name/value pairs. + public class NameValueHeaderValue + { + private static readonly HttpHeaderParser SingleValueParser + = new GenericHeaderParser(false, GetNameValueLength); + internal static readonly HttpHeaderParser MultipleValueParser + = new GenericHeaderParser(true, GetNameValueLength); + + private string _name; + private string _value; + + private NameValueHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public NameValueHeaderValue(string name) + : this(name, null) + { + } + + public NameValueHeaderValue(string name, string value) + { + CheckNameValueFormat(name, value); + + _name = name; + _value = value; + } + + public string Name + { + get { return _name; } + } + + public string Value + { + get { return _value; } + set + { + CheckValueFormat(value); + _value = value; + } + } + + public override int GetHashCode() + { + Contract.Assert(_name != null); + + var nameHashCode = StringComparer.OrdinalIgnoreCase.GetHashCode(_name); + + if (!string.IsNullOrEmpty(_value)) + { + // If we have a quoted-string, then just use the hash code. If we have a token, convert to lowercase + // and retrieve the hash code. + if (_value[0] == '"') + { + return nameHashCode ^ _value.GetHashCode(); + } + + return nameHashCode ^ StringComparer.OrdinalIgnoreCase.GetHashCode(_value); + } + + return nameHashCode; + } + + public override bool Equals(object obj) + { + var other = obj as NameValueHeaderValue; + + if (other == null) + { + return false; + } + + if (string.Compare(_name, other._name, StringComparison.OrdinalIgnoreCase) != 0) + { + return false; + } + + // RFC2616: 14.20: unquoted tokens should use case-INsensitive comparison; quoted-strings should use + // case-sensitive comparison. The RFC doesn't mention how to compare quoted-strings outside the "Expect" + // header. We treat all quoted-strings the same: case-sensitive comparison. + + if (string.IsNullOrEmpty(_value)) + { + return string.IsNullOrEmpty(other._value); + } + + if (_value[0] == '"') + { + // We have a quoted string, so we need to do case-sensitive comparison. + return (string.CompareOrdinal(_value, other._value) == 0); + } + else + { + return (string.Compare(_value, other._value, StringComparison.OrdinalIgnoreCase) == 0); + } + } + + public static NameValueHeaderValue Parse(string input) + { + var index = 0; + return SingleValueParser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out NameValueHeaderValue parsedValue) + { + var index = 0; + return SingleValueParser.TryParseValue(input, ref index, out parsedValue); + } + + public static IList ParseList(IList input) + { + return MultipleValueParser.ParseValues(input); + } + + public static bool TryParseList(IList input, out IList parsedValues) + { + return MultipleValueParser.TryParseValues(input, out parsedValues); + } + + public override string ToString() + { + if (!string.IsNullOrEmpty(_value)) + { + return _name + "=" + _value; + } + return _name; + } + + internal static void ToString(ICollection values, char separator, bool leadingSeparator, + StringBuilder destination) + { + Contract.Assert(destination != null); + + if ((values == null) || (values.Count == 0)) + { + return; + } + + foreach (var value in values) + { + if (leadingSeparator || (destination.Length > 0)) + { + destination.Append(separator); + destination.Append(' '); + } + destination.Append(value.ToString()); + } + } + + internal static string ToString(ICollection values, char separator, bool leadingSeparator) + { + if ((values == null) || (values.Count == 0)) + { + return null; + } + + var sb = new StringBuilder(); + + ToString(values, separator, leadingSeparator, sb); + + return sb.ToString(); + } + + internal static int GetHashCode(ICollection values) + { + if ((values == null) || (values.Count == 0)) + { + return 0; + } + + var result = 0; + foreach (var value in values) + { + result = result ^ value.GetHashCode(); + } + return result; + } + + private static int GetNameValueLength(string input, int startIndex, out NameValueHeaderValue parsedValue) + { + Contract.Requires(input != null); + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Parse the name, i.e. in name/value string "=". Caller must remove + // leading whitespaces. + var nameLength = HttpRuleParser.GetTokenLength(input, startIndex); + + if (nameLength == 0) + { + return 0; + } + + var name = input.Substring(startIndex, nameLength); + var current = startIndex + nameLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // Parse the separator between name and value + if ((current == input.Length) || (input[current] != '=')) + { + // We only have a name and that's OK. Return. + parsedValue = new NameValueHeaderValue(); + parsedValue._name = name; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); // skip whitespaces + return current - startIndex; + } + + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // Parse the value, i.e. in name/value string "=" + int valueLength = GetValueLength(input, current); + + // Value after the '=' may be empty + // Use parameterless ctor to avoid double-parsing of name and value, i.e. skip public ctor validation. + parsedValue = new NameValueHeaderValue(); + parsedValue._name = name; + parsedValue._value = input.Substring(current, valueLength); + current = current + valueLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); // skip whitespaces + return current - startIndex; + } + + // Returns the length of a name/value list, separated by 'delimiter'. E.g. "a=b, c=d, e=f" adds 3 + // name/value pairs to 'nameValueCollection' if 'delimiter' equals ','. + internal static int GetNameValueListLength(string input, int startIndex, char delimiter, + ICollection nameValueCollection) + { + Contract.Requires(nameValueCollection != null); + Contract.Requires(startIndex >= 0); + + if ((string.IsNullOrEmpty(input)) || (startIndex >= input.Length)) + { + return 0; + } + + var current = startIndex + HttpRuleParser.GetWhitespaceLength(input, startIndex); + while (true) + { + NameValueHeaderValue parameter = null; + var nameValueLength = GetNameValueLength(input, current, out parameter); + + if (nameValueLength == 0) + { + // There may be a trailing ';' + return current - startIndex; + } + + nameValueCollection.Add(parameter); + current = current + nameValueLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if ((current == input.Length) || (input[current] != delimiter)) + { + // We're done and we have at least one valid name/value pair. + return current - startIndex; + } + + // input[current] is 'delimiter'. Skip the delimiter and whitespaces and try to parse again. + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + } + } + + public static NameValueHeaderValue Find(ICollection values, string name) + { + Contract.Requires((name != null) && (name.Length > 0)); + + if ((values == null) || (values.Count == 0)) + { + return null; + } + + foreach (var value in values) + { + if (string.Compare(value.Name, name, StringComparison.OrdinalIgnoreCase) == 0) + { + return value; + } + } + return null; + } + + internal static int GetValueLength(string input, int startIndex) + { + Contract.Requires(input != null); + + if (startIndex >= input.Length) + { + return 0; + } + + var valueLength = HttpRuleParser.GetTokenLength(input, startIndex); + + if (valueLength == 0) + { + // A value can either be a token or a quoted string. Check if it is a quoted string. + if (HttpRuleParser.GetQuotedStringLength(input, startIndex, out valueLength) != HttpParseResult.Parsed) + { + // We have an invalid value. Reset the name and return. + return 0; + } + } + return valueLength; + } + + private static void CheckNameValueFormat(string name, string value) + { + HeaderUtilities.CheckValidToken(name, "name"); + CheckValueFormat(value); + } + + private static void CheckValueFormat(string value) + { + // Either value is null/empty or a valid token/quoted string + if (!(string.IsNullOrEmpty(value) || (GetValueLength(value, 0) == value.Length))) + { + throw new FormatException(string.Format(System.Globalization.CultureInfo.InvariantCulture, "The header value is invalid: '{0}'", value)); + } + } + + private static NameValueHeaderValue CreateNameValue() + { + return new NameValueHeaderValue(); + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/NotNullAttribute.cs b/src/Microsoft.Net.Http.Headers/NotNullAttribute.cs new file mode 100644 index 0000000000..a725ce11fe --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/NotNullAttribute.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Net.Http.Headers +{ + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + internal sealed class NotNullAttribute : Attribute + { + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/ObjectCollection.cs b/src/Microsoft.Net.Http.Headers/ObjectCollection.cs new file mode 100644 index 0000000000..f6746df140 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/ObjectCollection.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.ObjectModel; + +namespace Microsoft.Net.Http.Headers +{ + // List allows 'null' values to be added. This is not what we want so we use a custom Collection derived + // type to throw if 'null' gets added. Collection internally uses List which comes at some cost. In addition + // Collection.Add() calls List.InsertItem() which is an O(n) operation (compared to O(1) for List.Add()). + // This type is only used for very small collections (1-2 items) to keep the impact of using Collection small. + internal class ObjectCollection : Collection where T : class + { + private static readonly Action DefaultValidator = CheckNotNull; + + private Action _validator; + + public ObjectCollection() + : this(DefaultValidator) + { + } + + public ObjectCollection(Action validator) + { + _validator = validator; + } + + protected override void InsertItem(int index, T item) + { + if (_validator != null) + { + _validator(item); + } + base.InsertItem(index, item); + } + + protected override void SetItem(int index, T item) + { + if (_validator != null) + { + _validator(item); + } + base.SetItem(index, item); + } + + private static void CheckNotNull(T item) + { + // null values cannot be added to the collection. + if (item == null) + { + throw new ArgumentNullException("item"); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/RangeConditionHeaderValue.cs b/src/Microsoft.Net.Http.Headers/RangeConditionHeaderValue.cs new file mode 100644 index 0000000000..65f976a21c --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/RangeConditionHeaderValue.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.Contracts; + +namespace Microsoft.Net.Http.Headers +{ + public class RangeConditionHeaderValue + { + private static readonly HttpHeaderParser Parser + = new GenericHeaderParser(false, GetRangeConditionLength); + + private DateTimeOffset? _lastModified; + private EntityTagHeaderValue _entityTag; + + private RangeConditionHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public RangeConditionHeaderValue(DateTimeOffset lastModified) + { + _lastModified = lastModified; + } + + public RangeConditionHeaderValue(EntityTagHeaderValue entityTag) + { + if (entityTag == null) + { + throw new ArgumentNullException("entityTag"); + } + + _entityTag = entityTag; + } + + public RangeConditionHeaderValue(string entityTag) + : this(new EntityTagHeaderValue(entityTag)) + { + } + + public DateTimeOffset? LastModified + { + get { return _lastModified; } + } + + public EntityTagHeaderValue EntityTag + { + get { return _entityTag; } + } + + public override string ToString() + { + if (_entityTag == null) + { + return HttpRuleParser.DateToString(_lastModified.Value); + } + return _entityTag.ToString(); + } + + public override bool Equals(object obj) + { + var other = obj as RangeConditionHeaderValue; + + if (other == null) + { + return false; + } + + if (_entityTag == null) + { + return (other._lastModified != null) && (_lastModified.Value == other._lastModified.Value); + } + + return _entityTag.Equals(other._entityTag); + } + + public override int GetHashCode() + { + if (_entityTag == null) + { + return _lastModified.Value.GetHashCode(); + } + + return _entityTag.GetHashCode(); + } + + public static RangeConditionHeaderValue Parse(string input) + { + var index = 0; + return Parser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out RangeConditionHeaderValue parsedValue) + { + var index = 0; + return Parser.TryParseValue(input, ref index, out parsedValue); + } + + private static int GetRangeConditionLength(string input, int startIndex, out RangeConditionHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + // Make sure we have at least 2 characters + if (string.IsNullOrEmpty(input) || (startIndex + 1 >= input.Length)) + { + return 0; + } + + var current = startIndex; + + // Caller must remove leading whitespaces. + DateTimeOffset date = DateTimeOffset.MinValue; + EntityTagHeaderValue entityTag = null; + + // Entity tags are quoted strings optionally preceded by "W/". By looking at the first two character we + // can determine whether the string is en entity tag or a date. + var firstChar = input[current]; + var secondChar = input[current + 1]; + + if ((firstChar == '\"') || (((firstChar == 'w') || (firstChar == 'W')) && (secondChar == '/'))) + { + // trailing whitespaces are removed by GetEntityTagLength() + var entityTagLength = EntityTagHeaderValue.GetEntityTagLength(input, current, out entityTag); + + if (entityTagLength == 0) + { + return 0; + } + + current = current + entityTagLength; + + // RangeConditionHeaderValue only allows 1 value. There must be no delimiter/other chars after an + // entity tag. + if (current != input.Length) + { + return 0; + } + } + else + { + if (!HttpRuleParser.TryStringToDate(input.Substring(current), out date)) + { + return 0; + } + + // If we got a valid date, then the parser consumed the whole string (incl. trailing whitespaces). + current = input.Length; + } + + parsedValue = new RangeConditionHeaderValue(); + if (entityTag == null) + { + parsedValue._lastModified = date; + } + else + { + parsedValue._entityTag = entityTag; + } + + return current - startIndex; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/RangeHeaderValue.cs b/src/Microsoft.Net.Http.Headers/RangeHeaderValue.cs new file mode 100644 index 0000000000..3045a8b387 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/RangeHeaderValue.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + public class RangeHeaderValue + { + private static readonly HttpHeaderParser Parser + = new GenericHeaderParser(false, GetRangeLength); + + private string _unit; + private ICollection _ranges; + + public RangeHeaderValue() + { + _unit = HeaderUtilities.BytesUnit; + } + + public RangeHeaderValue(long? from, long? to) + { + // convenience ctor: "Range: bytes=from-to" + _unit = HeaderUtilities.BytesUnit; + Ranges.Add(new RangeItemHeaderValue(from, to)); + } + + public string Unit + { + get { return _unit; } + set + { + HeaderUtilities.CheckValidToken(value, "value"); + _unit = value; + } + } + + public ICollection Ranges + { + get + { + if (_ranges == null) + { + _ranges = new ObjectCollection(); + } + return _ranges; + } + } + + public override string ToString() + { + var sb = new StringBuilder(_unit); + sb.Append('='); + + var first = true; + foreach (var range in Ranges) + { + if (first) + { + first = false; + } + else + { + sb.Append(", "); + } + + sb.Append(range.From); + sb.Append('-'); + sb.Append(range.To); + } + + return sb.ToString(); + } + + public override bool Equals(object obj) + { + var other = obj as RangeHeaderValue; + + if (other == null) + { + return false; + } + + return (string.Compare(_unit, other._unit, StringComparison.OrdinalIgnoreCase) == 0) && + HeaderUtilities.AreEqualCollections(Ranges, other.Ranges); + } + + public override int GetHashCode() + { + var result = StringComparer.OrdinalIgnoreCase.GetHashCode(_unit); + + foreach (var range in Ranges) + { + result = result ^ range.GetHashCode(); + } + + return result; + } + + public static RangeHeaderValue Parse(string input) + { + var index = 0; + return Parser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out RangeHeaderValue parsedValue) + { + var index = 0; + return Parser.TryParseValue(input, ref index, out parsedValue); + } + + private static int GetRangeLength(string input, int startIndex, out RangeHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Parse the unit string: in '=-, -' + var unitLength = HttpRuleParser.GetTokenLength(input, startIndex); + + if (unitLength == 0) + { + return 0; + } + + RangeHeaderValue result = new RangeHeaderValue(); + result._unit = input.Substring(startIndex, unitLength); + var current = startIndex + unitLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if ((current == input.Length) || (input[current] != '=')) + { + return 0; + } + + current++; // skip '=' separator + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + var rangesLength = RangeItemHeaderValue.GetRangeItemListLength(input, current, result.Ranges); + + if (rangesLength == 0) + { + return 0; + } + + current = current + rangesLength; + Contract.Assert(current == input.Length, "GetRangeItemListLength() should consume the whole string or fail."); + + parsedValue = result; + return current - startIndex; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/RangeItemHeaderValue.cs b/src/Microsoft.Net.Http.Headers/RangeItemHeaderValue.cs new file mode 100644 index 0000000000..21c46f004c --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/RangeItemHeaderValue.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Globalization; + +namespace Microsoft.Net.Http.Headers +{ + public class RangeItemHeaderValue + { + private long? _from; + private long? _to; + + public RangeItemHeaderValue(long? from, long? to) + { + if (!from.HasValue && !to.HasValue) + { + throw new ArgumentException("Invalid header range."); + } + if (from.HasValue && (from.Value < 0)) + { + throw new ArgumentOutOfRangeException("from"); + } + if (to.HasValue && (to.Value < 0)) + { + throw new ArgumentOutOfRangeException("to"); + } + if (from.HasValue && to.HasValue && (from.Value > to.Value)) + { + throw new ArgumentOutOfRangeException("from"); + } + + _from = from; + _to = to; + } + + public long? From + { + get { return _from; } + } + + public long? To + { + get { return _to; } + } + + public override string ToString() + { + if (!_from.HasValue) + { + return "-" + _to.Value.ToString(NumberFormatInfo.InvariantInfo); + } + else if (!_to.HasValue) + { + return _from.Value.ToString(NumberFormatInfo.InvariantInfo) + "-"; + } + return _from.Value.ToString(NumberFormatInfo.InvariantInfo) + "-" + + _to.Value.ToString(NumberFormatInfo.InvariantInfo); + } + + public override bool Equals(object obj) + { + var other = obj as RangeItemHeaderValue; + + if (other == null) + { + return false; + } + return ((_from == other._from) && (_to == other._to)); + } + + public override int GetHashCode() + { + if (!_from.HasValue) + { + return _to.GetHashCode(); + } + else if (!_to.HasValue) + { + return _from.GetHashCode(); + } + return _from.GetHashCode() ^ _to.GetHashCode(); + } + + // Returns the length of a range list. E.g. "1-2, 3-4, 5-6" adds 3 ranges to 'rangeCollection'. Note that empty + // list segments are allowed, e.g. ",1-2, , 3-4,,". + internal static int GetRangeItemListLength(string input, int startIndex, + ICollection rangeCollection) + { + Contract.Requires(rangeCollection != null); + Contract.Requires(startIndex >= 0); + Contract.Ensures((Contract.Result() == 0) || (rangeCollection.Count > 0), + "If we can parse the string, then we expect to have at least one range item."); + + if ((string.IsNullOrEmpty(input)) || (startIndex >= input.Length)) + { + return 0; + } + + // Empty segments are allowed, so skip all delimiter-only segments (e.g. ", ,"). + var separatorFound = false; + var current = HeaderUtilities.GetNextNonEmptyOrWhitespaceIndex(input, startIndex, true, out separatorFound); + // It's OK if we didn't find leading separator characters. Ignore 'separatorFound'. + + if (current == input.Length) + { + return 0; + } + + RangeItemHeaderValue range = null; + while (true) + { + var rangeLength = GetRangeItemLength(input, current, out range); + + if (rangeLength == 0) + { + return 0; + } + + rangeCollection.Add(range); + + current = current + rangeLength; + current = HeaderUtilities.GetNextNonEmptyOrWhitespaceIndex(input, current, true, out separatorFound); + + // If the string is not consumed, we must have a delimiter, otherwise the string is not a valid + // range list. + if ((current < input.Length) && !separatorFound) + { + return 0; + } + + if (current == input.Length) + { + return current - startIndex; + } + } + } + + internal static int GetRangeItemLength(string input, int startIndex, out RangeItemHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + // This parser parses number ranges: e.g. '1-2', '1-', '-2'. + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Caller must remove leading whitespaces. If not, we'll return 0. + var current = startIndex; + + // Try parse the first value of a value pair. + var fromStartIndex = current; + var fromLength = HttpRuleParser.GetNumberLength(input, current, false); + + if (fromLength > HttpRuleParser.MaxInt64Digits) + { + return 0; + } + + current = current + fromLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // Afer the first value, the '-' character must follow. + if ((current == input.Length) || (input[current] != '-')) + { + // We need a '-' character otherwise this can't be a valid range. + return 0; + } + + current++; // skip the '-' character + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + var toStartIndex = current; + var toLength = 0; + + // If we didn't reach the end of the string, try parse the second value of the range. + if (current < input.Length) + { + toLength = HttpRuleParser.GetNumberLength(input, current, false); + + if (toLength > HttpRuleParser.MaxInt64Digits) + { + return 0; + } + + current = current + toLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + } + + if ((fromLength == 0) && (toLength == 0)) + { + return 0; // At least one value must be provided in order to be a valid range. + } + + // Try convert first value to int64 + long from = 0; + if ((fromLength > 0) && !HeaderUtilities.TryParseInt64(input.Substring(fromStartIndex, fromLength), out from)) + { + return 0; + } + + // Try convert second value to int64 + long to = 0; + if ((toLength > 0) && !HeaderUtilities.TryParseInt64(input.Substring(toStartIndex, toLength), out to)) + { + return 0; + } + + // 'from' must not be greater than 'to' + if ((fromLength > 0) && (toLength > 0) && (from > to)) + { + return 0; + } + + parsedValue = new RangeItemHeaderValue((fromLength == 0 ? (long?)null : (long?)from), + (toLength == 0 ? (long?)null : (long?)to)); + return current - startIndex; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs b/src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs new file mode 100644 index 0000000000..3556454156 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Text; + +namespace Microsoft.Net.Http.Headers +{ + // http://tools.ietf.org/html/rfc6265 + public class SetCookieHeaderValue + { + private const string ExpiresToken = "expires"; + private const string MaxAgeToken = "max-age"; + private const string DomainToken = "domain"; + private const string PathToken = "path"; + private const string SecureToken = "secure"; + private const string HttpOnlyToken = "httponly"; + private const string DefaultPath = "/"; // TODO: Used? + + private static readonly HttpHeaderParser SingleValueParser + = new GenericHeaderParser(false, GetSetCookieLength); + private static readonly HttpHeaderParser MultipleValueParser + = new GenericHeaderParser(true, GetSetCookieLength); + + private string _name; + private string _value; + + private SetCookieHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public SetCookieHeaderValue([NotNull] string name) + : this(name, string.Empty) + { + } + + public SetCookieHeaderValue([NotNull] string name, [NotNull] string value) + { + Name = name; + Value = value; + } + + public string Name + { + get { return _name; } + set + { + CookieHeaderValue.CheckNameFormat(value, "name"); + _name = value; + } + } + + public string Value + { + get { return _value; } + set + { + CookieHeaderValue.CheckValueFormat(value, "value"); + _value = value; + } + } + + public DateTimeOffset? Expires { get; set; } + + public TimeSpan? MaxAge { get; set; } + + public string Domain { get; set; } + + // TODO: PathString? + public string Path { get; set; } + + public bool Secure { get; set; } + + public bool HttpOnly { get; set; } + + // name="val ue"; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly + public override string ToString() + { + StringBuilder header = new StringBuilder(); + + header.Append(_name); + header.Append("="); + header.Append(_value); + + if (Expires.HasValue) + { + AppendSegment(header, ExpiresToken, HeaderUtilities.FormatDate(Expires.Value)); + } + + if (MaxAge.HasValue) + { + AppendSegment(header, MaxAgeToken, HeaderUtilities.FormatInt64((long)MaxAge.Value.TotalSeconds)); + } + + if (Domain != null) + { + AppendSegment(header, DomainToken, Domain); + } + + if (Path != null) + { + AppendSegment(header, PathToken, Path); + } + + if (Secure) + { + AppendSegment(header, SecureToken, null); + } + + if (HttpOnly) + { + AppendSegment(header, HttpOnlyToken, null); + } + + return header.ToString(); + } + + private static void AppendSegment(StringBuilder builder, string name, string value) + { + builder.Append("; "); + builder.Append(name); + if (value != null) + { + builder.Append("="); + builder.Append(value); + } + } + + public static SetCookieHeaderValue Parse(string input) + { + var index = 0; + return SingleValueParser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out SetCookieHeaderValue parsedValue) + { + var index = 0; + return SingleValueParser.TryParseValue(input, ref index, out parsedValue); + } + + public static IList ParseList(IList inputs) + { + return MultipleValueParser.ParseValues(inputs); + } + + public static bool TryParseList(IList inputs, out IList parsedValues) + { + return MultipleValueParser.TryParseValues(inputs, out parsedValues); + } + + // name=value; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly + private static int GetSetCookieLength(string input, int startIndex, out SetCookieHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + var offset = startIndex; + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (offset >= input.Length)) + { + return 0; + } + + var result = new SetCookieHeaderValue(); + + // The caller should have already consumed any leading whitespace, commas, etc.. + + // Name=value; + + // Name + var itemLength = HttpRuleParser.GetTokenLength(input, offset); + if (itemLength == 0) + { + return 0; + } + result._name = input.Substring(offset, itemLength); + offset += itemLength; + + // = (no spaces) + if (!ReadEqualsSign(input, ref offset)) + { + return 0; + } + + string value; + // value or "quoted value" + itemLength = CookieHeaderValue.GetCookieValueLength(input, offset, out value); + // The value may be empty + result._value = input.Substring(offset, itemLength); + offset += itemLength; + + // *(';' SP cookie-av) + while (offset < input.Length) + { + if (input[offset] == ',') + { + // Divider between headers + break; + } + if (input[offset] != ';') + { + // Expecting a ';' between parameters + return 0; + } + offset++; + + offset += HttpRuleParser.GetWhitespaceLength(input, offset); + + // cookie-av = expires-av / max-age-av / domain-av / path-av / secure-av / httponly-av / extension-av + itemLength = HttpRuleParser.GetTokenLength(input, offset); + if (itemLength == 0) + { + // Trailing ';' or leading into garbage. Let the next parser fail. + break; + } + var token = input.Substring(offset, itemLength); + offset += itemLength; + + // expires-av = "Expires=" sane-cookie-date + if (string.Equals(token, ExpiresToken, StringComparison.OrdinalIgnoreCase)) + { + // = (no spaces) + if (!ReadEqualsSign(input, ref offset)) + { + return 0; + } + var dateString = ReadToSemicolonOrEnd(input, ref offset); + DateTimeOffset expirationDate; + if (!HttpRuleParser.TryStringToDate(dateString, out expirationDate)) + { + // Invalid expiration date, abort + return 0; + } + result.Expires = expirationDate; + } + // max-age-av = "Max-Age=" non-zero-digit *DIGIT + else if (string.Equals(token, MaxAgeToken, StringComparison.OrdinalIgnoreCase)) + { + // = (no spaces) + if (!ReadEqualsSign(input, ref offset)) + { + return 0; + } + + itemLength = HttpRuleParser.GetNumberLength(input, offset, allowDecimal: false); + if (itemLength == 0) + { + return 0; + } + var numberString = input.Substring(offset, itemLength); + long maxAge; + if (!HeaderUtilities.TryParseInt64(numberString, out maxAge)) + { + // Invalid expiration date, abort + return 0; + } + result.MaxAge = TimeSpan.FromSeconds(maxAge); + offset += itemLength; + } + // domain-av = "Domain=" domain-value + // domain-value = ; defined in [RFC1034], Section 3.5, as enhanced by [RFC1123], Section 2.1 + else if (string.Equals(token, DomainToken, StringComparison.OrdinalIgnoreCase)) + { + // = (no spaces) + if (!ReadEqualsSign(input, ref offset)) + { + return 0; + } + // We don't do any detailed validation on the domain. + result.Domain = ReadToSemicolonOrEnd(input, ref offset); + } + // path-av = "Path=" path-value + // path-value = + else if (string.Equals(token, PathToken, StringComparison.OrdinalIgnoreCase)) + { + // = (no spaces) + if (!ReadEqualsSign(input, ref offset)) + { + return 0; + } + // We don't do any detailed validation on the path. + result.Path = ReadToSemicolonOrEnd(input, ref offset); + } + // secure-av = "Secure" + else if (string.Equals(token, SecureToken, StringComparison.OrdinalIgnoreCase)) + { + result.Secure = true; + } + // httponly-av = "HttpOnly" + else if (string.Equals(token, HttpOnlyToken, StringComparison.OrdinalIgnoreCase)) + { + result.HttpOnly = true; + } + // extension-av = + else + { + // TODO: skip it? Store it in a list? + } + } + + parsedValue = result; + return offset - startIndex; + } + + private static bool ReadEqualsSign(string input, ref int offset) + { + // = (no spaces) + if (offset >= input.Length || input[offset] != '=') + { + return false; + } + offset++; + return true; + } + + private static string ReadToSemicolonOrEnd(string input, ref int offset) + { + var end = input.IndexOf(';', offset); + if (end < 0) + { + // Remainder of the string + end = input.Length; + } + var itemLength = end - offset; + var result = input.Substring(offset, itemLength); + offset += itemLength; + return result; + } + + public override bool Equals(object obj) + { + var other = obj as SetCookieHeaderValue; + + if (other == null) + { + return false; + } + + return string.Equals(_name, other._name, StringComparison.OrdinalIgnoreCase) + && string.Equals(_value, other._value, StringComparison.OrdinalIgnoreCase) + && Expires.Equals(other.Expires) + && MaxAge.Equals(other.MaxAge) + && string.Equals(Domain, other.Domain, StringComparison.OrdinalIgnoreCase) + && string.Equals(Path, other.Path, StringComparison.OrdinalIgnoreCase) + && Secure == other.Secure + && HttpOnly == other.HttpOnly; + } + + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(_name) + ^ StringComparer.OrdinalIgnoreCase.GetHashCode(_value) + ^ (Expires.HasValue ? Expires.GetHashCode() : 0) + ^ (MaxAge.HasValue ? MaxAge.GetHashCode() : 0) + ^ (Domain != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Domain) : 0) + ^ (Path != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Path) : 0) + ^ Secure.GetHashCode() + ^ HttpOnly.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValue.cs b/src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValue.cs new file mode 100644 index 0000000000..41cacc9380 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValue.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Globalization; + +namespace Microsoft.Net.Http.Headers +{ + public class StringWithQualityHeaderValue + { + private static readonly HttpHeaderParser SingleValueParser + = new GenericHeaderParser(false, GetStringWithQualityLength); + private static readonly HttpHeaderParser MultipleValueParser + = new GenericHeaderParser(true, GetStringWithQualityLength); + + private string _value; + private double? _quality; + + private StringWithQualityHeaderValue() + { + // Used by the parser to create a new instance of this type. + } + + public StringWithQualityHeaderValue(string value) + { + HeaderUtilities.CheckValidToken(value, "value"); + + _value = value; + } + + public StringWithQualityHeaderValue(string value, double quality) + { + HeaderUtilities.CheckValidToken(value, "value"); + + if ((quality < 0) || (quality > 1)) + { + throw new ArgumentOutOfRangeException("quality"); + } + + _value = value; + _quality = quality; + } + + public string Value + { + get { return _value; } + } + + public double? Quality + { + get { return _quality; } + } + + public override string ToString() + { + if (_quality.HasValue) + { + return _value + "; q=" + _quality.Value.ToString("0.0##", NumberFormatInfo.InvariantInfo); + } + + return _value; + } + + public override bool Equals(object obj) + { + var other = obj as StringWithQualityHeaderValue; + + if (other == null) + { + return false; + } + + if (string.Compare(_value, other._value, StringComparison.OrdinalIgnoreCase) != 0) + { + return false; + } + + if (_quality.HasValue) + { + // Note that we don't consider double.Epsilon here. We really consider two values equal if they're + // actually equal. This makes sure that we also get the same hashcode for two values considered equal + // by Equals(). + return other._quality.HasValue && (_quality.Value == other._quality.Value); + } + + // If we don't have a quality value, then 'other' must also have no quality assigned in order to be + // considered equal. + return !other._quality.HasValue; + } + + public override int GetHashCode() + { + var result = StringComparer.OrdinalIgnoreCase.GetHashCode(_value); + + if (_quality.HasValue) + { + result = result ^ _quality.Value.GetHashCode(); + } + + return result; + } + + public static StringWithQualityHeaderValue Parse(string input) + { + var index = 0; + return SingleValueParser.ParseValue(input, ref index); + } + + public static bool TryParse(string input, out StringWithQualityHeaderValue parsedValue) + { + var index = 0; + return SingleValueParser.TryParseValue(input, ref index, out parsedValue); + } + + public static IList ParseList(IList input) + { + return MultipleValueParser.ParseValues(input); + } + + public static bool TryParseList(IList input, out IList parsedValues) + { + return MultipleValueParser.TryParseValues(input, out parsedValues); + } + + private static int GetStringWithQualityLength(string input, int startIndex, out StringWithQualityHeaderValue parsedValue) + { + Contract.Requires(startIndex >= 0); + + parsedValue = null; + + if (string.IsNullOrEmpty(input) || (startIndex >= input.Length)) + { + return 0; + } + + // Parse the value string: in '; q=' + var valueLength = HttpRuleParser.GetTokenLength(input, startIndex); + + if (valueLength == 0) + { + return 0; + } + + StringWithQualityHeaderValue result = new StringWithQualityHeaderValue(); + result._value = input.Substring(startIndex, valueLength); + var current = startIndex + valueLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if ((current == input.Length) || (input[current] != ';')) + { + parsedValue = result; + return current - startIndex; // we have a valid token, but no quality. + } + + current++; // skip ';' separator + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // If we found a ';' separator, it must be followed by a quality information + if (!TryReadQuality(input, result, ref current)) + { + return 0; + } + + parsedValue = result; + return current - startIndex; + } + + private static bool TryReadQuality(string input, StringWithQualityHeaderValue result, ref int index) + { + var current = index; + + // See if we have a quality value by looking for "q" + if ((current == input.Length) || ((input[current] != 'q') && (input[current] != 'Q'))) + { + return false; + } + + current++; // skip 'q' identifier + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + // If we found "q" it must be followed by "=" + if ((current == input.Length) || (input[current] != '=')) + { + return false; + } + + current++; // skip '=' separator + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + if (current == input.Length) + { + return false; + } + + int qualityLength = HttpRuleParser.GetNumberLength(input, current, true); + + if (qualityLength == 0) + { + return false; + } + + double quality; + if (!double.TryParse(input.Substring(current, qualityLength), NumberStyles.AllowDecimalPoint, + NumberFormatInfo.InvariantInfo, out quality)) + { + return false; + } + + if ((quality < 0) || (quality > 1)) + { + return false; + } + + result._quality = quality; + + current = current + qualityLength; + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + + index = current; + return true; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValueComparer.cs b/src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValueComparer.cs new file mode 100644 index 0000000000..d7a3def9e1 --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/StringWithQualityHeaderValueComparer.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Net.Http.Headers +{ + /// + /// Implementation of that can compare content negotiation header fields + /// based on their quality values (a.k.a q-values). This applies to values used in accept-charset, + /// accept-encoding, accept-language and related header fields with similar syntax rules. See + /// for a comparer for media type + /// q-values. + /// + public class StringWithQualityHeaderValueComparer : IComparer + { + private static readonly StringWithQualityHeaderValueComparer _qualityComparer = + new StringWithQualityHeaderValueComparer(); + + private StringWithQualityHeaderValueComparer() + { + } + + public static StringWithQualityHeaderValueComparer QualityComparer + { + get { return _qualityComparer; } + } + + /// + /// Compares two based on their quality value + /// (a.k.a their "q-value"). + /// Values with identical q-values are considered equal (i.e the result is 0) with the exception of wild-card + /// values (i.e. a value of "*") which are considered less than non-wild-card values. This allows to sort + /// a sequence of following their q-values ending up with any + /// wild-cards at the end. + /// + /// The first value to compare. + /// The second value to compare + /// The result of the comparison. + public int Compare([NotNull] StringWithQualityHeaderValue stringWithQuality1, + [NotNull] StringWithQualityHeaderValue stringWithQuality2) + { + var quality1 = stringWithQuality1.Quality ?? HeaderQuality.Match; + var quality2 = stringWithQuality2.Quality ?? HeaderQuality.Match; + var qualityDifference = quality1 - quality2; + if (qualityDifference < 0) + { + return -1; + } + else if (qualityDifference > 0) + { + return 1; + } + + if (!String.Equals(stringWithQuality1.Value, stringWithQuality2.Value, StringComparison.OrdinalIgnoreCase)) + { + if (String.Equals(stringWithQuality1.Value, "*", StringComparison.Ordinal)) + { + return -1; + } + else if (String.Equals(stringWithQuality2.Value, "*", StringComparison.Ordinal)) + { + return 1; + } + } + + return 0; + } + } +} diff --git a/src/Microsoft.Net.Http.Headers/project.json b/src/Microsoft.Net.Http.Headers/project.json new file mode 100644 index 0000000000..c0c551004b --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/project.json @@ -0,0 +1,19 @@ +{ + "version": "1.0.0-*", + "dependencies": { + }, + "frameworks" : { + "net45" : { }, + "aspnet50" : { }, + "aspnetcore50" : { + "dependencies": { + "System.Collections": "4.0.10-beta-*", + "System.Diagnostics.Contracts": "4.0.0-beta-*", + "System.Globalization": "4.0.10-beta-*", + "System.Globalization.Extensions": "4.0.0-beta-*", + "System.Text.Encoding": "4.0.10-beta-*", + "System.Runtime": "4.0.20-beta-*" + } + } + } +} diff --git a/test/Microsoft.AspNet.Http.Extensions.Tests/HeaderDictionaryTypeExtensionsTest.cs b/test/Microsoft.AspNet.Http.Extensions.Tests/HeaderDictionaryTypeExtensionsTest.cs new file mode 100644 index 0000000000..6b437d8925 --- /dev/null +++ b/test/Microsoft.AspNet.Http.Extensions.Tests/HeaderDictionaryTypeExtensionsTest.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.PipelineCore.Collections; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNet.Http.Headers +{ + public class HeaderDictionaryTypeExtensionsTest + { + [Fact] + public void GetT_KnownTypeWithValidValue_Success() + { + var headers = new HeaderDictionary(); + headers[HeaderNames.ContentType] = "text/plain"; + + var result = headers.Get(HeaderNames.ContentType); + + var expected = new MediaTypeHeaderValue("text/plain"); + Assert.Equal(expected, result); + } + + [Fact] + public void GetT_KnownTypeWithMissingValue_Null() + { + var headers = new HeaderDictionary(); + + var result = headers.Get(HeaderNames.ContentType); + + Assert.Null(result); + } + + [Fact] + public void GetT_KnownTypeWithInvalidValue_Null() + { + var headers = new HeaderDictionary(); + headers[HeaderNames.ContentType] = "invalid"; + + var result = headers.Get(HeaderNames.ContentType); + + Assert.Null(result); + } + + [Fact] + public void GetT_UnknownTypeWithTryParseAndValidValue_Success() + { + var headers = new HeaderDictionary(); + headers["custom"] = "valid"; + + var result = headers.Get("custom"); + Assert.NotNull(result); + } + + [Fact] + public void GetT_UnknownTypeWithTryParseAndInvalidValue_Null() + { + var headers = new HeaderDictionary(); + headers["custom"] = "invalid"; + + var result = headers.Get("custom"); + Assert.Null(result); + } + + [Fact] + public void GetT_UnknownTypeWithTryParseAndMissingValue_Null() + { + var headers = new HeaderDictionary(); + + var result = headers.Get("custom"); + Assert.Null(result); + } + + [Fact] + public void GetT_UnknownTypeWithoutTryParse_Throws() + { + var headers = new HeaderDictionary(); + headers["custom"] = "valid"; + + Assert.Throws(() => headers.Get("custom")); + } + + [Fact] + public void GetListT_KnownTypeWithValidValue_Success() + { + var headers = new HeaderDictionary(); + headers[HeaderNames.Accept] = "text/plain; q=0.9, text/other, */*"; + + var result = headers.GetList(HeaderNames.Accept); + + var expected = new[] { + new MediaTypeHeaderValue("text/plain", 0.9), + new MediaTypeHeaderValue("text/other"), + new MediaTypeHeaderValue("*/*"), + }.ToList(); + Assert.Equal(expected, result); + } + + [Fact] + public void GetListT_KnownTypeWithMissingValue_Null() + { + var headers = new HeaderDictionary(); + + var result = headers.GetList(HeaderNames.Accept); + + Assert.Null(result); + } + + [Fact] + public void GetListT_KnownTypeWithInvalidValue_Null() + { + var headers = new HeaderDictionary(); + headers[HeaderNames.Accept] = "invalid"; + + var result = headers.GetList(HeaderNames.Accept); + + Assert.Null(result); + } + + [Fact] + public void GetListT_UnknownTypeWithTryParseListAndValidValue_Success() + { + var headers = new HeaderDictionary(); + headers["custom"] = "valid"; + + var results = headers.GetList("custom"); + Assert.NotNull(results); + Assert.Equal(new[] { new TestHeaderValue() }.ToList(), results); + } + + [Fact] + public void GetListT_UnknownTypeWithTryParseListAndInvalidValue_Null() + { + var headers = new HeaderDictionary(); + headers["custom"] = "invalid"; + + var results = headers.GetList("custom"); + Assert.Null(results); + } + + [Fact] + public void GetListT_UnknownTypeWithTryParseListAndMissingValue_Null() + { + var headers = new HeaderDictionary(); + + var results = headers.GetList("custom"); + Assert.Null(results); + } + + [Fact] + public void GetListT_UnknownTypeWithoutTryParseList_Throws() + { + var headers = new HeaderDictionary(); + headers["custom"] = "valid"; + + Assert.Throws(() => headers.GetList("custom")); + } + + public class TestHeaderValue + { + public static bool TryParse(string value, out TestHeaderValue result) + { + if (string.Equals("valid", value)) + { + result = new TestHeaderValue(); + return true; + } + result = null; + return false; + } + + public static bool TryParseList(IList values, out IList result) + { + var results = new List(); + foreach (var value in values) + { + if (string.Equals("valid", value)) + { + results.Add(new TestHeaderValue()); + } + } + if (results.Count > 0) + { + result = results; + return true; + } + result = null; + return false; + } + + public override bool Equals(object obj) + { + var other = obj as TestHeaderValue; + return other != null; + } + + public override int GetHashCode() + { + return 0; + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Http.Extensions.Tests/Microsoft.AspNet.Http.Extensions.Tests.kproj b/test/Microsoft.AspNet.Http.Extensions.Tests/Microsoft.AspNet.Http.Extensions.Tests.kproj index cf6c82e1bb..b3dbf90a69 100644 --- a/test/Microsoft.AspNet.Http.Extensions.Tests/Microsoft.AspNet.Http.Extensions.Tests.kproj +++ b/test/Microsoft.AspNet.Http.Extensions.Tests/Microsoft.AspNet.Http.Extensions.Tests.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNet.WebUtilities.Tests/QueryBuilderTests.cs b/test/Microsoft.AspNet.Http.Extensions.Tests/QueryBuilderTests.cs similarity index 98% rename from test/Microsoft.AspNet.WebUtilities.Tests/QueryBuilderTests.cs rename to test/Microsoft.AspNet.Http.Extensions.Tests/QueryBuilderTests.cs index 76d3ac977d..2f52599c83 100644 --- a/test/Microsoft.AspNet.WebUtilities.Tests/QueryBuilderTests.cs +++ b/test/Microsoft.AspNet.Http.Extensions.Tests/QueryBuilderTests.cs @@ -5,7 +5,7 @@ using System; using System.Collections.Generic; using Xunit; -namespace Microsoft.AspNet.WebUtilities +namespace Microsoft.AspNet.Http.Extensions { public class QueryBuilderTests { diff --git a/test/Microsoft.AspNet.Http.Extensions.Tests/UseWithServicesTests.cs b/test/Microsoft.AspNet.Http.Extensions.Tests/UseWithServicesTests.cs index 9dc54ab09e..1ed29d112e 100644 --- a/test/Microsoft.AspNet.Http.Extensions.Tests/UseWithServicesTests.cs +++ b/test/Microsoft.AspNet.Http.Extensions.Tests/UseWithServicesTests.cs @@ -124,7 +124,7 @@ namespace Microsoft.AspNet.Http.Extensions.Tests { } - public class TestMiddleware + public class TestMiddleware { RequestDelegate _next; @@ -133,9 +133,10 @@ namespace Microsoft.AspNet.Http.Extensions.Tests _next = next; } - public async Task Invoke(HttpContext context, ITestService testService) + public Task Invoke(HttpContext context, ITestService testService) { context.Items[typeof(ITestService)] = testService; + return Task.FromResult(0); } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.PipelineCore.Tests/DefaultHttpRequestTests.cs b/test/Microsoft.AspNet.PipelineCore.Tests/DefaultHttpRequestTests.cs index c82bfd67b7..397d480580 100644 --- a/test/Microsoft.AspNet.PipelineCore.Tests/DefaultHttpRequestTests.cs +++ b/test/Microsoft.AspNet.PipelineCore.Tests/DefaultHttpRequestTests.cs @@ -59,26 +59,6 @@ namespace Microsoft.AspNet.PipelineCore.Tests Assert.Null(request.ContentType); } - [Fact] - public void GetAcceptHeader_ReturnsNullIfHeaderDoesNotExist() - { - // Arrange - var request = GetRequestWithAcceptHeader(acceptHeader: null); - - // Act and Assert - Assert.Null(request.Accept); - } - - [Fact] - public void GetAcceptCharsetHeader_ReturnsNullIfHeaderDoesNotExist() - { - // Arrange - var request = GetRequestWithAcceptCharsetHeader(acceptCharset: null); - - // Act and Assert - Assert.Null(request.AcceptCharset); - } - [Fact] public void Host_GetsHostFromHeaders() { diff --git a/test/Microsoft.AspNet.WebUtilities.Tests/Microsoft.AspNet.WebUtilities.Tests.kproj b/test/Microsoft.AspNet.WebUtilities.Tests/Microsoft.AspNet.WebUtilities.Tests.kproj index 1a6f3883fd..098750fe14 100644 --- a/test/Microsoft.AspNet.WebUtilities.Tests/Microsoft.AspNet.WebUtilities.Tests.kproj +++ b/test/Microsoft.AspNet.WebUtilities.Tests/Microsoft.AspNet.WebUtilities.Tests.kproj @@ -1,4 +1,4 @@ - + 14.0 @@ -14,4 +14,9 @@ 2.0 - + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNet.WebUtilities.Tests/project.json b/test/Microsoft.AspNet.WebUtilities.Tests/project.json index c3fcb3e97c..a445ee721d 100644 --- a/test/Microsoft.AspNet.WebUtilities.Tests/project.json +++ b/test/Microsoft.AspNet.WebUtilities.Tests/project.json @@ -2,12 +2,14 @@ "dependencies": { "Microsoft.AspNet.Http": "1.0.0-*", "Microsoft.AspNet.WebUtilities": "1.0.0-*", + "Microsoft.AspNet.PipelineCore": "1.0.0-*", "xunit.runner.kre": "1.0.0-*" }, "commands": { "test": "xunit.runner.kre" }, "frameworks": { - "aspnet50": { } + "aspnet50": { }, + "aspnetcore50": { } } } diff --git a/test/Microsoft.Net.Http.Headers.Tests/CacheControlHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/CacheControlHeaderValueTest.cs new file mode 100644 index 0000000000..0ac0fe3d2c --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/CacheControlHeaderValueTest.cs @@ -0,0 +1,599 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class CacheControlHeaderValueTest + { + [Fact] + public void Properties_SetAndGetAllProperties_SetValueReturnedInGetter() + { + var cacheControl = new CacheControlHeaderValue(); + + // Bool properties + cacheControl.NoCache = true; + Assert.True(cacheControl.NoCache, "NoCache"); + cacheControl.NoStore = true; + Assert.True(cacheControl.NoStore, "NoStore"); + cacheControl.MaxStale = true; + Assert.True(cacheControl.MaxStale, "MaxStale"); + cacheControl.NoTransform = true; + Assert.True(cacheControl.NoTransform, "NoTransform"); + cacheControl.OnlyIfCached = true; + Assert.True(cacheControl.OnlyIfCached, "OnlyIfCached"); + cacheControl.Public = true; + Assert.True(cacheControl.Public, "Public"); + cacheControl.Private = true; + Assert.True(cacheControl.Private, "Private"); + cacheControl.MustRevalidate = true; + Assert.True(cacheControl.MustRevalidate, "MustRevalidate"); + cacheControl.ProxyRevalidate = true; + Assert.True(cacheControl.ProxyRevalidate, "ProxyRevalidate"); + + // TimeSpan properties + TimeSpan timeSpan = new TimeSpan(1, 2, 3); + cacheControl.MaxAge = timeSpan; + Assert.Equal(timeSpan, cacheControl.MaxAge); + cacheControl.SharedMaxAge = timeSpan; + Assert.Equal(timeSpan, cacheControl.SharedMaxAge); + cacheControl.MaxStaleLimit = timeSpan; + Assert.Equal(timeSpan, cacheControl.MaxStaleLimit); + cacheControl.MinFresh = timeSpan; + Assert.Equal(timeSpan, cacheControl.MinFresh); + + // String collection properties + Assert.NotNull(cacheControl.NoCacheHeaders); + Assert.Throws(() => cacheControl.NoCacheHeaders.Add(null)); + Assert.Throws(() => cacheControl.NoCacheHeaders.Add("invalid token")); + cacheControl.NoCacheHeaders.Add("token"); + Assert.Equal(1, cacheControl.NoCacheHeaders.Count); + Assert.Equal("token", cacheControl.NoCacheHeaders.First()); + + Assert.NotNull(cacheControl.PrivateHeaders); + Assert.Throws(() => cacheControl.PrivateHeaders.Add(null)); + Assert.Throws(() => cacheControl.PrivateHeaders.Add("invalid token")); + cacheControl.PrivateHeaders.Add("token"); + Assert.Equal(1, cacheControl.PrivateHeaders.Count); + Assert.Equal("token", cacheControl.PrivateHeaders.First()); + + // NameValueHeaderValue collection property + Assert.NotNull(cacheControl.Extensions); + Assert.Throws(() => cacheControl.Extensions.Add(null)); + cacheControl.Extensions.Add(new NameValueHeaderValue("name", "value")); + Assert.Equal(1, cacheControl.Extensions.Count); + Assert.Equal(new NameValueHeaderValue("name", "value"), cacheControl.Extensions.First()); + } + + [Fact] + public void ToString_UseRequestDirectiveValues_AllSerializedCorrectly() + { + var cacheControl = new CacheControlHeaderValue(); + Assert.Equal("", cacheControl.ToString()); + + // Note that we allow all combinations of all properties even though the RFC specifies rules what value + // can be used together. + // Also for property pairs (bool property + collection property) like 'NoCache' and 'NoCacheHeaders' the + // caller needs to set the bool property in order for the collection to be populated as string. + + // Cache Request Directive sample + cacheControl.NoStore = true; + Assert.Equal("no-store", cacheControl.ToString()); + cacheControl.NoCache = true; + Assert.Equal("no-store, no-cache", cacheControl.ToString()); + cacheControl.MaxAge = new TimeSpan(0, 1, 10); + Assert.Equal("no-store, no-cache, max-age=70", cacheControl.ToString()); + cacheControl.MaxStale = true; + Assert.Equal("no-store, no-cache, max-age=70, max-stale", cacheControl.ToString()); + cacheControl.MaxStaleLimit = new TimeSpan(0, 2, 5); + Assert.Equal("no-store, no-cache, max-age=70, max-stale=125", cacheControl.ToString()); + cacheControl.MinFresh = new TimeSpan(0, 3, 0); + Assert.Equal("no-store, no-cache, max-age=70, max-stale=125, min-fresh=180", cacheControl.ToString()); + + cacheControl = new CacheControlHeaderValue(); + cacheControl.NoTransform = true; + Assert.Equal("no-transform", cacheControl.ToString()); + cacheControl.OnlyIfCached = true; + Assert.Equal("no-transform, only-if-cached", cacheControl.ToString()); + cacheControl.Extensions.Add(new NameValueHeaderValue("custom")); + cacheControl.Extensions.Add(new NameValueHeaderValue("customName", "customValue")); + Assert.Equal("no-transform, only-if-cached, custom, customName=customValue", cacheControl.ToString()); + + cacheControl = new CacheControlHeaderValue(); + cacheControl.Extensions.Add(new NameValueHeaderValue("custom")); + Assert.Equal("custom", cacheControl.ToString()); + } + + [Fact] + public void ToString_UseResponseDirectiveValues_AllSerializedCorrectly() + { + var cacheControl = new CacheControlHeaderValue(); + Assert.Equal("", cacheControl.ToString()); + + cacheControl.NoCache = true; + Assert.Equal("no-cache", cacheControl.ToString()); + cacheControl.NoCacheHeaders.Add("token1"); + Assert.Equal("no-cache=\"token1\"", cacheControl.ToString()); + cacheControl.Public = true; + Assert.Equal("public, no-cache=\"token1\"", cacheControl.ToString()); + + cacheControl = new CacheControlHeaderValue(); + cacheControl.Private = true; + Assert.Equal("private", cacheControl.ToString()); + cacheControl.PrivateHeaders.Add("token2"); + cacheControl.PrivateHeaders.Add("token3"); + Assert.Equal("private=\"token2, token3\"", cacheControl.ToString()); + cacheControl.MustRevalidate = true; + Assert.Equal("must-revalidate, private=\"token2, token3\"", cacheControl.ToString()); + cacheControl.ProxyRevalidate = true; + Assert.Equal("must-revalidate, proxy-revalidate, private=\"token2, token3\"", cacheControl.ToString()); + } + + [Fact] + public void GetHashCode_CompareValuesWithBoolFieldsSet_MatchExpectation() + { + // Verify that different bool fields return different hash values. + var values = new CacheControlHeaderValue[9]; + + for (int i = 0; i < values.Length; i++) + { + values[i] = new CacheControlHeaderValue(); + } + + values[0].ProxyRevalidate = true; + values[1].NoCache = true; + values[2].NoStore = true; + values[3].MaxStale = true; + values[4].NoTransform = true; + values[5].OnlyIfCached = true; + values[6].Public = true; + values[7].Private = true; + values[8].MustRevalidate = true; + + // Only one bool field set. All hash codes should differ + for (int i = 0; i < values.Length; i++) + { + for (int j = 0; j < values.Length; j++) + { + if (i != j) + { + CompareHashCodes(values[i], values[j], false); + } + } + } + + // Validate that two instances with the same bool fields set are equal. + values[0].NoCache = true; + CompareHashCodes(values[0], values[1], false); + values[1].ProxyRevalidate = true; + CompareHashCodes(values[0], values[1], true); + } + + [Fact] + public void GetHashCode_CompareValuesWithTimeSpanFieldsSet_MatchExpectation() + { + // Verify that different timespan fields return different hash values. + var values = new CacheControlHeaderValue[4]; + + for (int i = 0; i < values.Length; i++) + { + values[i] = new CacheControlHeaderValue(); + } + + values[0].MaxAge = new TimeSpan(0, 1, 1); + values[1].MaxStaleLimit = new TimeSpan(0, 1, 1); + values[2].MinFresh = new TimeSpan(0, 1, 1); + values[3].SharedMaxAge = new TimeSpan(0, 1, 1); + + // Only one timespan field set. All hash codes should differ + for (int i = 0; i < values.Length; i++) + { + for (int j = 0; j < values.Length; j++) + { + if (i != j) + { + CompareHashCodes(values[i], values[j], false); + } + } + } + + values[0].MaxStaleLimit = new TimeSpan(0, 1, 2); + CompareHashCodes(values[0], values[1], false); + + values[1].MaxAge = new TimeSpan(0, 1, 1); + values[1].MaxStaleLimit = new TimeSpan(0, 1, 2); + CompareHashCodes(values[0], values[1], true); + } + + [Fact] + public void GetHashCode_CompareCollectionFieldsSet_MatchExpectation() + { + var cacheControl1 = new CacheControlHeaderValue(); + var cacheControl2 = new CacheControlHeaderValue(); + var cacheControl3 = new CacheControlHeaderValue(); + var cacheControl4 = new CacheControlHeaderValue(); + var cacheControl5 = new CacheControlHeaderValue(); + + cacheControl1.NoCache = true; + cacheControl1.NoCacheHeaders.Add("token2"); + + cacheControl2.NoCache = true; + cacheControl2.NoCacheHeaders.Add("token1"); + cacheControl2.NoCacheHeaders.Add("token2"); + + CompareHashCodes(cacheControl1, cacheControl2, false); + + cacheControl1.NoCacheHeaders.Add("token1"); + CompareHashCodes(cacheControl1, cacheControl2, true); + + // Since NoCache and Private generate different hash codes, even if NoCacheHeaders and PrivateHeaders + // have the same values, the hash code will be different. + cacheControl3.Private = true; + cacheControl3.PrivateHeaders.Add("token2"); + CompareHashCodes(cacheControl1, cacheControl3, false); + + + cacheControl4.Extensions.Add(new NameValueHeaderValue("custom")); + CompareHashCodes(cacheControl1, cacheControl4, false); + + cacheControl5.Extensions.Add(new NameValueHeaderValue("customN", "customV")); + cacheControl5.Extensions.Add(new NameValueHeaderValue("custom")); + CompareHashCodes(cacheControl4, cacheControl5, false); + + cacheControl4.Extensions.Add(new NameValueHeaderValue("customN", "customV")); + CompareHashCodes(cacheControl4, cacheControl5, true); + } + + [Fact] + public void Equals_CompareValuesWithBoolFieldsSet_MatchExpectation() + { + // Verify that different bool fields return different hash values. + var values = new CacheControlHeaderValue[9]; + + for (int i = 0; i < values.Length; i++) + { + values[i] = new CacheControlHeaderValue(); + } + + values[0].ProxyRevalidate = true; + values[1].NoCache = true; + values[2].NoStore = true; + values[3].MaxStale = true; + values[4].NoTransform = true; + values[5].OnlyIfCached = true; + values[6].Public = true; + values[7].Private = true; + values[8].MustRevalidate = true; + + // Only one bool field set. All hash codes should differ + for (int i = 0; i < values.Length; i++) + { + for (int j = 0; j < values.Length; j++) + { + if (i != j) + { + CompareValues(values[i], values[j], false); + } + } + } + + // Validate that two instances with the same bool fields set are equal. + values[0].NoCache = true; + CompareValues(values[0], values[1], false); + values[1].ProxyRevalidate = true; + CompareValues(values[0], values[1], true); + } + + [Fact] + public void Equals_CompareValuesWithTimeSpanFieldsSet_MatchExpectation() + { + // Verify that different timespan fields return different hash values. + var values = new CacheControlHeaderValue[4]; + + for (int i = 0; i < values.Length; i++) + { + values[i] = new CacheControlHeaderValue(); + } + + values[0].MaxAge = new TimeSpan(0, 1, 1); + values[1].MaxStaleLimit = new TimeSpan(0, 1, 1); + values[2].MinFresh = new TimeSpan(0, 1, 1); + values[3].SharedMaxAge = new TimeSpan(0, 1, 1); + + // Only one timespan field set. All hash codes should differ + for (int i = 0; i < values.Length; i++) + { + for (int j = 0; j < values.Length; j++) + { + if (i != j) + { + CompareValues(values[i], values[j], false); + } + } + } + + values[0].MaxStaleLimit = new TimeSpan(0, 1, 2); + CompareValues(values[0], values[1], false); + + values[1].MaxAge = new TimeSpan(0, 1, 1); + values[1].MaxStaleLimit = new TimeSpan(0, 1, 2); + CompareValues(values[0], values[1], true); + + var value1 = new CacheControlHeaderValue(); + value1.MaxStale = true; + var value2 = new CacheControlHeaderValue(); + value2.MaxStale = true; + CompareValues(value1, value2, true); + + value2.MaxStaleLimit = new TimeSpan(1, 2, 3); + CompareValues(value1, value2, false); + } + + [Fact] + public void Equals_CompareCollectionFieldsSet_MatchExpectation() + { + var cacheControl1 = new CacheControlHeaderValue(); + var cacheControl2 = new CacheControlHeaderValue(); + var cacheControl3 = new CacheControlHeaderValue(); + var cacheControl4 = new CacheControlHeaderValue(); + var cacheControl5 = new CacheControlHeaderValue(); + var cacheControl6 = new CacheControlHeaderValue(); + + cacheControl1.NoCache = true; + cacheControl1.NoCacheHeaders.Add("token2"); + + Assert.False(cacheControl1.Equals(null), "Compare with 'null'"); + + cacheControl2.NoCache = true; + cacheControl2.NoCacheHeaders.Add("token1"); + cacheControl2.NoCacheHeaders.Add("token2"); + + CompareValues(cacheControl1, cacheControl2, false); + + cacheControl1.NoCacheHeaders.Add("token1"); + CompareValues(cacheControl1, cacheControl2, true); + + // Since NoCache and Private generate different hash codes, even if NoCacheHeaders and PrivateHeaders + // have the same values, the hash code will be different. + cacheControl3.Private = true; + cacheControl3.PrivateHeaders.Add("token2"); + CompareValues(cacheControl1, cacheControl3, false); + + cacheControl4.Private = true; + cacheControl4.PrivateHeaders.Add("token3"); + CompareValues(cacheControl3, cacheControl4, false); + + cacheControl5.Extensions.Add(new NameValueHeaderValue("custom")); + CompareValues(cacheControl1, cacheControl5, false); + + cacheControl6.Extensions.Add(new NameValueHeaderValue("customN", "customV")); + cacheControl6.Extensions.Add(new NameValueHeaderValue("custom")); + CompareValues(cacheControl5, cacheControl6, false); + + cacheControl5.Extensions.Add(new NameValueHeaderValue("customN", "customV")); + CompareValues(cacheControl5, cacheControl6, true); + } + + [Fact] + public void TryParse_DifferentValidScenarios_AllReturnTrue() + { + var expected = new CacheControlHeaderValue(); + expected.NoCache = true; + CheckValidTryParse(" , no-cache ,,", expected); + + expected = new CacheControlHeaderValue(); + expected.NoCache = true; + expected.NoCacheHeaders.Add("token1"); + expected.NoCacheHeaders.Add("token2"); + CheckValidTryParse("no-cache=\"token1, token2\"", expected); + + expected = new CacheControlHeaderValue(); + expected.NoStore = true; + expected.MaxAge = new TimeSpan(0, 0, 125); + expected.MaxStale = true; + CheckValidTryParse(" no-store , max-age = 125, max-stale,", expected); + + expected = new CacheControlHeaderValue(); + expected.MinFresh = new TimeSpan(0, 0, 123); + expected.NoTransform = true; + expected.OnlyIfCached = true; + expected.Extensions.Add(new NameValueHeaderValue("custom")); + CheckValidTryParse("min-fresh=123, no-transform, only-if-cached, custom", expected); + + expected = new CacheControlHeaderValue(); + expected.Public = true; + expected.Private = true; + expected.PrivateHeaders.Add("token1"); + expected.MustRevalidate = true; + expected.ProxyRevalidate = true; + expected.Extensions.Add(new NameValueHeaderValue("c", "d")); + expected.Extensions.Add(new NameValueHeaderValue("a", "b")); + CheckValidTryParse(",public, , private=\"token1\", must-revalidate, c=d, proxy-revalidate, a=b", expected); + + expected = new CacheControlHeaderValue(); + expected.Private = true; + expected.SharedMaxAge = new TimeSpan(0, 0, 1234567890); + expected.MaxAge = new TimeSpan(0, 0, 987654321); + CheckValidTryParse("s-maxage=1234567890, private, max-age = 987654321,", expected); + + expected = new CacheControlHeaderValue(); + expected.Extensions.Add(new NameValueHeaderValue("custom", "")); + CheckValidTryParse("custom=", expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + // Token-only values + [InlineData("no-store=15")] + [InlineData("no-store=")] + [InlineData("no-transform=a")] + [InlineData("no-transform=")] + [InlineData("only-if-cached=\"x\"")] + [InlineData("only-if-cached=")] + [InlineData("public=\"x\"")] + [InlineData("public=")] + [InlineData("must-revalidate=\"1\"")] + [InlineData("must-revalidate=")] + [InlineData("proxy-revalidate=x")] + [InlineData("proxy-revalidate=")] + // Token with optional field-name list + [InlineData("no-cache=")] + [InlineData("no-cache=token")] + [InlineData("no-cache=\"token")] + [InlineData("no-cache=\"\"")] // at least one token expected as value + [InlineData("private=")] + [InlineData("private=token")] + [InlineData("private=\"token")] + [InlineData("private=\",\"")] // at least one token expected as value + [InlineData("private=\"=\"")] + // Token with delta-seconds value + [InlineData("max-age")] + [InlineData("max-age=")] + [InlineData("max-age=a")] + [InlineData("max-age=\"1\"")] + [InlineData("max-age=1.5")] + [InlineData("max-stale=")] + [InlineData("max-stale=a")] + [InlineData("max-stale=\"1\"")] + [InlineData("max-stale=1.5")] + [InlineData("min-fresh")] + [InlineData("min-fresh=")] + [InlineData("min-fresh=a")] + [InlineData("min-fresh=\"1\"")] + [InlineData("min-fresh=1.5")] + [InlineData("s-maxage")] + [InlineData("s-maxage=")] + [InlineData("s-maxage=a")] + [InlineData("s-maxage=\"1\"")] + [InlineData("s-maxage=1.5")] + // Invalid Extension values + [InlineData("custom value")] + public void TryParse_DifferentInvalidScenarios_ReturnsFalse(string input) + { + CheckInvalidTryParse(input); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + // Just verify parser is implemented correctly. Don't try to test syntax parsed by CacheControlHeaderValue. + var expected = new CacheControlHeaderValue(); + expected.NoStore = true; + expected.MinFresh = new TimeSpan(0, 2, 3); + CheckValidParse(" , no-store, min-fresh=123", expected); + + expected = new CacheControlHeaderValue(); + expected.MaxStale = true; + expected.NoCache = true; + expected.NoCacheHeaders.Add("t"); + CheckValidParse("max-stale, no-cache=\"t\", ,,", expected); + + expected = new CacheControlHeaderValue(); + expected.Extensions.Add(new NameValueHeaderValue("custom")); + CheckValidParse("custom =", expected); + + expected = new CacheControlHeaderValue(); + expected.Extensions.Add(new NameValueHeaderValue("custom", "")); + CheckValidParse("custom =", expected); + } + + [Fact] + public void Parse_SetOfInvalidValueStrings_Throws() + { + CheckInvalidParse(null); + CheckInvalidParse(""); + CheckInvalidParse(" "); + CheckInvalidParse("no-cache,="); + CheckInvalidParse("max-age=123x"); + CheckInvalidParse("=no-cache"); + CheckInvalidParse("no-cache no-store"); + CheckInvalidParse("会"); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + // Just verify parser is implemented correctly. Don't try to test syntax parsed by CacheControlHeaderValue. + var expected = new CacheControlHeaderValue(); + expected.NoStore = true; + expected.MinFresh = new TimeSpan(0, 2, 3); + CheckValidTryParse(" , no-store, min-fresh=123", expected); + + expected = new CacheControlHeaderValue(); + expected.MaxStale = true; + expected.NoCache = true; + expected.NoCacheHeaders.Add("t"); + CheckValidTryParse("max-stale, no-cache=\"t\", ,,", expected); + + expected = new CacheControlHeaderValue(); + expected.Extensions.Add(new NameValueHeaderValue("custom")); + CheckValidTryParse("custom = ", expected); + + expected = new CacheControlHeaderValue(); + expected.Extensions.Add(new NameValueHeaderValue("custom", "")); + CheckValidTryParse("custom =", expected); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidTryParse("no-cache,="); + CheckInvalidTryParse("max-age=123x"); + CheckInvalidTryParse("=no-cache"); + CheckInvalidTryParse("no-cache no-store"); + CheckInvalidTryParse("会"); + } + + #region Helper methods + + private void CompareHashCodes(CacheControlHeaderValue x, CacheControlHeaderValue y, bool areEqual) + { + if (areEqual) + { + Assert.Equal(x.GetHashCode(), y.GetHashCode()); + } + else + { + Assert.NotEqual(x.GetHashCode(), y.GetHashCode()); + } + } + + private void CompareValues(CacheControlHeaderValue x, CacheControlHeaderValue y, bool areEqual) + { + Assert.Equal(areEqual, x.Equals(y)); + Assert.Equal(areEqual, y.Equals(x)); + } + + private void CheckValidParse(string input, CacheControlHeaderValue expectedResult) + { + var result = CacheControlHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidParse(string input) + { + Assert.Throws(() => CacheControlHeaderValue.Parse(input)); + } + + private void CheckValidTryParse(string input, CacheControlHeaderValue expectedResult) + { + CacheControlHeaderValue result = null; + Assert.True(CacheControlHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidTryParse(string input) + { + CacheControlHeaderValue result = null; + Assert.False(CacheControlHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + #endregion + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/ContentDispositionHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/ContentDispositionHeaderValueTest.cs new file mode 100644 index 0000000000..41bb2bdaae --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/ContentDispositionHeaderValueTest.cs @@ -0,0 +1,609 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class ContentDispositionHeaderValueTest + { + [Fact] + public void Ctor_ContentDispositionNull_Throw() + { + Assert.Throws(() => new ContentDispositionHeaderValue(null)); + } + + [Fact] + public void Ctor_ContentDispositionEmpty_Throw() + { + // null and empty should be treated the same. So we also throw for empty strings. + Assert.Throws(() => new ContentDispositionHeaderValue(string.Empty)); + } + + [Fact] + public void Ctor_ContentDispositionInvalidFormat_ThrowFormatException() + { + // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed. + AssertFormatException(" inline "); + AssertFormatException(" inline"); + AssertFormatException("inline "); + AssertFormatException("\"inline\""); + AssertFormatException("te xt"); + AssertFormatException("te=xt"); + AssertFormatException("teäxt"); + AssertFormatException("text;"); + AssertFormatException("te/xt;"); + AssertFormatException("inline; name=someName; "); + AssertFormatException("text;name=someName"); // ctor takes only disposition-type name, no parameters + } + + [Fact] + public void Ctor_ContentDispositionValidFormat_SuccessfullyCreated() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + Assert.Equal("inline", contentDisposition.DispositionType); + Assert.Equal(0, contentDisposition.Parameters.Count); + Assert.Null(contentDisposition.Name); + Assert.Null(contentDisposition.FileName); + Assert.Null(contentDisposition.CreationDate); + Assert.Null(contentDisposition.ModificationDate); + Assert.Null(contentDisposition.ReadDate); + Assert.Null(contentDisposition.Size); + } + + [Fact] + public void Parameters_AddNull_Throw() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + Assert.Throws(() => contentDisposition.Parameters.Add(null)); + } + + [Fact] + public void ContentDisposition_SetAndGetContentDisposition_MatchExpectations() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + Assert.Equal("inline", contentDisposition.DispositionType); + + contentDisposition.DispositionType = "attachment"; + Assert.Equal("attachment", contentDisposition.DispositionType); + } + + [Fact] + public void Name_SetNameAndValidateObject_ParametersEntryForNameAdded() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + contentDisposition.Name = "myname"; + Assert.Equal("myname", contentDisposition.Name); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("name", contentDisposition.Parameters.First().Name); + + contentDisposition.Name = null; + Assert.Null(contentDisposition.Name); + Assert.Equal(0, contentDisposition.Parameters.Count); + contentDisposition.Name = null; // It's OK to set it again to null; no exception. + } + + [Fact] + public void Name_AddNameParameterThenUseProperty_ParametersEntryIsOverwritten() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + NameValueHeaderValue name = new NameValueHeaderValue("NAME", "old_name"); + contentDisposition.Parameters.Add(name); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("NAME", contentDisposition.Parameters.First().Name); + + contentDisposition.Name = "new_name"; + Assert.Equal("new_name", contentDisposition.Name); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("NAME", contentDisposition.Parameters.First().Name); + + contentDisposition.Parameters.Remove(name); + Assert.Null(contentDisposition.Name); + } + + [Fact] + public void FileName_AddNameParameterThenUseProperty_ParametersEntryIsOverwritten() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var fileName = new NameValueHeaderValue("FILENAME", "old_name"); + contentDisposition.Parameters.Add(fileName); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME", contentDisposition.Parameters.First().Name); + + contentDisposition.FileName = "new_name"; + Assert.Equal("new_name", contentDisposition.FileName); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME", contentDisposition.Parameters.First().Name); + + contentDisposition.Parameters.Remove(fileName); + Assert.Null(contentDisposition.FileName); + } + + [Fact] + public void FileName_NeedsEncoding_EncodedAndDecodedCorrectly() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + contentDisposition.FileName = "FileÃName.bat"; + Assert.Equal("FileÃName.bat", contentDisposition.FileName); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("filename", contentDisposition.Parameters.First().Name); + Assert.Equal("\"=?utf-8?B?RmlsZcODTmFtZS5iYXQ=?=\"", contentDisposition.Parameters.First().Value); + + contentDisposition.Parameters.Remove(contentDisposition.Parameters.First()); + Assert.Null(contentDisposition.FileName); + } + + [Fact] + public void FileName_UnknownOrBadEncoding_PropertyFails() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var fileName = new NameValueHeaderValue("FILENAME", "\"=?utf-99?Q?R=mlsZcODTmFtZS5iYXQ=?=\""); + contentDisposition.Parameters.Add(fileName); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME", contentDisposition.Parameters.First().Name); + Assert.Equal("\"=?utf-99?Q?R=mlsZcODTmFtZS5iYXQ=?=\"", contentDisposition.Parameters.First().Value); + Assert.Equal("\"=?utf-99?Q?R=mlsZcODTmFtZS5iYXQ=?=\"", contentDisposition.FileName); + + contentDisposition.FileName = "new_name"; + Assert.Equal("new_name", contentDisposition.FileName); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME", contentDisposition.Parameters.First().Name); + + contentDisposition.Parameters.Remove(fileName); + Assert.Null(contentDisposition.FileName); + } + + [Fact] + public void FileNameStar_AddNameParameterThenUseProperty_ParametersEntryIsOverwritten() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var fileNameStar = new NameValueHeaderValue("FILENAME*", "old_name"); + contentDisposition.Parameters.Add(fileNameStar); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME*", contentDisposition.Parameters.First().Name); + Assert.Null(contentDisposition.FileNameStar); // Decode failure + + contentDisposition.FileNameStar = "new_name"; + Assert.Equal("new_name", contentDisposition.FileNameStar); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME*", contentDisposition.Parameters.First().Name); + Assert.Equal("UTF-8\'\'new_name", contentDisposition.Parameters.First().Value); + + contentDisposition.Parameters.Remove(fileNameStar); + Assert.Null(contentDisposition.FileNameStar); + } + + [Fact] + public void FileNameStar_NeedsEncoding_EncodedAndDecodedCorrectly() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + contentDisposition.FileNameStar = "FileÃName.bat"; + Assert.Equal("FileÃName.bat", contentDisposition.FileNameStar); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("filename*", contentDisposition.Parameters.First().Name); + Assert.Equal("UTF-8\'\'File%C3%83Name.bat", contentDisposition.Parameters.First().Value); + + contentDisposition.Parameters.Remove(contentDisposition.Parameters.First()); + Assert.Null(contentDisposition.FileNameStar); + } + + [Fact] + public void FileNameStar_UnknownOrBadEncoding_PropertyFails() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var fileNameStar = new NameValueHeaderValue("FILENAME*", "utf-99'lang'File%CZName.bat"); + contentDisposition.Parameters.Add(fileNameStar); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME*", contentDisposition.Parameters.First().Name); + Assert.Equal("utf-99'lang'File%CZName.bat", contentDisposition.Parameters.First().Value); + Assert.Null(contentDisposition.FileNameStar); // Decode failure + + contentDisposition.FileNameStar = "new_name"; + Assert.Equal("new_name", contentDisposition.FileNameStar); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("FILENAME*", contentDisposition.Parameters.First().Name); + + contentDisposition.Parameters.Remove(fileNameStar); + Assert.Null(contentDisposition.FileNameStar); + } + + [Fact] + public void Dates_AddDateParameterThenUseProperty_ParametersEntryIsOverwritten() + { + string validDateString = "\"Tue, 15 Nov 1994 08:12:31 GMT\""; + DateTimeOffset validDate = DateTimeOffset.Parse("Tue, 15 Nov 1994 08:12:31 GMT"); + + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var dateParameter = new NameValueHeaderValue("Creation-DATE", validDateString); + contentDisposition.Parameters.Add(dateParameter); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("Creation-DATE", contentDisposition.Parameters.First().Name); + + Assert.Equal(validDate, contentDisposition.CreationDate); + + var newDate = validDate.AddSeconds(1); + contentDisposition.CreationDate = newDate; + Assert.Equal(newDate, contentDisposition.CreationDate); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("Creation-DATE", contentDisposition.Parameters.First().Name); + Assert.Equal("\"Tue, 15 Nov 1994 08:12:32 GMT\"", contentDisposition.Parameters.First().Value); + + contentDisposition.Parameters.Remove(dateParameter); + Assert.Null(contentDisposition.CreationDate); + } + + [Fact] + public void Dates_InvalidDates_PropertyFails() + { + string invalidDateString = "\"Tue, 15 Nov 94 08:12 GMT\""; + + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var dateParameter = new NameValueHeaderValue("read-DATE", invalidDateString); + contentDisposition.Parameters.Add(dateParameter); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("read-DATE", contentDisposition.Parameters.First().Name); + + Assert.Null(contentDisposition.ReadDate); + + contentDisposition.ReadDate = null; + Assert.Null(contentDisposition.ReadDate); + Assert.Equal(0, contentDisposition.Parameters.Count); + } + + [Fact] + public void Size_AddSizeParameterThenUseProperty_ParametersEntryIsOverwritten() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var sizeParameter = new NameValueHeaderValue("SIZE", "279172874239"); + contentDisposition.Parameters.Add(sizeParameter); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("SIZE", contentDisposition.Parameters.First().Name); + Assert.Equal(279172874239, contentDisposition.Size); + + contentDisposition.Size = 279172874240; + Assert.Equal(279172874240, contentDisposition.Size); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("SIZE", contentDisposition.Parameters.First().Name); + + contentDisposition.Parameters.Remove(sizeParameter); + Assert.Null(contentDisposition.Size); + } + + [Fact] + public void Size_InvalidSizes_PropertyFails() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var sizeParameter = new NameValueHeaderValue("SIZE", "-279172874239"); + contentDisposition.Parameters.Add(sizeParameter); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("SIZE", contentDisposition.Parameters.First().Name); + Assert.Null(contentDisposition.Size); + + // Negatives not allowed + Assert.Throws(() => contentDisposition.Size = -279172874240); + Assert.Null(contentDisposition.Size); + Assert.Equal(1, contentDisposition.Parameters.Count); + Assert.Equal("SIZE", contentDisposition.Parameters.First().Name); + + contentDisposition.Parameters.Remove(sizeParameter); + Assert.Null(contentDisposition.Size); + } + + [Fact] + public void ToString_UseDifferentContentDispositions_AllSerializedCorrectly() + { + var contentDisposition = new ContentDispositionHeaderValue("inline"); + Assert.Equal("inline", contentDisposition.ToString()); + + contentDisposition.Name = "myname"; + Assert.Equal("inline; name=myname", contentDisposition.ToString()); + + contentDisposition.FileName = "my File Name"; + Assert.Equal("inline; name=myname; filename=\"my File Name\"", contentDisposition.ToString()); + + contentDisposition.CreationDate = new DateTimeOffset(new DateTime(2011, 2, 15)); + Assert.Equal("inline; name=myname; filename=\"my File Name\"; creation-date=" + + "\"Tue, 15 Feb 2011 08:00:00 GMT\"", contentDisposition.ToString()); + + contentDisposition.Parameters.Add(new NameValueHeaderValue("custom", "\"custom value\"")); + Assert.Equal("inline; name=myname; filename=\"my File Name\"; creation-date=" + + "\"Tue, 15 Feb 2011 08:00:00 GMT\"; custom=\"custom value\"", contentDisposition.ToString()); + + contentDisposition.Name = null; + Assert.Equal("inline; filename=\"my File Name\"; creation-date=" + + "\"Tue, 15 Feb 2011 08:00:00 GMT\"; custom=\"custom value\"", contentDisposition.ToString()); + + contentDisposition.FileNameStar = "File%Name"; + Assert.Equal("inline; filename=\"my File Name\"; creation-date=" + + "\"Tue, 15 Feb 2011 08:00:00 GMT\"; custom=\"custom value\"; filename*=UTF-8\'\'File%25Name", + contentDisposition.ToString()); + + contentDisposition.FileName = null; + Assert.Equal("inline; creation-date=\"Tue, 15 Feb 2011 08:00:00 GMT\"; custom=\"custom value\";" + + " filename*=UTF-8\'\'File%25Name", contentDisposition.ToString()); + + contentDisposition.CreationDate = null; + Assert.Equal("inline; custom=\"custom value\"; filename*=UTF-8\'\'File%25Name", + contentDisposition.ToString()); + } + + [Fact] + public void GetHashCode_UseContentDispositionWithAndWithoutParameters_SameOrDifferentHashCodes() + { + var contentDisposition1 = new ContentDispositionHeaderValue("inline"); + var contentDisposition2 = new ContentDispositionHeaderValue("inline"); + contentDisposition2.Name = "myname"; + var contentDisposition3 = new ContentDispositionHeaderValue("inline"); + contentDisposition3.Parameters.Add(new NameValueHeaderValue("name", "value")); + var contentDisposition4 = new ContentDispositionHeaderValue("INLINE"); + var contentDisposition5 = new ContentDispositionHeaderValue("INLINE"); + contentDisposition5.Parameters.Add(new NameValueHeaderValue("NAME", "MYNAME")); + + Assert.NotEqual(contentDisposition1.GetHashCode(), contentDisposition2.GetHashCode()); + Assert.NotEqual(contentDisposition1.GetHashCode(), contentDisposition3.GetHashCode()); + Assert.NotEqual(contentDisposition2.GetHashCode(), contentDisposition3.GetHashCode()); + Assert.Equal(contentDisposition1.GetHashCode(), contentDisposition4.GetHashCode()); + Assert.Equal(contentDisposition2.GetHashCode(), contentDisposition5.GetHashCode()); + } + + [Fact] + public void Equals_UseContentDispositionWithAndWithoutParameters_EqualOrNotEqualNoExceptions() + { + var contentDisposition1 = new ContentDispositionHeaderValue("inline"); + var contentDisposition2 = new ContentDispositionHeaderValue("inline"); + contentDisposition2.Name = "myName"; + var contentDisposition3 = new ContentDispositionHeaderValue("inline"); + contentDisposition3.Parameters.Add(new NameValueHeaderValue("name", "value")); + var contentDisposition4 = new ContentDispositionHeaderValue("INLINE"); + var contentDisposition5 = new ContentDispositionHeaderValue("INLINE"); + contentDisposition5.Parameters.Add(new NameValueHeaderValue("NAME", "MYNAME")); + var contentDisposition6 = new ContentDispositionHeaderValue("INLINE"); + contentDisposition6.Parameters.Add(new NameValueHeaderValue("NAME", "MYNAME")); + contentDisposition6.Parameters.Add(new NameValueHeaderValue("custom", "value")); + var contentDisposition7 = new ContentDispositionHeaderValue("attachment"); + + Assert.False(contentDisposition1.Equals(contentDisposition2), "No params vs. name."); + Assert.False(contentDisposition2.Equals(contentDisposition1), "name vs. no params."); + Assert.False(contentDisposition1.Equals(null), "No params vs. ."); + Assert.False(contentDisposition1.Equals(contentDisposition3), "No params vs. custom param."); + Assert.False(contentDisposition2.Equals(contentDisposition3), "name vs. custom param."); + Assert.True(contentDisposition1.Equals(contentDisposition4), "Different casing."); + Assert.True(contentDisposition2.Equals(contentDisposition5), "Different casing in name."); + Assert.False(contentDisposition5.Equals(contentDisposition6), "name vs. custom param."); + Assert.False(contentDisposition1.Equals(contentDisposition7), "inline vs. text/other."); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + var expected = new ContentDispositionHeaderValue("inline"); + CheckValidParse("\r\n inline ", expected); + CheckValidParse("inline", expected); + + // We don't have to test all possible input strings, since most of the pieces are handled by other parsers. + // The purpose of this test is to verify that these other parsers are combined correctly to build a + // Content-Disposition parser. + expected.Name = "myName"; + CheckValidParse("\r\n inline ; name = myName ", expected); + CheckValidParse(" inline;name=myName", expected); + + expected.Name = null; + expected.DispositionType = "attachment"; + expected.FileName = "foo-ae.html"; + expected.Parameters.Add(new NameValueHeaderValue("filename*", "UTF-8''foo-%c3%a4.html")); + CheckValidParse(@"attachment; filename*=UTF-8''foo-%c3%a4.html; filename=foo-ae.html", expected); + } + + [Fact] + public void Parse_SetOfInvalidValueStrings_Throws() + { + CheckInvalidParse(""); + CheckInvalidParse(" "); + CheckInvalidParse(null); + CheckInvalidParse("inline会"); + CheckInvalidParse("inline ,"); + CheckInvalidParse("inline,"); + CheckInvalidParse("inline; name=myName ,"); + CheckInvalidParse("inline; name=myName,"); + CheckInvalidParse("inline; name=my会Name"); + CheckInvalidParse("inline/"); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + var expected = new ContentDispositionHeaderValue("inline"); + CheckValidTryParse("\r\n inline ", expected); + CheckValidTryParse("inline", expected); + + // We don't have to test all possible input strings, since most of the pieces are handled by other parsers. + // The purpose of this test is to verify that these other parsers are combined correctly to build a + // Content-Disposition parser. + expected.Name = "myName"; + CheckValidTryParse("\r\n inline ; name = myName ", expected); + CheckValidTryParse(" inline;name=myName", expected); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidTryParse(""); + CheckInvalidTryParse(" "); + CheckInvalidTryParse(null); + CheckInvalidTryParse("inline会"); + CheckInvalidTryParse("inline ,"); + CheckInvalidTryParse("inline,"); + CheckInvalidTryParse("inline; name=myName ,"); + CheckInvalidTryParse("inline; name=myName,"); + CheckInvalidTryParse("text/"); + } + + public static TheoryData ValidContentDispositionTestCases = new TheoryData() + { + { "inline", new ContentDispositionHeaderValue("inline") }, // @"This should be equivalent to not including the header at all." + { "inline;", new ContentDispositionHeaderValue("inline") }, + { "inline;name=", new ContentDispositionHeaderValue("inline") { Parameters = { new NameValueHeaderValue("name", "") } } }, // TODO: passing in a null value causes a strange assert on CoreCLR before the test even starts. Not reproducable in the body of a test. + { "inline;name=value", new ContentDispositionHeaderValue("inline") { Name = "value" } }, + { "inline;name=value;", new ContentDispositionHeaderValue("inline") { Name = "value" } }, + { "inline;name=value;", new ContentDispositionHeaderValue("inline") { Name = "value" } }, + { @"inline; filename=""foo.html""", new ContentDispositionHeaderValue("inline") { FileName = @"""foo.html""" } }, + { @"inline; filename=""Not an attachment!""", new ContentDispositionHeaderValue("inline") { FileName = @"""Not an attachment!""" } }, // 'inline', specifying a filename of Not an attachment! - this checks for proper parsing for disposition types. + { @"inline; filename=""foo.pdf""", new ContentDispositionHeaderValue("inline") { FileName = @"""foo.pdf""" } }, + { "attachment", new ContentDispositionHeaderValue("attachment") }, + { "ATTACHMENT", new ContentDispositionHeaderValue("ATTACHMENT") }, + { @"attachment; filename=""foo.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""foo.html""" } }, + { @"attachment; filename=""\""quoting\"" tested.html""", new ContentDispositionHeaderValue("attachment") { FileName = "\"\"quoting\" tested.html\"" } }, // 'attachment', specifying a filename of \"quoting\" tested.html (using double quotes around "quoting" to test... quoting) + { @"attachment; filename=""Here's a semicolon;.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""Here's a semicolon;.html""" } }, // , 'attachment', specifying a filename of Here's a semicolon;.html - this checks for proper parsing for parameters. + { @"attachment; foo=""bar""; filename=""foo.html""", new ContentDispositionHeaderValue(@"attachment") { FileName = @"""foo.html""", Parameters = { new NameValueHeaderValue("foo", @"""bar""") } } }, // 'attachment', specifying a filename of foo.html and an extension parameter "foo" which should be ignored (see Section 2.8 of RFC 2183.). + { @"attachment; foo=""\""\\"";filename=""foo.html""", new ContentDispositionHeaderValue(@"attachment") { FileName = @"""foo.html""", Parameters = { new NameValueHeaderValue("foo", @"""\""\\""") } } }, // 'attachment', specifying a filename of foo.html and an extension parameter "foo" which should be ignored (see Section 2.8 of RFC 2183.). The extension parameter actually uses backslash-escapes. This tests whether the UA properly skips the parameter. + { @"attachment; FILENAME=""foo.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""foo.html""" } }, + { @"attachment; filename=foo.html", new ContentDispositionHeaderValue("attachment") { FileName = "foo.html" } }, // 'attachment', specifying a filename of foo.html using a token instead of a quoted-string. + { @"attachment; filename='foo.bar'", new ContentDispositionHeaderValue("attachment") { FileName = "'foo.bar'" } }, // 'attachment', specifying a filename of 'foo.bar' using single quotes. + { @"attachment; filename=""foo-ä.html""", new ContentDispositionHeaderValue("attachment" ) { Parameters = { new NameValueHeaderValue("filename", @"""foo-ä.html""") } } }, // 'attachment', specifying a filename of foo-ä.html, using plain ISO-8859-1 + { @"attachment; filename=""foo-ä.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""foo-ä.html""" } }, // 'attachment', specifying a filename of foo-ä.html, which happens to be foo-ä.html using UTF-8 encoding. + { @"attachment; filename=""foo-%41.html""", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("filename", @"""foo-%41.html""") } } }, + { @"attachment; filename=""50%.html""", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("filename", @"""50%.html""") } } }, + { @"attachment; filename=""foo-%\41.html""", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("filename", @"""foo-%\41.html""") } } }, // 'attachment', specifying a filename of foo-%41.html, using an escape character (this tests whether adding an escape character inside a %xx sequence can be used to disable the non-conformant %xx-unescaping). + { @"attachment; name=""foo-%41.html""", new ContentDispositionHeaderValue("attachment") { Name = @"""foo-%41.html""" } }, // 'attachment', specifying a name parameter of foo-%41.html. (this test was added to observe the behavior of the (unspecified) treatment of ""name"" as synonym for ""filename""; see Ned Freed's summary where this comes from in MIME messages) + { @"attachment; filename=""ä-%41.html""", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("filename", @"""ä-%41.html""") } } }, // 'attachment', specifying a filename parameter of ä-%41.html. (this test was added to observe the behavior when non-ASCII characters and percent-hexdig sequences are combined) + { @"attachment; filename=""foo-%c3%a4-%e2%82%ac.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""foo-%c3%a4-%e2%82%ac.html""" } }, // 'attachment', specifying a filename of foo-%c3%a4-%e2%82%ac.html, using raw percent encoded UTF-8 to represent foo-ä-€.html + { @"attachment; filename =""foo.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""foo.html""" } }, + { @"attachment; xfilename=foo.html", new ContentDispositionHeaderValue("attachment" ) { Parameters = { new NameValueHeaderValue("xfilename", "foo.html") } } }, + { @"attachment; filename=""/foo.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""/foo.html""" } }, + { @"attachment; creation-date=""Wed, 12 Feb 1997 16:29:51 -0500""", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("creation-date", @"""Wed, 12 Feb 1997 16:29:51 -0500""") } } }, + { @"attachment; modification-date=""Wed, 12 Feb 1997 16:29:51 -0500""", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("modification-date", @"""Wed, 12 Feb 1997 16:29:51 -0500""") } } }, + { @"foobar", new ContentDispositionHeaderValue("foobar") }, // @"This should be equivalent to using ""attachment""." + { @"attachment; example=""filename=example.txt""", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("example", @"""filename=example.txt""") } } }, + { @"attachment; filename*=iso-8859-1''foo-%E4.html", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("filename*", "iso-8859-1''foo-%E4.html") } } }, // 'attachment', specifying a filename of foo-ä.html, using RFC2231 encoded ISO-8859-1 + { @"attachment; filename*=UTF-8''foo-%c3%a4-%e2%82%ac.html", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("filename*", "UTF-8''foo-%c3%a4-%e2%82%ac.html") } } }, // 'attachment', specifying a filename of foo-ä-€.html, using RFC2231 encoded UTF-8 + { @"attachment; filename*=''foo-%c3%a4-%e2%82%ac.html", new ContentDispositionHeaderValue("attachment") { Parameters = { new NameValueHeaderValue("filename*", "''foo-%c3%a4-%e2%82%ac.html") } } }, // Behavior is undefined in RFC 2231, the charset part is missing, although UTF-8 was used. + { @"attachment; filename*=UTF-8''foo-a%22.html", new ContentDispositionHeaderValue("attachment") { FileNameStar = @"foo-a"".html" } }, + { @"attachment; filename*= UTF-8''foo-%c3%a4.html", new ContentDispositionHeaderValue("attachment") { FileNameStar = "foo-ä.html" } }, + { @"attachment; filename* =UTF-8''foo-%c3%a4.html", new ContentDispositionHeaderValue("attachment") { FileNameStar = "foo-ä.html" } }, + { @"attachment; filename*=UTF-8''A-%2541.html", new ContentDispositionHeaderValue("attachment") { FileNameStar = "A-%41.html" } }, + { @"attachment; filename*=UTF-8''%5cfoo.html", new ContentDispositionHeaderValue("attachment") { FileNameStar = @"\foo.html" } }, + { @"attachment; filename=""foo-ae.html""; filename*=UTF-8''foo-%c3%a4.html", new ContentDispositionHeaderValue("attachment") { FileName = @"""foo-ae.html""", FileNameStar = "foo-ä.html" } }, + { @"attachment; filename*=UTF-8''foo-%c3%a4.html; filename=""foo-ae.html""", new ContentDispositionHeaderValue("attachment") { FileNameStar = "foo-ä.html", FileName = @"""foo-ae.html""" } }, + { @"attachment; foobar=x; filename=""foo.html""", new ContentDispositionHeaderValue("attachment") { FileName = @"""foo.html""", Parameters = { new NameValueHeaderValue("foobar", "x") } } }, + { @"attachment; filename=""=?ISO-8859-1?Q?foo-=E4.html?=""", new ContentDispositionHeaderValue("attachment") { FileName = @"""=?ISO-8859-1?Q?foo-=E4.html?=""" } }, // attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?=" + { @"attachment; filename=""=?utf-8?B?Zm9vLeQuaHRtbA==?=""", new ContentDispositionHeaderValue("attachment") { FileName = @"""=?utf-8?B?Zm9vLeQuaHRtbA==?=""" } }, // attachment; filename="=?utf-8?B?Zm9vLeQuaHRtbA==?=" + { @"attachment; filename=foo.html ;", new ContentDispositionHeaderValue("attachment") { FileName="foo.html" } }, // 'attachment', specifying a filename of foo.html using a token instead of a quoted-string, and adding a trailing semicolon., + }; + + [Theory] + [MemberData(nameof(ValidContentDispositionTestCases))] + public void ContentDispositionHeaderValue_ParseValid_Success(string input, ContentDispositionHeaderValue expected) + { + // System.Diagnostics.Debugger.Launch(); + var result = ContentDispositionHeaderValue.Parse(input); + Assert.Equal(expected, result); + } + + [Theory] + // Invalid values + [InlineData(@"""inline""")] // @"'inline' only, using double quotes", false) }, + [InlineData(@"""attachment""")] // @"'attachment' only, using double quotes", false) }, + [InlineData(@"attachment; filename=foo bar.html")] // @"'attachment', specifying a filename of foo bar.html without using quoting.", false) }, + // Duplicate file name parameter + // @"attachment; filename=""foo.html""; // filename=""bar.html""", @"'attachment', specifying two filename parameters. This is invalid syntax.", false) }, + [InlineData(@"attachment; filename=foo[1](2).html")] // @"'attachment', specifying a filename of foo[1](2).html, but missing the quotes. Also, ""["", ""]"", ""("" and "")"" are not allowed in the HTTP token production.", false) }, + [InlineData(@"attachment; filename=foo-ä.html")] // @"'attachment', specifying a filename of foo-ä.html, but missing the quotes.", false) }, + // HTML escaping, not supported + // @"attachment; filename=foo-ä.html", // "'attachment', specifying a filename of foo-ä.html (which happens to be foo-ä.html using UTF-8 encoding) but missing the quotes.", false) }, + [InlineData(@"filename=foo.html")] // @"Disposition type missing, filename specified.", false) }, + [InlineData(@"x=y; filename=foo.html")] // @"Disposition type missing, filename specified after extension parameter.", false) }, + [InlineData(@"""foo; filename=bar;baz""; filename=qux")] // @"Disposition type missing, filename ""qux"". Can it be more broken? (Probably)", false) }, + [InlineData(@"filename=foo.html, filename=bar.html")] // @"Disposition type missing, two filenames specified separated by a comma (this is syntactically equivalent to have two instances of the header with one filename parameter each).", false) }, + [InlineData(@"; filename=foo.html")] // @"Disposition type missing (but delimiter present), filename specified.", false) }, + // This is permitted as a parameter without a value + // @"inline; attachment; filename=foo.html", // @"Both disposition types specified.", false) }, + // This is permitted as a parameter without a value + // @"inline; attachment; filename=foo.html", // @"Both disposition types specified.", false) }, + [InlineData(@"attachment; filename=""foo.html"".txt")] // @"'attachment', specifying a filename parameter that is broken (quoted-string followed by more characters). This is invalid syntax. ", false) }, + [InlineData(@"attachment; filename=""bar")] // @"'attachment', specifying a filename parameter that is broken (missing ending double quote). This is invalid syntax.", false) }, + [InlineData(@"attachment; filename=foo""bar;baz""qux")] // @"'attachment', specifying a filename parameter that is broken (disallowed characters in token syntax). This is invalid syntax.", false) }, + [InlineData(@"attachment; filename=foo.html, attachment; filename=bar.html")] // @"'attachment', two comma-separated instances of the header field. As Content-Disposition doesn't use a list-style syntax, this is invalid syntax and, according to RFC 2616, Section 4.2, roughly equivalent to having two separate header field instances.", false) }, + [InlineData(@"filename=foo.html; attachment")] // @"filename parameter and disposition type reversed.", false) }, + // Escaping is not verified + // @"attachment; filename*=iso-8859-1''foo-%c3%a4-%e2%82%ac.html", // @"'attachment', specifying a filename of foo-ä-€.html, using RFC2231 encoded UTF-8, but declaring ISO-8859-1", false) }, + // Escaping is not verified + // @"attachment; filename *=UTF-8''foo-%c3%a4.html", // @"'attachment', specifying a filename of foo-ä.html, using RFC2231 encoded UTF-8, with whitespace before ""*=""", false) }, + // Escaping is not verified + // @"attachment; filename*=""UTF-8''foo-%c3%a4.html""", // @"'attachment', specifying a filename of foo-ä.html, using RFC2231 encoded UTF-8, with double quotes around the parameter value.", false) }, + [InlineData(@"attachment; filename==?ISO-8859-1?Q?foo-=E4.html?=")] // @"Uses RFC 2047 style encoded word. ""="" is invalid inside the token production, so this is invalid.", false) }, + [InlineData(@"attachment; filename==?utf-8?B?Zm9vLeQuaHRtbA==?=")] // @"Uses RFC 2047 style encoded word. ""="" is invalid inside the token production, so this is invalid.", false) }, + public void ContentDispositionHeaderValue_ParseInvalid_Throws(string input) + { + Assert.Throws(() => ContentDispositionHeaderValue.Parse(input)); + } + + public class ContentDispositionValue + { + public ContentDispositionValue(string value, string description, bool valid) + { + Value = value; + Description = description; + Valid = valid; + } + + public string Value { get; private set; } + + public string Description { get; private set; } + + public bool Valid { get; private set; } + } + + private void CheckValidParse(string input, ContentDispositionHeaderValue expectedResult) + { + var result = ContentDispositionHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidParse(string input) + { + Assert.Throws(() => ContentDispositionHeaderValue.Parse(input)); + } + + private void CheckValidTryParse(string input, ContentDispositionHeaderValue expectedResult) + { + ContentDispositionHeaderValue result = null; + Assert.True(ContentDispositionHeaderValue.TryParse(input, out result), input); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidTryParse(string input) + { + ContentDispositionHeaderValue result = null; + Assert.False(ContentDispositionHeaderValue.TryParse(input, out result), input); + Assert.Null(result); + } + + private static void AssertFormatException(string contentDisposition) + { + Assert.Throws(() => new ContentDispositionHeaderValue(contentDisposition)); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/ContentRangeHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/ContentRangeHeaderValueTest.cs new file mode 100644 index 0000000000..d479f4d46f --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/ContentRangeHeaderValueTest.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class ContentRangeHeaderValueTest + { + [Fact] + public void Ctor_LengthOnlyOverloadUseInvalidValues_Throw() + { + Assert.Throws(() => new ContentRangeHeaderValue(-1)); + } + + [Fact] + public void Ctor_LengthOnlyOverloadValidValues_ValuesCorrectlySet() + { + var range = new ContentRangeHeaderValue(5); + + Assert.False(range.HasRange, "HasRange"); + Assert.True(range.HasLength, "HasLength"); + Assert.Equal("bytes", range.Unit); + Assert.Null(range.From); + Assert.Null(range.To); + Assert.Equal(5, range.Length); + } + + [Fact] + public void Ctor_FromAndToOverloadUseInvalidValues_Throw() + { + Assert.Throws(() => new ContentRangeHeaderValue(-1, 1)); + Assert.Throws(() => new ContentRangeHeaderValue(0, -1)); + Assert.Throws(() => new ContentRangeHeaderValue(2, 1)); + } + + [Fact] + public void Ctor_FromAndToOverloadValidValues_ValuesCorrectlySet() + { + var range = new ContentRangeHeaderValue(0, 1); + + Assert.True(range.HasRange, "HasRange"); + Assert.False(range.HasLength, "HasLength"); + Assert.Equal("bytes", range.Unit); + Assert.Equal(0, range.From); + Assert.Equal(1, range.To); + Assert.Null(range.Length); + } + + [Fact] + public void Ctor_FromToAndLengthOverloadUseInvalidValues_Throw() + { + Assert.Throws(() => new ContentRangeHeaderValue(-1, 1, 2)); + Assert.Throws(() => new ContentRangeHeaderValue(0, -1, 2)); + Assert.Throws(() => new ContentRangeHeaderValue(0, 1, -1)); + Assert.Throws(() => new ContentRangeHeaderValue(2, 1, 3)); + Assert.Throws(() => new ContentRangeHeaderValue(1, 2, 1)); + } + + [Fact] + public void Ctor_FromToAndLengthOverloadValidValues_ValuesCorrectlySet() + { + var range = new ContentRangeHeaderValue(0, 1, 2); + + Assert.True(range.HasRange, "HasRange"); + Assert.True(range.HasLength, "HasLength"); + Assert.Equal("bytes", range.Unit); + Assert.Equal(0, range.From); + Assert.Equal(1, range.To); + Assert.Equal(2, range.Length); + } + + [Fact] + public void Unit_GetAndSetValidAndInvalidValues_MatchExpectation() + { + var range = new ContentRangeHeaderValue(0); + range.Unit = "myunit"; + Assert.Equal("myunit", range.Unit); + + Assert.Throws(() => range.Unit = null); + Assert.Throws(() => range.Unit = ""); + Assert.Throws(() => range.Unit = " x"); + Assert.Throws(() => range.Unit = "x "); + Assert.Throws(() => range.Unit = "x y"); + } + + [Fact] + public void ToString_UseDifferentRanges_AllSerializedCorrectly() + { + var range = new ContentRangeHeaderValue(1, 2, 3); + range.Unit = "myunit"; + Assert.Equal("myunit 1-2/3", range.ToString()); + + range = new ContentRangeHeaderValue(123456789012345678, 123456789012345679); + Assert.Equal("bytes 123456789012345678-123456789012345679/*", range.ToString()); + + range = new ContentRangeHeaderValue(150); + Assert.Equal("bytes */150", range.ToString()); + } + + [Fact] + public void GetHashCode_UseSameAndDifferentRanges_SameOrDifferentHashCodes() + { + var range1 = new ContentRangeHeaderValue(1, 2, 5); + var range2 = new ContentRangeHeaderValue(1, 2); + var range3 = new ContentRangeHeaderValue(5); + var range4 = new ContentRangeHeaderValue(1, 2, 5); + range4.Unit = "BYTES"; + var range5 = new ContentRangeHeaderValue(1, 2, 5); + range5.Unit = "myunit"; + + Assert.NotEqual(range1.GetHashCode(), range2.GetHashCode()); + Assert.NotEqual(range1.GetHashCode(), range3.GetHashCode()); + Assert.NotEqual(range2.GetHashCode(), range3.GetHashCode()); + Assert.Equal(range1.GetHashCode(), range4.GetHashCode()); + Assert.NotEqual(range1.GetHashCode(), range5.GetHashCode()); + } + + [Fact] + public void Equals_UseSameAndDifferentRanges_EqualOrNotEqualNoExceptions() + { + var range1 = new ContentRangeHeaderValue(1, 2, 5); + var range2 = new ContentRangeHeaderValue(1, 2); + var range3 = new ContentRangeHeaderValue(5); + var range4 = new ContentRangeHeaderValue(1, 2, 5); + range4.Unit = "BYTES"; + var range5 = new ContentRangeHeaderValue(1, 2, 5); + range5.Unit = "myunit"; + var range6 = new ContentRangeHeaderValue(1, 3, 5); + var range7 = new ContentRangeHeaderValue(2, 2, 5); + var range8 = new ContentRangeHeaderValue(1, 2, 6); + + Assert.False(range1.Equals(null), "bytes 1-2/5 vs. "); + Assert.False(range1.Equals(range2), "bytes 1-2/5 vs. bytes 1-2/*"); + Assert.False(range1.Equals(range3), "bytes 1-2/5 vs. bytes */5"); + Assert.False(range2.Equals(range3), "bytes 1-2/* vs. bytes */5"); + Assert.True(range1.Equals(range4), "bytes 1-2/5 vs. BYTES 1-2/5"); + Assert.True(range4.Equals(range1), "BYTES 1-2/5 vs. bytes 1-2/5"); + Assert.False(range1.Equals(range5), "bytes 1-2/5 vs. myunit 1-2/5"); + Assert.False(range1.Equals(range6), "bytes 1-2/5 vs. bytes 1-3/5"); + Assert.False(range1.Equals(range7), "bytes 1-2/5 vs. bytes 2-2/5"); + Assert.False(range1.Equals(range8), "bytes 1-2/5 vs. bytes 1-2/6"); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidParse(" bytes 1-2/3 ", new ContentRangeHeaderValue(1, 2, 3)); + CheckValidParse("bytes * / 3", new ContentRangeHeaderValue(3)); + + CheckValidParse(" custom 1234567890123456789-1234567890123456799/*", + new ContentRangeHeaderValue(1234567890123456789, 1234567890123456799) { Unit = "custom" }); + + CheckValidParse(" custom * / 123 ", + new ContentRangeHeaderValue(123) { Unit = "custom" }); + + // Note that we don't have a public constructor for value 'bytes */*' since the RFC doesn't mention a + // scenario for it. However, if a server returns this value, we're flexible and accept it. + var result = ContentRangeHeaderValue.Parse("bytes */*"); + Assert.Equal("bytes", result.Unit); + Assert.Null(result.From); + Assert.Null(result.To); + Assert.Null(result.Length); + Assert.False(result.HasRange, "HasRange"); + Assert.False(result.HasLength, "HasLength"); + } + + [Theory] + [InlineData("bytes 1-2/3,")] // no character after 'length' allowed + [InlineData("x bytes 1-2/3")] + [InlineData("bytes 1-2/3.4")] + [InlineData(null)] + [InlineData("")] + [InlineData("bytes 3-2/5")] + [InlineData("bytes 6-6/5")] + [InlineData("bytes 1-6/5")] + [InlineData("bytes 1-2/")] + [InlineData("bytes 1-2")] + [InlineData("bytes 1-/")] + [InlineData("bytes 1-")] + [InlineData("bytes 1")] + [InlineData("bytes ")] + [InlineData("bytes a-2/3")] + [InlineData("bytes 1-b/3")] + [InlineData("bytes 1-2/c")] + [InlineData("bytes1-2/3")] + // More than 19 digits >>Int64.MaxValue + [InlineData("bytes 1-12345678901234567890/3")] + [InlineData("bytes 12345678901234567890-3/3")] + [InlineData("bytes 1-2/12345678901234567890")] + // Exceed Int64.MaxValue, but use 19 digits + [InlineData("bytes 1-9999999999999999999/3")] + [InlineData("bytes 9999999999999999999-3/3")] + [InlineData("bytes 1-2/9999999999999999999")] + public void Parse_SetOfInvalidValueStrings_Throws(string input) + { + Assert.Throws(() => ContentRangeHeaderValue.Parse(input)); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidTryParse(" bytes 1-2/3 ", new ContentRangeHeaderValue(1, 2, 3)); + CheckValidTryParse("bytes * / 3", new ContentRangeHeaderValue(3)); + + CheckValidTryParse(" custom 1234567890123456789-1234567890123456799/*", + new ContentRangeHeaderValue(1234567890123456789, 1234567890123456799) { Unit = "custom" }); + + CheckValidTryParse(" custom * / 123 ", + new ContentRangeHeaderValue(123) { Unit = "custom" }); + + // Note that we don't have a public constructor for value 'bytes */*' since the RFC doesn't mention a + // scenario for it. However, if a server returns this value, we're flexible and accept it. + ContentRangeHeaderValue result = null; + Assert.True(ContentRangeHeaderValue.TryParse("bytes */*", out result)); + Assert.Equal("bytes", result.Unit); + Assert.Null(result.From); + Assert.Null(result.To); + Assert.Null(result.Length); + Assert.False(result.HasRange, "HasRange"); + Assert.False(result.HasLength, "HasLength"); + } + + [Theory] + [InlineData("bytes 1-2/3,")] // no character after 'length' allowed + [InlineData("x bytes 1-2/3")] + [InlineData("bytes 1-2/3.4")] + [InlineData(null)] + [InlineData("")] + [InlineData("bytes 3-2/5")] + [InlineData("bytes 6-6/5")] + [InlineData("bytes 1-6/5")] + [InlineData("bytes 1-2/")] + [InlineData("bytes 1-2")] + [InlineData("bytes 1-/")] + [InlineData("bytes 1-")] + [InlineData("bytes 1")] + [InlineData("bytes ")] + [InlineData("bytes a-2/3")] + [InlineData("bytes 1-b/3")] + [InlineData("bytes 1-2/c")] + [InlineData("bytes1-2/3")] + // More than 19 digits >>Int64.MaxValue + [InlineData("bytes 1-12345678901234567890/3")] + [InlineData("bytes 12345678901234567890-3/3")] + [InlineData("bytes 1-2/12345678901234567890")] + // Exceed Int64.MaxValue, but use 19 digits + [InlineData("bytes 1-9999999999999999999/3")] + [InlineData("bytes 9999999999999999999-3/3")] + [InlineData("bytes 1-2/9999999999999999999")] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse(string input) + { + ContentRangeHeaderValue result = null; + Assert.False(ContentRangeHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + private void CheckValidParse(string input, ContentRangeHeaderValue expectedResult) + { + var result = ContentRangeHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckValidTryParse(string input, ContentRangeHeaderValue expectedResult) + { + ContentRangeHeaderValue result = null; + Assert.True(ContentRangeHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/CookieHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/CookieHeaderValueTest.cs new file mode 100644 index 0000000000..9a25389a50 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/CookieHeaderValueTest.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class CookieHeaderValueTest + { + public static TheoryData CookieHeaderDataSet + { + get + { + var dataset = new TheoryData(); + var header1 = new CookieHeaderValue("name1", "n1=v1&n2=v2&n3=v3"); + dataset.Add(header1, "name1=n1=v1&n2=v2&n3=v3"); + + var header2 = new CookieHeaderValue("name2", ""); + dataset.Add(header2, "name2="); + + var header3 = new CookieHeaderValue("name3", "value3"); + dataset.Add(header3, "name3=value3"); + + var header4 = new CookieHeaderValue("name4", "\"value4\""); + dataset.Add(header4, "name4=\"value4\""); + + return dataset; + } + } + + public static TheoryData InvalidCookieHeaderDataSet + { + get + { + return new TheoryData + { + "=value", + "name=value;", + "name=value,", + }; + } + } + + public static TheoryData InvalidCookieNames + { + get + { + return new TheoryData + { + "", + "{acb}", + "[acb]", + "\"acb\"", + "a,b", + "a;b", + "a\\b", + "a b", + }; + } + } + + public static TheoryData InvalidCookieValues + { + get + { + return new TheoryData + { + { "\"" }, + { "a,b" }, + { "a;b" }, + { "a\\b" }, + { "\"abc" }, + { "a\"bc" }, + { "abc\"" }, + { "a b" }, + }; + } + } + public static TheoryData, string[]> ListOfCookieHeaderDataSet + { + get + { + var dataset = new TheoryData, string[]>(); + var header1 = new CookieHeaderValue("name1", "n1=v1&n2=v2&n3=v3"); + var string1 = "name1=n1=v1&n2=v2&n3=v3"; + + var header2 = new CookieHeaderValue("name2", "value2"); + var string2 = "name2=value2"; + + var header3 = new CookieHeaderValue("name3", "value3"); + var string3 = "name3=value3"; + + var header4 = new CookieHeaderValue("name4", "\"value4\""); + var string4 = "name4=\"value4\""; + + dataset.Add(new[] { header1 }.ToList(), new[] { string1 }); + dataset.Add(new[] { header1, header1 }.ToList(), new[] { string1, string1 }); + dataset.Add(new[] { header1, header1 }.ToList(), new[] { string1, null, "", " ", ";", " , ", string1 }); + dataset.Add(new[] { header2 }.ToList(), new[] { string2 }); + dataset.Add(new[] { header1, header2 }.ToList(), new[] { string1, string2 }); + dataset.Add(new[] { header1, header2 }.ToList(), new[] { string1 + ", " + string2 }); + dataset.Add(new[] { header2, header1 }.ToList(), new[] { string2 + "; " + string1 }); + dataset.Add(new[] { header1, header2, header3, header4 }.ToList(), new[] { string1, string2, string3, string4 }); + dataset.Add(new[] { header1, header2, header3, header4 }.ToList(), new[] { string.Join(",", string1, string2, string3, string4) }); + dataset.Add(new[] { header1, header2, header3, header4 }.ToList(), new[] { string.Join(";", string1, string2, string3, string4) }); + + return dataset; + } + } + + // TODO: [Fact] + public void CookieHeaderValue_CtorThrowsOnNullName() + { + Assert.Throws(() => new CookieHeaderValue(null, "value")); + } + + [Theory] + [MemberData(nameof(InvalidCookieNames))] + public void CookieHeaderValue_CtorThrowsOnInvalidName(string name) + { + Assert.Throws(() => new CookieHeaderValue(name, "value")); + } + + [Theory] + [MemberData(nameof(InvalidCookieValues))] + public void CookieHeaderValue_CtorThrowsOnInvalidValue(string value) + { + Assert.Throws(() => new CookieHeaderValue("name", value)); + } + + [Fact] + public void CookieHeaderValue_Ctor1_InitializesCorrectly() + { + var header = new CookieHeaderValue("cookie"); + Assert.Equal("cookie", header.Name); + Assert.Equal(string.Empty, header.Value); + } + + [Theory] + [InlineData("name", "")] + [InlineData("name", "value")] + [InlineData("name", "\"acb\"")] + public void CookieHeaderValue_Ctor2InitializesCorrectly(string name, string value) + { + var header = new CookieHeaderValue(name, value); + Assert.Equal(name, header.Name); + Assert.Equal(value, header.Value); + } + + [Fact] + public void CookieHeaderValue_Value() + { + var cookie = new CookieHeaderValue("name"); + Assert.Equal(String.Empty, cookie.Value); + + cookie.Value = "value1"; + Assert.Equal("value1", cookie.Value); + } + + [Theory] + [MemberData(nameof(CookieHeaderDataSet))] + public void CookieHeaderValue_ToString(CookieHeaderValue input, string expectedValue) + { + Assert.Equal(expectedValue, input.ToString()); + } + + [Theory] + [MemberData(nameof(CookieHeaderDataSet))] + public void CookieHeaderValue_Parse_AcceptsValidValues(CookieHeaderValue cookie, string expectedValue) + { + var header = CookieHeaderValue.Parse(expectedValue); + + Assert.Equal(cookie, header); + Assert.Equal(expectedValue, header.ToString()); + } + + [Theory] + [MemberData(nameof(CookieHeaderDataSet))] + public void CookieHeaderValue_TryParse_AcceptsValidValues(CookieHeaderValue cookie, string expectedValue) + { + CookieHeaderValue header; + bool result = CookieHeaderValue.TryParse(expectedValue, out header); + Assert.True(result); + + Assert.Equal(cookie, header); + Assert.Equal(expectedValue, header.ToString()); + } + + [Theory] + [MemberData(nameof(InvalidCookieHeaderDataSet))] + public void CookieHeaderValue_Parse_RejectsInvalidValues(string value) + { + Assert.Throws(() => CookieHeaderValue.Parse(value)); + } + + [Theory] + [MemberData(nameof(InvalidCookieHeaderDataSet))] + public void CookieHeaderValue_TryParse_RejectsInvalidValues(string value) + { + CookieHeaderValue header; + bool result = CookieHeaderValue.TryParse(value, out header); + + Assert.False(result); + } + + [Theory] + [MemberData(nameof(ListOfCookieHeaderDataSet))] + public void CookieHeaderValue_ParseList_AcceptsValidValues(IList cookies, string[] input) + { + var results = CookieHeaderValue.ParseList(input); + + Assert.Equal(cookies, results); + } + + [Theory] + [MemberData(nameof(ListOfCookieHeaderDataSet))] + public void CookieHeaderValue_TryParseList_AcceptsValidValues(IList cookies, string[] input) + { + IList results; + bool result = CookieHeaderValue.TryParseList(input, out results); + Assert.True(result); + + Assert.Equal(cookies, results); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/DateParserTest.cs b/test/Microsoft.Net.Http.Headers.Tests/DateParserTest.cs new file mode 100644 index 0000000000..61479e9adf --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/DateParserTest.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class DateParserTest + { + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + // We don't need to validate all possible date values, since they're already tested in HttpRuleParserTest. + // Just make sure the parser calls HttpRuleParser methods correctly. + CheckValidParsedValue("Tue, 15 Nov 1994 08:12:31 GMT", new DateTimeOffset(1994, 11, 15, 8, 12, 31, TimeSpan.Zero)); + CheckValidParsedValue(" Sunday, 06-Nov-94 08:49:37 GMT ", new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero)); + CheckValidParsedValue(" Tue,\r\n 15 Nov\r\n 1994 08:12:31 GMT ", new DateTimeOffset(1994, 11, 15, 8, 12, 31, TimeSpan.Zero)); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidParsedValue(null); + CheckInvalidParsedValue(string.Empty); + CheckInvalidParsedValue(" "); + CheckInvalidParsedValue("!!Sunday, 06-Nov-94 08:49:37 GMT"); + } + + [Fact] + public void ToString_UseDifferentValues_MatchExpectation() + { + Assert.Equal("Sat, 31 Jul 2010 15:38:57 GMT", + HeaderUtilities.FormatDate(new DateTimeOffset(2010, 7, 31, 15, 38, 57, TimeSpan.Zero))); + + Assert.Equal("Fri, 01 Jan 2010 01:01:01 GMT", + HeaderUtilities.FormatDate(new DateTimeOffset(2010, 1, 1, 1, 1, 1, TimeSpan.Zero))); + } + + #region Helper methods + + private void CheckValidParsedValue(string input, DateTimeOffset expectedResult) + { + DateTimeOffset result; + Assert.True(HeaderUtilities.TryParseDate(input, out result)); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidParsedValue(string input) + { + DateTimeOffset result; + Assert.False(HeaderUtilities.TryParseDate(input, out result)); + Assert.Equal(new DateTimeOffset(), result); + } + + #endregion + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/EntityTagHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/EntityTagHeaderValueTest.cs new file mode 100644 index 0000000000..d94ce6409f --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/EntityTagHeaderValueTest.cs @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class EntityTagHeaderValueTest + { + [Fact] + public void Ctor_ETagNull_Throw() + { + Assert.Throws(() => new EntityTagHeaderValue(null)); + // null and empty should be treated the same. So we also throw for empty strings. + Assert.Throws(() => new EntityTagHeaderValue(string.Empty)); + } + + [Fact] + public void Ctor_ETagInvalidFormat_ThrowFormatException() + { + // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed. + AssertFormatException("tag"); + AssertFormatException(" tag "); + AssertFormatException("\"tag\" invalid"); + AssertFormatException("\"tag"); + AssertFormatException("tag\""); + AssertFormatException("\"tag\"\""); + AssertFormatException("\"\"tag\"\""); + AssertFormatException("\"\"tag\""); + AssertFormatException("W/\"tag\""); // tag value must not contain 'W/' + } + + [Fact] + public void Ctor_ETagValidFormat_SuccessfullyCreated() + { + var etag = new EntityTagHeaderValue("\"tag\""); + Assert.Equal("\"tag\"", etag.Tag); + Assert.False(etag.IsWeak, "IsWeak"); + } + + [Fact] + public void Ctor_ETagValidFormatAndIsWeak_SuccessfullyCreated() + { + var etag = new EntityTagHeaderValue("\"e tag\"", true); + Assert.Equal("\"e tag\"", etag.Tag); + Assert.True(etag.IsWeak, "IsWeak"); + } + + [Fact] + public void ToString_UseDifferentETags_AllSerializedCorrectly() + { + var etag = new EntityTagHeaderValue("\"e tag\""); + Assert.Equal("\"e tag\"", etag.ToString()); + + etag = new EntityTagHeaderValue("\"e tag\"", true); + Assert.Equal("W/\"e tag\"", etag.ToString()); + + etag = new EntityTagHeaderValue("\"\"", false); + Assert.Equal("\"\"", etag.ToString()); + } + + [Fact] + public void GetHashCode_UseSameAndDifferentETags_SameOrDifferentHashCodes() + { + var etag1 = new EntityTagHeaderValue("\"tag\""); + var etag2 = new EntityTagHeaderValue("\"TAG\""); + var etag3 = new EntityTagHeaderValue("\"tag\"", true); + var etag4 = new EntityTagHeaderValue("\"tag1\""); + var etag5 = new EntityTagHeaderValue("\"tag\""); + var etag6 = EntityTagHeaderValue.Any; + + Assert.NotEqual(etag1.GetHashCode(), etag2.GetHashCode()); + Assert.NotEqual(etag1.GetHashCode(), etag3.GetHashCode()); + Assert.NotEqual(etag1.GetHashCode(), etag4.GetHashCode()); + Assert.NotEqual(etag1.GetHashCode(), etag6.GetHashCode()); + Assert.Equal(etag1.GetHashCode(), etag5.GetHashCode()); + } + + [Fact] + public void Equals_UseSameAndDifferentETags_EqualOrNotEqualNoExceptions() + { + var etag1 = new EntityTagHeaderValue("\"tag\""); + var etag2 = new EntityTagHeaderValue("\"TAG\""); + var etag3 = new EntityTagHeaderValue("\"tag\"", true); + var etag4 = new EntityTagHeaderValue("\"tag1\""); + var etag5 = new EntityTagHeaderValue("\"tag\""); + var etag6 = EntityTagHeaderValue.Any; + + Assert.False(etag1.Equals(etag2), "Different casing."); + Assert.False(etag2.Equals(etag1), "Different casing."); + Assert.False(etag1.Equals(null), "tag vs. ."); + Assert.False(etag1.Equals(etag3), "strong vs. weak."); + Assert.False(etag3.Equals(etag1), "weak vs. strong."); + Assert.False(etag1.Equals(etag4), "tag vs. tag1."); + Assert.False(etag1.Equals(etag6), "tag vs. *."); + Assert.True(etag1.Equals(etag5), "tag vs. tag.."); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidParse("\"tag\"", new EntityTagHeaderValue("\"tag\"")); + CheckValidParse(" \"tag\" ", new EntityTagHeaderValue("\"tag\"")); + CheckValidParse("\r\n \"tag\"\r\n ", new EntityTagHeaderValue("\"tag\"")); + CheckValidParse("\"tag\"", new EntityTagHeaderValue("\"tag\"")); + CheckValidParse("\"tag会\"", new EntityTagHeaderValue("\"tag会\"")); + CheckValidParse("W/\"tag\"", new EntityTagHeaderValue("\"tag\"", true)); + CheckValidParse("*", new EntityTagHeaderValue("*")); + } + + [Fact] + public void Parse_SetOfInvalidValueStrings_Throws() + { + CheckInvalidParse(null); + CheckInvalidParse(string.Empty); + CheckInvalidParse(" "); + CheckInvalidParse(" !"); + CheckInvalidParse("tag\" !"); + CheckInvalidParse("!\"tag\""); + CheckInvalidParse("\"tag\","); + CheckInvalidParse("W"); + CheckInvalidParse("W/"); + CheckInvalidParse("W/\""); + CheckInvalidParse("\"tag\" \"tag2\""); + CheckInvalidParse("/\"tag\""); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidTryParse("\"tag\"", new EntityTagHeaderValue("\"tag\"")); + CheckValidTryParse(" \"tag\" ", new EntityTagHeaderValue("\"tag\"")); + CheckValidTryParse("\r\n \"tag\"\r\n ", new EntityTagHeaderValue("\"tag\"")); + CheckValidTryParse("\"tag\"", new EntityTagHeaderValue("\"tag\"")); + CheckValidTryParse("\"tag会\"", new EntityTagHeaderValue("\"tag会\"")); + CheckValidTryParse("W/\"tag\"", new EntityTagHeaderValue("\"tag\"", true)); + CheckValidTryParse("*", new EntityTagHeaderValue("*")); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidTryParse(null); + CheckInvalidTryParse(string.Empty); + CheckInvalidTryParse(" "); + CheckInvalidTryParse(" !"); + CheckInvalidTryParse("tag\" !"); + CheckInvalidTryParse("!\"tag\""); + CheckInvalidTryParse("\"tag\","); + CheckInvalidTryParse("\"tag\" \"tag2\""); + CheckInvalidTryParse("/\"tag\""); + } + + [Fact] + public void ParseList_NullOrEmptyArray_ReturnsEmptyList() + { + var result = EntityTagHeaderValue.ParseList(null); + Assert.NotNull(result); + Assert.Equal(0, result.Count); + + result = EntityTagHeaderValue.ParseList(new string[0]); + Assert.NotNull(result); + Assert.Equal(0, result.Count); + + result = EntityTagHeaderValue.ParseList(new string[] { "" }); + Assert.NotNull(result); + Assert.Equal(0, result.Count); + } + + [Fact] + public void TryParseList_NullOrEmptyArray_ReturnsFalse() + { + IList results = null; + Assert.False(EntityTagHeaderValue.TryParseList(null, out results)); + Assert.False(EntityTagHeaderValue.TryParseList(new string[0], out results)); + Assert.False(EntityTagHeaderValue.TryParseList(new string[] { "" }, out results)); + } + + [Fact] + public void ParseList_SetOfValidValueStrings_ParsedCorrectly() + { + var inputs = new[] + { + "", + "\"tag\"", + "", + " \"tag\" ", + "\r\n \"tag\"\r\n ", + "\"tag会\"", + "\"tag\",\"tag\"", + "\"tag\", \"tag\"", + "W/\"tag\"", + }; + IList results = EntityTagHeaderValue.ParseList(inputs); + + var expectedResults = new[] + { + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag会\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\"", true), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void TryParseList_SetOfValidValueStrings_ParsedCorrectly() + { + var inputs = new[] + { + "", + "\"tag\"", + "", + " \"tag\" ", + "\r\n \"tag\"\r\n ", + "\"tag会\"", + "\"tag\",\"tag\"", + "\"tag\", \"tag\"", + "W/\"tag\"", + }; + IList results; + Assert.True(EntityTagHeaderValue.TryParseList(inputs, out results)); + var expectedResults = new[] + { + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag会\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\""), + new EntityTagHeaderValue("\"tag\"", true), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void ParseList_WithSomeInvlaidValues_Throws() + { + var inputs = new[] + { + "", + "\"tag\", tag, \"tag\"", + "tag, \"tag\"", + "", + " \"tag ", + "\r\n tag\"\r\n ", + "\"tag会\"", + "\"tag\", \"tag\"", + "W/\"tag\"", + }; + Assert.Throws(() => EntityTagHeaderValue.ParseList(inputs)); + } + + [Fact] + public void TryParseList_WithSomeInvlaidValues_ReturnsFalse() + { + var inputs = new[] + { + "", + "\"tag\", tag, \"tag\"", + "tag, \"tag\"", + "", + " \"tag ", + "\r\n tag\"\r\n ", + "\"tag会\"", + "\"tag\", \"tag\"", + "W/\"tag\"", + }; + IList results; + Assert.False(EntityTagHeaderValue.TryParseList(inputs, out results)); + } + + private void CheckValidParse(string input, EntityTagHeaderValue expectedResult) + { + var result = EntityTagHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidParse(string input) + { + Assert.Throws(() => EntityTagHeaderValue.Parse(input)); + } + + private void CheckValidTryParse(string input, EntityTagHeaderValue expectedResult) + { + EntityTagHeaderValue result = null; + Assert.True(EntityTagHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidTryParse(string input) + { + EntityTagHeaderValue result = null; + Assert.False(EntityTagHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + private static void AssertFormatException(string tag) + { + Assert.Throws(() => new EntityTagHeaderValue(tag)); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueComparerTests.cs b/test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueComparerTests.cs new file mode 100644 index 0000000000..8ef17a55aa --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueComparerTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class MediaTypeHeaderValueComparerTests + { + public static IEnumerable SortValues + { + get + { + yield return new object[] { + new string[] + { + "application/*", + "text/plain", + "text/plain;q=1.0", + "text/plain", + "text/plain;q=0", + "*/*;q=0.8", + "*/*;q=1", + "text/*;q=1", + "text/plain;q=0.8", + "text/*;q=0.8", + "text/*;q=0.6", + "text/*;q=1.0", + "*/*;q=0.4", + "text/plain;q=0.6", + "text/xml", + }, + new string[] + { + "text/plain", + "text/plain;q=1.0", + "text/plain", + "text/xml", + "application/*", + "text/*;q=1", + "text/*;q=1.0", + "*/*;q=1", + "text/plain;q=0.8", + "text/*;q=0.8", + "*/*;q=0.8", + "text/plain;q=0.6", + "text/*;q=0.6", + "*/*;q=0.4", + "text/plain;q=0", + } + }; + } + } + + [Theory] + [MemberData(nameof(SortValues))] + public void SortMediaTypeHeaderValuesByQFactor_SortsCorrectly(IEnumerable unsorted, IEnumerable expectedSorted) + { + var unsortedValues = MediaTypeHeaderValue.ParseList(unsorted.ToList()); + var expectedSortedValues = MediaTypeHeaderValue.ParseList(expectedSorted.ToList()); + + var actualSorted = unsortedValues.OrderByDescending(m => m, MediaTypeHeaderValueComparer.QualityComparer).ToList(); + + Assert.Equal(expectedSortedValues, actualSorted); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueTest.cs new file mode 100644 index 0000000000..140d629497 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/MediaTypeHeaderValueTest.cs @@ -0,0 +1,520 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class MediaTypeHeaderValueTest + { + [Fact] + public void Ctor_MediaTypeNull_Throw() + { + Assert.Throws(() => new MediaTypeHeaderValue(null)); + // null and empty should be treated the same. So we also throw for empty strings. + Assert.Throws(() => new MediaTypeHeaderValue(string.Empty)); + } + + [Fact] + public void Ctor_MediaTypeInvalidFormat_ThrowFormatException() + { + // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed. + AssertFormatException(" text/plain "); + AssertFormatException("text / plain"); + AssertFormatException("text/ plain"); + AssertFormatException("text /plain"); + AssertFormatException("text/plain "); + AssertFormatException(" text/plain"); + AssertFormatException("te xt/plain"); + AssertFormatException("te=xt/plain"); + AssertFormatException("teäxt/plain"); + AssertFormatException("text/pläin"); + AssertFormatException("text"); + AssertFormatException("\"text/plain\""); + AssertFormatException("text/plain; charset=utf-8; "); + AssertFormatException("text/plain;"); + AssertFormatException("text/plain;charset=utf-8"); // ctor takes only media-type name, no parameters + } + + [Fact] + public void Ctor_MediaTypeValidFormat_SuccessfullyCreated() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + Assert.Equal("text/plain", mediaType.MediaType); + Assert.Equal(0, mediaType.Parameters.Count); + Assert.Null(mediaType.Charset); + } + + [Fact] + public void Ctor_AddNameAndQuality_QualityParameterAdded() + { + var mediaType = new MediaTypeHeaderValue("application/xml", 0.08); + Assert.Equal(0.08, mediaType.Quality); + Assert.Equal("application/xml", mediaType.MediaType); + Assert.Equal(1, mediaType.Parameters.Count); + } + + [Fact] + public void Parameters_AddNull_Throw() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + Assert.Throws(() => mediaType.Parameters.Add(null)); + } + + [Fact] + public void MediaType_SetAndGetMediaType_MatchExpectations() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + Assert.Equal("text/plain", mediaType.MediaType); + + mediaType.MediaType = "application/xml"; + Assert.Equal("application/xml", mediaType.MediaType); + } + + [Fact] + public void Charset_SetCharsetAndValidateObject_ParametersEntryForCharsetAdded() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + mediaType.Charset = "mycharset"; + Assert.Equal("mycharset", mediaType.Charset); + Assert.Equal(1, mediaType.Parameters.Count); + Assert.Equal("charset", mediaType.Parameters.First().Name); + + mediaType.Charset = null; + Assert.Null(mediaType.Charset); + Assert.Equal(0, mediaType.Parameters.Count); + mediaType.Charset = null; // It's OK to set it again to null; no exception. + } + + [Fact] + public void Charset_AddCharsetParameterThenUseProperty_ParametersEntryIsOverwritten() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + + // Note that uppercase letters are used. Comparison should happen case-insensitive. + var charset = new NameValueHeaderValue("CHARSET", "old_charset"); + mediaType.Parameters.Add(charset); + Assert.Equal(1, mediaType.Parameters.Count); + Assert.Equal("CHARSET", mediaType.Parameters.First().Name); + + mediaType.Charset = "new_charset"; + Assert.Equal("new_charset", mediaType.Charset); + Assert.Equal(1, mediaType.Parameters.Count); + Assert.Equal("CHARSET", mediaType.Parameters.First().Name); + + mediaType.Parameters.Remove(charset); + Assert.Null(mediaType.Charset); + } + + [Fact] + public void Quality_SetCharsetAndValidateObject_ParametersEntryForCharsetAdded() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + mediaType.Quality = 0.563156454; + Assert.Equal(0.563, mediaType.Quality); + Assert.Equal(1, mediaType.Parameters.Count); + Assert.Equal("q", mediaType.Parameters.First().Name); + Assert.Equal("0.563", mediaType.Parameters.First().Value); + + mediaType.Quality = null; + Assert.Null(mediaType.Quality); + Assert.Equal(0, mediaType.Parameters.Count); + mediaType.Quality = null; // It's OK to set it again to null; no exception. + } + + [Fact] + public void Quality_AddQualityParameterThenUseProperty_ParametersEntryIsOverwritten() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + + var quality = new NameValueHeaderValue("q", "0.132"); + mediaType.Parameters.Add(quality); + Assert.Equal(1, mediaType.Parameters.Count); + Assert.Equal("q", mediaType.Parameters.First().Name); + Assert.Equal(0.132, mediaType.Quality); + + mediaType.Quality = 0.9; + Assert.Equal(0.9, mediaType.Quality); + Assert.Equal(1, mediaType.Parameters.Count); + Assert.Equal("q", mediaType.Parameters.First().Name); + + mediaType.Parameters.Remove(quality); + Assert.Null(mediaType.Quality); + } + + [Fact] + public void Quality_AddQualityParameterUpperCase_CaseInsensitiveComparison() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + + var quality = new NameValueHeaderValue("Q", "0.132"); + mediaType.Parameters.Add(quality); + Assert.Equal(1, mediaType.Parameters.Count); + Assert.Equal("Q", mediaType.Parameters.First().Name); + Assert.Equal(0.132, mediaType.Quality); + } + + [Fact] + public void Quality_LessThanZero_Throw() + { + Assert.Throws(() => new MediaTypeHeaderValue("application/xml", -0.01)); + } + + [Fact] + public void Quality_GreaterThanOne_Throw() + { + var mediaType = new MediaTypeHeaderValue("application/xml"); + Assert.Throws(() => mediaType.Quality = 1.01); + } + + [Fact] + public void ToString_UseDifferentMediaTypes_AllSerializedCorrectly() + { + var mediaType = new MediaTypeHeaderValue("text/plain"); + Assert.Equal("text/plain", mediaType.ToString()); + + mediaType.Charset = "utf-8"; + Assert.Equal("text/plain; charset=utf-8", mediaType.ToString()); + + mediaType.Parameters.Add(new NameValueHeaderValue("custom", "\"custom value\"")); + Assert.Equal("text/plain; charset=utf-8; custom=\"custom value\"", mediaType.ToString()); + + mediaType.Charset = null; + Assert.Equal("text/plain; custom=\"custom value\"", mediaType.ToString()); + } + + [Fact] + public void GetHashCode_UseMediaTypeWithAndWithoutParameters_SameOrDifferentHashCodes() + { + var mediaType1 = new MediaTypeHeaderValue("text/plain"); + var mediaType2 = new MediaTypeHeaderValue("text/plain"); + mediaType2.Charset = "utf-8"; + var mediaType3 = new MediaTypeHeaderValue("text/plain"); + mediaType3.Parameters.Add(new NameValueHeaderValue("name", "value")); + var mediaType4 = new MediaTypeHeaderValue("TEXT/plain"); + var mediaType5 = new MediaTypeHeaderValue("TEXT/plain"); + mediaType5.Parameters.Add(new NameValueHeaderValue("CHARSET", "UTF-8")); + + Assert.NotEqual(mediaType1.GetHashCode(), mediaType2.GetHashCode()); + Assert.NotEqual(mediaType1.GetHashCode(), mediaType3.GetHashCode()); + Assert.NotEqual(mediaType2.GetHashCode(), mediaType3.GetHashCode()); + Assert.Equal(mediaType1.GetHashCode(), mediaType4.GetHashCode()); + Assert.Equal(mediaType2.GetHashCode(), mediaType5.GetHashCode()); + } + + [Fact] + public void Equals_UseMediaTypeWithAndWithoutParameters_EqualOrNotEqualNoExceptions() + { + var mediaType1 = new MediaTypeHeaderValue("text/plain"); + var mediaType2 = new MediaTypeHeaderValue("text/plain"); + mediaType2.Charset = "utf-8"; + var mediaType3 = new MediaTypeHeaderValue("text/plain"); + mediaType3.Parameters.Add(new NameValueHeaderValue("name", "value")); + var mediaType4 = new MediaTypeHeaderValue("TEXT/plain"); + var mediaType5 = new MediaTypeHeaderValue("TEXT/plain"); + mediaType5.Parameters.Add(new NameValueHeaderValue("CHARSET", "UTF-8")); + var mediaType6 = new MediaTypeHeaderValue("TEXT/plain"); + mediaType6.Parameters.Add(new NameValueHeaderValue("CHARSET", "UTF-8")); + mediaType6.Parameters.Add(new NameValueHeaderValue("custom", "value")); + var mediaType7 = new MediaTypeHeaderValue("text/other"); + + Assert.False(mediaType1.Equals(mediaType2), "No params vs. charset."); + Assert.False(mediaType2.Equals(mediaType1), "charset vs. no params."); + Assert.False(mediaType1.Equals(null), "No params vs. ."); + Assert.False(mediaType1.Equals(mediaType3), "No params vs. custom param."); + Assert.False(mediaType2.Equals(mediaType3), "charset vs. custom param."); + Assert.True(mediaType1.Equals(mediaType4), "Different casing."); + Assert.True(mediaType2.Equals(mediaType5), "Different casing in charset."); + Assert.False(mediaType5.Equals(mediaType6), "charset vs. custom param."); + Assert.False(mediaType1.Equals(mediaType7), "text/plain vs. text/other."); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidParse("\r\n text/plain ", new MediaTypeHeaderValue("text/plain")); + CheckValidParse("text/plain", new MediaTypeHeaderValue("text/plain")); + + CheckValidParse("\r\n text / plain ; charset = utf-8 ", new MediaTypeHeaderValue("text/plain") { Charset = "utf-8" }); + CheckValidParse(" text/plain;charset=utf-8", new MediaTypeHeaderValue("text/plain") { Charset = "utf-8" }); + + CheckValidParse("text/plain; charset=iso-8859-1", new MediaTypeHeaderValue("text/plain") { Charset = "iso-8859-1" }); + + var expected = new MediaTypeHeaderValue("text/plain") { Charset = "utf-8" }; + expected.Parameters.Add(new NameValueHeaderValue("custom", "value")); + CheckValidParse(" text/plain; custom=value;charset=utf-8", expected); + + expected = new MediaTypeHeaderValue("text/plain"); + expected.Parameters.Add(new NameValueHeaderValue("custom")); + CheckValidParse(" text/plain; custom", expected); + + expected = new MediaTypeHeaderValue("text/plain") { Charset = "utf-8" }; + expected.Parameters.Add(new NameValueHeaderValue("custom", "\"x\"")); + CheckValidParse("text / plain ; custom =\r\n \"x\" ; charset = utf-8 ", expected); + + expected = new MediaTypeHeaderValue("text/plain") { Charset = "utf-8" }; + expected.Parameters.Add(new NameValueHeaderValue("custom", "\"x\"")); + CheckValidParse("text/plain;custom=\"x\";charset=utf-8", expected); + + expected = new MediaTypeHeaderValue("text/plain"); + CheckValidParse("text/plain;", expected); + + expected = new MediaTypeHeaderValue("text/plain"); + expected.Parameters.Add(new NameValueHeaderValue("name", "")); + CheckValidParse("text/plain;name=", expected); + + expected = new MediaTypeHeaderValue("text/plain"); + expected.Parameters.Add(new NameValueHeaderValue("name", "value")); + CheckValidParse("text/plain;name=value;", expected); + + expected = new MediaTypeHeaderValue("text/plain"); + expected.Charset = "iso-8859-1"; + expected.Quality = 1.0; + CheckValidParse("text/plain; charset=iso-8859-1; q=1.0", expected); + + expected = new MediaTypeHeaderValue("*/xml"); + expected.Charset = "utf-8"; + expected.Quality = 0.5; + CheckValidParse("\r\n */xml; charset=utf-8; q=0.5", expected); + + expected = new MediaTypeHeaderValue("*/*"); + CheckValidParse("*/*", expected); + + expected = new MediaTypeHeaderValue("text/*"); + expected.Charset = "utf-8"; + expected.Parameters.Add(new NameValueHeaderValue("foo", "bar")); + CheckValidParse("text/*; charset=utf-8; foo=bar", expected); + + expected = new MediaTypeHeaderValue("text/plain"); + expected.Charset = "utf-8"; + expected.Quality = 0; + expected.Parameters.Add(new NameValueHeaderValue("foo", "bar")); + CheckValidParse("text/plain; charset=utf-8; foo=bar; q=0.0", expected); + } + + [Fact] + public void Parse_SetOfInvalidValueStrings_Throws() + { + CheckInvalidParse(""); + CheckInvalidParse(" "); + CheckInvalidParse(null); + CheckInvalidParse("text/plain会"); + CheckInvalidParse("text/plain ,"); + CheckInvalidParse("text/plain,"); + CheckInvalidParse("text/plain; charset=utf-8 ,"); + CheckInvalidParse("text/plain; charset=utf-8,"); + CheckInvalidParse("textplain"); + CheckInvalidParse("text/"); + CheckInvalidParse(",, , ,,text/plain; charset=iso-8859-1; q=1.0,\r\n */xml; charset=utf-8; q=0.5,,,"); + CheckInvalidParse("text/plain; charset=iso-8859-1; q=1.0, */xml; charset=utf-8; q=0.5"); + CheckInvalidParse(" , */xml; charset=utf-8; q=0.5 "); + CheckInvalidParse("text/plain; charset=iso-8859-1; q=1.0 , "); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + var expected = new MediaTypeHeaderValue("text/plain"); + CheckValidTryParse("\r\n text/plain ", expected); + CheckValidTryParse("text/plain", expected); + + // We don't have to test all possible input strings, since most of the pieces are handled by other parsers. + // The purpose of this test is to verify that these other parsers are combined correctly to build a + // media-type parser. + expected.Charset = "utf-8"; + CheckValidTryParse("\r\n text / plain ; charset = utf-8 ", expected); + CheckValidTryParse(" text/plain;charset=utf-8", expected); + + var value1 = new MediaTypeHeaderValue("text/plain"); + value1.Charset = "iso-8859-1"; + value1.Quality = 1.0; + + CheckValidTryParse("text/plain; charset=iso-8859-1; q=1.0", value1); + + var value2 = new MediaTypeHeaderValue("*/xml"); + value2.Charset = "utf-8"; + value2.Quality = 0.5; + + CheckValidTryParse("\r\n */xml; charset=utf-8; q=0.5", value2); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidTryParse(""); + CheckInvalidTryParse(" "); + CheckInvalidTryParse(null); + CheckInvalidTryParse("text/plain会"); + CheckInvalidTryParse("text/plain ,"); + CheckInvalidTryParse("text/plain,"); + CheckInvalidTryParse("text/plain; charset=utf-8 ,"); + CheckInvalidTryParse("text/plain; charset=utf-8,"); + CheckInvalidTryParse("textplain"); + CheckInvalidTryParse("text/"); + CheckInvalidTryParse(",, , ,,text/plain; charset=iso-8859-1; q=1.0,\r\n */xml; charset=utf-8; q=0.5,,,"); + CheckInvalidTryParse("text/plain; charset=iso-8859-1; q=1.0, */xml; charset=utf-8; q=0.5"); + CheckInvalidTryParse(" , */xml; charset=utf-8; q=0.5 "); + CheckInvalidTryParse("text/plain; charset=iso-8859-1; q=1.0 , "); + } + + [Fact] + public void ParseList_NullOrEmptyArray_ReturnsEmptyList() + { + var results = MediaTypeHeaderValue.ParseList(null); + Assert.NotNull(results); + Assert.Equal(0, results.Count); + + results = MediaTypeHeaderValue.ParseList(new string[0]); + Assert.NotNull(results); + Assert.Equal(0, results.Count); + + results = MediaTypeHeaderValue.ParseList(new string[] { "" }); + Assert.NotNull(results); + Assert.Equal(0, results.Count); + } + + [Fact] + public void TryParseList_NullOrEmptyArray_ReturnsFalse() + { + IList results; + Assert.False(MediaTypeHeaderValue.TryParseList(null, out results)); + Assert.False(MediaTypeHeaderValue.TryParseList(new string[0], out results)); + Assert.False(MediaTypeHeaderValue.TryParseList(new string[] { "" }, out results)); + } + + [Fact] + public void ParseList_SetOfValidValueStrings_ReturnsValues() + { + var inputs = new[] { "text/html,application/xhtml+xml,", "application/xml;q=0.9,image/webp,*/*;q=0.8" }; + var results = MediaTypeHeaderValue.ParseList(inputs); + + var expectedResults = new[] + { + new MediaTypeHeaderValue("text/html"), + new MediaTypeHeaderValue("application/xhtml+xml"), + new MediaTypeHeaderValue("application/xml", 0.9), + new MediaTypeHeaderValue("image/webp"), + new MediaTypeHeaderValue("*/*", 0.8), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void TryParseList_SetOfValidValueStrings_ReturnsTrue() + { + var inputs = new[] { "text/html,application/xhtml+xml,", "application/xml;q=0.9,image/webp,*/*;q=0.8" }; + IList results; + Assert.True(MediaTypeHeaderValue.TryParseList(inputs, out results)); + + var expectedResults = new[] + { + new MediaTypeHeaderValue("text/html"), + new MediaTypeHeaderValue("application/xhtml+xml"), + new MediaTypeHeaderValue("application/xml", 0.9), + new MediaTypeHeaderValue("image/webp"), + new MediaTypeHeaderValue("*/*", 0.8), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void ParseList_WithSomeInvlaidValues_Throws() + { + var inputs = new[] + { + "text/html,application/xhtml+xml, ignore-this, ignore/this", + "application/xml;q=0.9,image/webp,*/*;q=0.8" + }; + Assert.Throws(() => MediaTypeHeaderValue.ParseList(inputs)); + } + + [Fact] + public void TryParseList_WithSomeInvlaidValues_ReturnsFalse() + { + var inputs = new[] + { + "text/html,application/xhtml+xml, ignore-this, ignore/this", + "application/xml;q=0.9,image/webp,*/*;q=0.8", + "application/xml;q=0 4" + }; + IList results; + Assert.False(MediaTypeHeaderValue.TryParseList(inputs, out results)); + } + + [Theory] + [InlineData("*/*;", "*/*")] + [InlineData("text/*;", "text/*")] + [InlineData("text/plain;", "text/plain")] + [InlineData("*/*;", "*/*;charset=utf-8;")] + [InlineData("text/*;", "*/*;charset=utf-8;")] + [InlineData("text/plain;", "*/*;charset=utf-8;")] + [InlineData("text/plain;", "text/*;charset=utf-8;")] + [InlineData("text/plain;", "text/plain;charset=utf-8;")] + [InlineData("text/plain;version=v1", "Text/plain;Version=v1")] + [InlineData("text/plain;version=v1", "tExT/plain;version=V1")] + [InlineData("text/plain;version=v1", "TEXT/PLAIN;VERSION=V1")] + [InlineData("text/plain;charset=utf-8;foo=bar;q=0.0", "text/plain;charset=utf-8;foo=bar;q=0.0")] + [InlineData("text/plain;charset=utf-8;foo=bar;q=0.0", "text/plain;foo=bar;q=0.0;charset=utf-8")] // different order of parameters + [InlineData("text/plain;charset=utf-8;foo=bar;q=0.0", "text/*;charset=utf-8;foo=bar;q=0.0")] + [InlineData("text/plain;charset=utf-8;foo=bar;q=0.0", "*/*;charset=utf-8;foo=bar;q=0.0")] + public void IsSubsetOf_PositiveCases(string mediaType1, string mediaType2) + { + var parsedMediaType1 = MediaTypeHeaderValue.Parse(mediaType1); + var parsedMediaType2 = MediaTypeHeaderValue.Parse(mediaType2); + + var isSubset = parsedMediaType1.IsSubsetOf(parsedMediaType2); + Assert.True(isSubset); + } + + [Theory] + [InlineData("text/plain;version=v1", "text/plain;version=")] + [InlineData("*/*;", "text/plain;charset=utf-8;foo=bar;q=0.0")] + [InlineData("text/*;", "text/plain;charset=utf-8;foo=bar;q=0.0")] + [InlineData("text/plain;missingparam=4;", "text/plain;charset=utf-8;foo=bar;q=0.0")] + [InlineData("text/plain;missingparam=4;", "text/*;charset=utf-8;foo=bar;q=0.0")] + [InlineData("text/plain;missingparam=4;", "*/*;charset=utf-8;foo=bar;q=0.0")] + public void IsSubsetOf_NegativeCases(string mediaType1, string mediaType2) + { + var parsedMediaType1 = MediaTypeHeaderValue.Parse(mediaType1); + var parsedMediaType2 = MediaTypeHeaderValue.Parse(mediaType2); + + var isSubset = parsedMediaType1.IsSubsetOf(parsedMediaType2); + Assert.False(isSubset); + } + + private void CheckValidParse(string input, MediaTypeHeaderValue expectedResult) + { + var result = MediaTypeHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidParse(string input) + { + Assert.Throws(() => MediaTypeHeaderValue.Parse(input)); + } + + private void CheckValidTryParse(string input, MediaTypeHeaderValue expectedResult) + { + MediaTypeHeaderValue result = null; + Assert.True(MediaTypeHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidTryParse(string input) + { + MediaTypeHeaderValue result = null; + Assert.False(MediaTypeHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + private static void AssertFormatException(string mediaType) + { + Assert.Throws(() => new MediaTypeHeaderValue(mediaType)); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/Microsoft.Net.Http.Headers.Tests.kproj b/test/Microsoft.Net.Http.Headers.Tests/Microsoft.Net.Http.Headers.Tests.kproj new file mode 100644 index 0000000000..cbf789a8a0 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/Microsoft.Net.Http.Headers.Tests.kproj @@ -0,0 +1,23 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + e6bb7ad1-bd10-4a23-b780-f4a86adf00d1 + Microsoft.Net.Http.Headers.Tests + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + + + + + + + + \ No newline at end of file diff --git a/test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs new file mode 100644 index 0000000000..3ae04a9212 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class NameValueHeaderValueTest + { + [Fact] + public void Ctor_NameNull_Throw() + { + Assert.Throws(() => new NameValueHeaderValue(null)); + // null and empty should be treated the same. So we also throw for empty strings. + Assert.Throws(() => new NameValueHeaderValue(string.Empty)); + } + + [Fact] + public void Ctor_NameInvalidFormat_ThrowFormatException() + { + // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed. + AssertFormatException(" text ", null); + AssertFormatException("text ", null); + AssertFormatException(" text", null); + AssertFormatException("te xt", null); + AssertFormatException("te=xt", null); // The ctor takes a name which must not contain '='. + AssertFormatException("teäxt", null); + } + + [Fact] + public void Ctor_NameValidFormat_SuccessfullyCreated() + { + var nameValue = new NameValueHeaderValue("text", null); + Assert.Equal("text", nameValue.Name); + } + + [Fact] + public void Ctor_ValueInvalidFormat_ThrowFormatException() + { + // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed. + AssertFormatException("text", " token "); + AssertFormatException("text", "token "); + AssertFormatException("text", " token"); + AssertFormatException("text", "token string"); + AssertFormatException("text", "\"quoted string with \" quotes\""); + AssertFormatException("text", "\"quoted string with \"two\" quotes\""); + } + + [Fact] + public void Ctor_ValueValidFormat_SuccessfullyCreated() + { + CheckValue(null); + CheckValue(string.Empty); + CheckValue("token_string"); + CheckValue("\"quoted string\""); + CheckValue("\"quoted string with quoted \\\" quote-pair\""); + } + + [Fact] + public void Value_CallSetterWithInvalidValues_Throw() + { + // Just verify that the setter calls the same validation the ctor invokes. + Assert.Throws(() => { var x = new NameValueHeaderValue("name"); x.Value = " x "; }); + Assert.Throws(() => { var x = new NameValueHeaderValue("name"); x.Value = "x y"; }); + } + + [Fact] + public void ToString_UseNoValueAndTokenAndQuotedStringValues_SerializedCorrectly() + { + var nameValue = new NameValueHeaderValue("text", "token"); + Assert.Equal("text=token", nameValue.ToString()); + + nameValue.Value = "\"quoted string\""; + Assert.Equal("text=\"quoted string\"", nameValue.ToString()); + + nameValue.Value = null; + Assert.Equal("text", nameValue.ToString()); + + nameValue.Value = string.Empty; + Assert.Equal("text", nameValue.ToString()); + } + + [Fact] + public void GetHashCode_ValuesUseDifferentValues_HashDiffersAccordingToRfc() + { + var nameValue1 = new NameValueHeaderValue("text"); + var nameValue2 = new NameValueHeaderValue("text"); + + nameValue1.Value = null; + nameValue2.Value = null; + Assert.Equal(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + + nameValue1.Value = "token"; + nameValue2.Value = null; + Assert.NotEqual(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + + nameValue1.Value = "token"; + nameValue2.Value = string.Empty; + Assert.NotEqual(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + + nameValue1.Value = null; + nameValue2.Value = string.Empty; + Assert.Equal(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + + nameValue1.Value = "token"; + nameValue2.Value = "TOKEN"; + Assert.Equal(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + + nameValue1.Value = "token"; + nameValue2.Value = "token"; + Assert.Equal(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + + nameValue1.Value = "\"quoted string\""; + nameValue2.Value = "\"QUOTED STRING\""; + Assert.NotEqual(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + + nameValue1.Value = "\"quoted string\""; + nameValue2.Value = "\"quoted string\""; + Assert.Equal(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + } + + [Fact] + public void GetHashCode_NameUseDifferentCasing_HashDiffersAccordingToRfc() + { + var nameValue1 = new NameValueHeaderValue("text"); + var nameValue2 = new NameValueHeaderValue("TEXT"); + Assert.Equal(nameValue1.GetHashCode(), nameValue2.GetHashCode()); + } + + [Fact] + public void Equals_ValuesUseDifferentValues_ValuesAreEqualOrDifferentAccordingToRfc() + { + var nameValue1 = new NameValueHeaderValue("text"); + var nameValue2 = new NameValueHeaderValue("text"); + + nameValue1.Value = null; + nameValue2.Value = null; + Assert.True(nameValue1.Equals(nameValue2), " vs. ."); + + nameValue1.Value = "token"; + nameValue2.Value = null; + Assert.False(nameValue1.Equals(nameValue2), "token vs. ."); + + nameValue1.Value = null; + nameValue2.Value = "token"; + Assert.False(nameValue1.Equals(nameValue2), " vs. token."); + + nameValue1.Value = string.Empty; + nameValue2.Value = "token"; + Assert.False(nameValue1.Equals(nameValue2), "string.Empty vs. token."); + + nameValue1.Value = null; + nameValue2.Value = string.Empty; + Assert.True(nameValue1.Equals(nameValue2), " vs. string.Empty."); + + nameValue1.Value = "token"; + nameValue2.Value = "TOKEN"; + Assert.True(nameValue1.Equals(nameValue2), "token vs. TOKEN."); + + nameValue1.Value = "token"; + nameValue2.Value = "token"; + Assert.True(nameValue1.Equals(nameValue2), "token vs. token."); + + nameValue1.Value = "\"quoted string\""; + nameValue2.Value = "\"QUOTED STRING\""; + Assert.False(nameValue1.Equals(nameValue2), "\"quoted string\" vs. \"QUOTED STRING\"."); + + nameValue1.Value = "\"quoted string\""; + nameValue2.Value = "\"quoted string\""; + Assert.True(nameValue1.Equals(nameValue2), "\"quoted string\" vs. \"quoted string\"."); + + Assert.False(nameValue1.Equals(null), "\"quoted string\" vs. ."); + } + + [Fact] + public void Equals_NameUseDifferentCasing_ConsideredEqual() + { + var nameValue1 = new NameValueHeaderValue("text"); + var nameValue2 = new NameValueHeaderValue("TEXT"); + Assert.True(nameValue1.Equals(nameValue2), "text vs. TEXT."); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidParse(" name = value ", new NameValueHeaderValue("name", "value")); + CheckValidParse(" name", new NameValueHeaderValue("name")); + CheckValidParse(" name ", new NameValueHeaderValue("name")); + CheckValidParse(" name=\"value\"", new NameValueHeaderValue("name", "\"value\"")); + CheckValidParse("name=value", new NameValueHeaderValue("name", "value")); + CheckValidParse("name=\"quoted str\"", new NameValueHeaderValue("name", "\"quoted str\"")); + CheckValidParse("name\t =va1ue", new NameValueHeaderValue("name", "va1ue")); + CheckValidParse("name= va*ue ", new NameValueHeaderValue("name", "va*ue")); + CheckValidParse("name=", new NameValueHeaderValue("name", "")); + } + + [Fact] + public void Parse_SetOfInvalidValueStrings_Throws() + { + CheckInvalidParse("name[value"); + CheckInvalidParse("name=value="); + CheckInvalidParse("name=会"); + CheckInvalidParse("name==value"); + CheckInvalidParse("name= va:ue"); + CheckInvalidParse("=value"); + CheckInvalidParse("name value"); + CheckInvalidParse("name=,value"); + CheckInvalidParse("会"); + CheckInvalidParse(null); + CheckInvalidParse(string.Empty); + CheckInvalidParse(" "); + CheckInvalidParse(" ,,"); + CheckInvalidParse(" , , name = value , "); + CheckInvalidParse(" name,"); + CheckInvalidParse(" ,name=\"value\""); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidTryParse(" name = value ", new NameValueHeaderValue("name", "value")); + CheckValidTryParse(" name", new NameValueHeaderValue("name")); + CheckValidTryParse(" name=\"value\"", new NameValueHeaderValue("name", "\"value\"")); + CheckValidTryParse("name=value", new NameValueHeaderValue("name", "value")); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidTryParse("name[value"); + CheckInvalidTryParse("name=value="); + CheckInvalidTryParse("name=会"); + CheckInvalidTryParse("name==value"); + CheckInvalidTryParse("=value"); + CheckInvalidTryParse("name value"); + CheckInvalidTryParse("name=,value"); + CheckInvalidTryParse("会"); + CheckInvalidTryParse(null); + CheckInvalidTryParse(string.Empty); + CheckInvalidTryParse(" "); + CheckInvalidTryParse(" ,,"); + CheckInvalidTryParse(" , , name = value , "); + CheckInvalidTryParse(" name,"); + CheckInvalidTryParse(" ,name=\"value\""); + } + + [Fact] + public void ParseList_SetOfValidValueStrings_ParsedCorrectly() + { + var inputs = new[] + { + "", + "name=value1", + "", + " name = value2 ", + "\r\n name =value3\r\n ", + "name=\"value 4\"", + "name=\"value会5\"", + "name=value6,name=value7", + "name=\"value 8\", name= \"value 9\"", + }; + var results = NameValueHeaderValue.ParseList(inputs); + + var expectedResults = new[] + { + new NameValueHeaderValue("name", "value1"), + new NameValueHeaderValue("name", "value2"), + new NameValueHeaderValue("name", "value3"), + new NameValueHeaderValue("name", "\"value 4\""), + new NameValueHeaderValue("name", "\"value会5\""), + new NameValueHeaderValue("name", "value6"), + new NameValueHeaderValue("name", "value7"), + new NameValueHeaderValue("name", "\"value 8\""), + new NameValueHeaderValue("name", "\"value 9\""), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void TryParseList_SetOfValidValueStrings_ParsedCorrectly() + { + var inputs = new[] + { + "", + "name=value1", + "", + " name = value2 ", + "\r\n name =value3\r\n ", + "name=\"value 4\"", + "name=\"value会5\"", + "name=value6,name=value7", + "name=\"value 8\", name= \"value 9\"", + }; + IList results; + Assert.True(NameValueHeaderValue.TryParseList(inputs, out results)); + + var expectedResults = new[] + { + new NameValueHeaderValue("name", "value1"), + new NameValueHeaderValue("name", "value2"), + new NameValueHeaderValue("name", "value3"), + new NameValueHeaderValue("name", "\"value 4\""), + new NameValueHeaderValue("name", "\"value会5\""), + new NameValueHeaderValue("name", "value6"), + new NameValueHeaderValue("name", "value7"), + new NameValueHeaderValue("name", "\"value 8\""), + new NameValueHeaderValue("name", "\"value 9\""), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void ParseList_WithSomeInvlaidValues_Throws() + { + var inputs = new[] + { + "", + "name1=value1", + "name2", + " name3 = 3, value a", + "name4 =value4, name5 = value5 b", + "name6=\"value 6", + "name7=\"value会7\"", + "name8=value8,name9=value9", + "name10=\"value 10\", name11= \"value 11\"", + }; + Assert.Throws(() => NameValueHeaderValue.ParseList(inputs)); + } + + [Fact] + public void TryParseList_WithSomeInvlaidValues_ReturnsFalse() + { + var inputs = new[] + { + "", + "name1=value1", + "name2", + " name3 = 3, value a", + "name4 =value4, name5 = value5 b", + "name6=\"value 6", + "name7=\"value会7\"", + "name8=value8,name9=value9", + "name10=\"value 10\", name11= \"value 11\"", + }; + IList results; + Assert.False(NameValueHeaderValue.TryParseList(inputs, out results)); + } + + #region Helper methods + + private void CheckValidParse(string input, NameValueHeaderValue expectedResult) + { + var result = NameValueHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidParse(string input) + { + Assert.Throws(() => NameValueHeaderValue.Parse(input)); + } + + private void CheckValidTryParse(string input, NameValueHeaderValue expectedResult) + { + NameValueHeaderValue result = null; + Assert.True(NameValueHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidTryParse(string input) + { + NameValueHeaderValue result = null; + Assert.False(NameValueHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + private static void CheckValue(string value) + { + var nameValue = new NameValueHeaderValue("text", value); + Assert.Equal(value, nameValue.Value); + } + + private static void AssertFormatException(string name, string value) + { + Assert.Throws(() => new NameValueHeaderValue(name, value)); + } + + #endregion + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/RangeConditionHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/RangeConditionHeaderValueTest.cs new file mode 100644 index 0000000000..b47b03b026 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/RangeConditionHeaderValueTest.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class RangeConditionHeaderValueTest + { + [Fact] + public void Ctor_EntityTagOverload_MatchExpectation() + { + var rangeCondition = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"x\"")); + Assert.Equal(new EntityTagHeaderValue("\"x\""), rangeCondition.EntityTag); + Assert.Null(rangeCondition.LastModified); + + EntityTagHeaderValue input = null; + Assert.Throws(() => new RangeConditionHeaderValue(input)); + } + + [Fact] + public void Ctor_EntityTagStringOverload_MatchExpectation() + { + var rangeCondition = new RangeConditionHeaderValue("\"y\""); + Assert.Equal(new EntityTagHeaderValue("\"y\""), rangeCondition.EntityTag); + Assert.Null(rangeCondition.LastModified); + + Assert.Throws(() => new RangeConditionHeaderValue((string)null)); + } + + [Fact] + public void Ctor_DateOverload_MatchExpectation() + { + var rangeCondition = new RangeConditionHeaderValue( + new DateTimeOffset(2010, 7, 15, 12, 33, 57, TimeSpan.Zero)); + Assert.Null(rangeCondition.EntityTag); + Assert.Equal(new DateTimeOffset(2010, 7, 15, 12, 33, 57, TimeSpan.Zero), rangeCondition.LastModified); + } + + [Fact] + public void ToString_UseDifferentrangeConditions_AllSerializedCorrectly() + { + var rangeCondition = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"x\"")); + Assert.Equal("\"x\"", rangeCondition.ToString()); + + rangeCondition = new RangeConditionHeaderValue(new DateTimeOffset(2010, 7, 15, 12, 33, 57, TimeSpan.Zero)); + Assert.Equal("Thu, 15 Jul 2010 12:33:57 GMT", rangeCondition.ToString()); + } + + [Fact] + public void GetHashCode_UseSameAndDifferentrangeConditions_SameOrDifferentHashCodes() + { + var rangeCondition1 = new RangeConditionHeaderValue("\"x\""); + var rangeCondition2 = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"x\"")); + var rangeCondition3 = new RangeConditionHeaderValue( + new DateTimeOffset(2010, 7, 15, 12, 33, 57, TimeSpan.Zero)); + var rangeCondition4 = new RangeConditionHeaderValue( + new DateTimeOffset(2008, 8, 16, 13, 44, 10, TimeSpan.Zero)); + var rangeCondition5 = new RangeConditionHeaderValue( + new DateTimeOffset(2010, 7, 15, 12, 33, 57, TimeSpan.Zero)); + var rangeCondition6 = new RangeConditionHeaderValue( + new EntityTagHeaderValue("\"x\"", true)); + + Assert.Equal(rangeCondition1.GetHashCode(), rangeCondition2.GetHashCode()); + Assert.NotEqual(rangeCondition1.GetHashCode(), rangeCondition3.GetHashCode()); + Assert.NotEqual(rangeCondition3.GetHashCode(), rangeCondition4.GetHashCode()); + Assert.Equal(rangeCondition3.GetHashCode(), rangeCondition5.GetHashCode()); + Assert.NotEqual(rangeCondition1.GetHashCode(), rangeCondition6.GetHashCode()); + } + + [Fact] + public void Equals_UseSameAndDifferentRanges_EqualOrNotEqualNoExceptions() + { + var rangeCondition1 = new RangeConditionHeaderValue("\"x\""); + var rangeCondition2 = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"x\"")); + var rangeCondition3 = new RangeConditionHeaderValue( + new DateTimeOffset(2010, 7, 15, 12, 33, 57, TimeSpan.Zero)); + var rangeCondition4 = new RangeConditionHeaderValue( + new DateTimeOffset(2008, 8, 16, 13, 44, 10, TimeSpan.Zero)); + var rangeCondition5 = new RangeConditionHeaderValue( + new DateTimeOffset(2010, 7, 15, 12, 33, 57, TimeSpan.Zero)); + var rangeCondition6 = new RangeConditionHeaderValue( + new EntityTagHeaderValue("\"x\"", true)); + + Assert.False(rangeCondition1.Equals(null), "\"x\" vs. "); + Assert.True(rangeCondition1.Equals(rangeCondition2), "\"x\" vs. \"x\""); + Assert.False(rangeCondition1.Equals(rangeCondition3), "\"x\" vs. date"); + Assert.False(rangeCondition3.Equals(rangeCondition1), "date vs. \"x\""); + Assert.False(rangeCondition3.Equals(rangeCondition4), "date vs. different date"); + Assert.True(rangeCondition3.Equals(rangeCondition5), "date vs. date"); + Assert.False(rangeCondition1.Equals(rangeCondition6), "\"x\" vs. W/\"x\""); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidParse(" \"x\" ", new RangeConditionHeaderValue("\"x\"")); + CheckValidParse(" Sun, 06 Nov 1994 08:49:37 GMT ", + new RangeConditionHeaderValue(new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero))); + CheckValidParse("Wed, 09 Nov 1994 08:49:37 GMT", + new RangeConditionHeaderValue(new DateTimeOffset(1994, 11, 9, 8, 49, 37, TimeSpan.Zero))); + CheckValidParse(" W/ \"tag\" ", new RangeConditionHeaderValue(new EntityTagHeaderValue("\"tag\"", true))); + CheckValidParse(" w/\"tag\"", new RangeConditionHeaderValue(new EntityTagHeaderValue("\"tag\"", true))); + CheckValidParse("\"tag\"", new RangeConditionHeaderValue(new EntityTagHeaderValue("\"tag\""))); + } + + [Theory] + [InlineData("\"x\" ,")] // no delimiter allowed + [InlineData("Sun, 06 Nov 1994 08:49:37 GMT ,")] // no delimiter allowed + [InlineData("\"x\" Sun, 06 Nov 1994 08:49:37 GMT")] + [InlineData("Sun, 06 Nov 1994 08:49:37 GMT \"x\"")] + [InlineData(null)] + [InlineData("")] + [InlineData(" Wed 09 Nov 1994 08:49:37 GMT")] + [InlineData("\"x")] + [InlineData("Wed, 09 Nov")] + [InlineData("W/Wed 09 Nov 1994 08:49:37 GMT")] + [InlineData("\"x\",")] + [InlineData("Wed 09 Nov 1994 08:49:37 GMT,")] + public void Parse_SetOfInvalidValueStrings_Throws(string input) + { + Assert.Throws(() => RangeConditionHeaderValue.Parse(input)); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidTryParse(" \"x\" ", new RangeConditionHeaderValue("\"x\"")); + CheckValidTryParse(" Sun, 06 Nov 1994 08:49:37 GMT ", + new RangeConditionHeaderValue(new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero))); + CheckValidTryParse(" W/ \"tag\" ", new RangeConditionHeaderValue(new EntityTagHeaderValue("\"tag\"", true))); + CheckValidTryParse(" w/\"tag\"", new RangeConditionHeaderValue(new EntityTagHeaderValue("\"tag\"", true))); + CheckValidTryParse("\"tag\"", new RangeConditionHeaderValue(new EntityTagHeaderValue("\"tag\""))); + } + + [Theory] + [InlineData("\"x\" ,")] // no delimiter allowed + [InlineData("Sun, 06 Nov 1994 08:49:37 GMT ,")] // no delimiter allowed + [InlineData("\"x\" Sun, 06 Nov 1994 08:49:37 GMT")] + [InlineData("Sun, 06 Nov 1994 08:49:37 GMT \"x\"")] + [InlineData(null)] + [InlineData("")] + [InlineData(" Wed 09 Nov 1994 08:49:37 GMT")] + [InlineData("\"x")] + [InlineData("Wed, 09 Nov")] + [InlineData("W/Wed 09 Nov 1994 08:49:37 GMT")] + [InlineData("\"x\",")] + [InlineData("Wed 09 Nov 1994 08:49:37 GMT,")] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse(string input) + { + RangeConditionHeaderValue result = null; + Assert.False(RangeConditionHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + #region Helper methods + + private void CheckValidParse(string input, RangeConditionHeaderValue expectedResult) + { + var result = RangeConditionHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckValidTryParse(string input, RangeConditionHeaderValue expectedResult) + { + RangeConditionHeaderValue result = null; + Assert.True(RangeConditionHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + + #endregion + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/RangeHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/RangeHeaderValueTest.cs new file mode 100644 index 0000000000..22dc5dc749 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/RangeHeaderValueTest.cs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class RangeHeaderValueTest + { + [Fact] + public void Ctor_InvalidRange_Throw() + { + Assert.Throws(() => new RangeHeaderValue(5, 2)); + } + + [Fact] + public void Unit_GetAndSetValidAndInvalidValues_MatchExpectation() + { + var range = new RangeHeaderValue(); + range.Unit = "myunit"; + Assert.Equal("myunit", range.Unit); + + Assert.Throws(() => range.Unit = null); + Assert.Throws(() => range.Unit = ""); + Assert.Throws(() => range.Unit = " x"); + Assert.Throws(() => range.Unit = "x "); + Assert.Throws(() => range.Unit = "x y"); + } + + [Fact] + public void ToString_UseDifferentRanges_AllSerializedCorrectly() + { + var range = new RangeHeaderValue(); + range.Unit = "myunit"; + range.Ranges.Add(new RangeItemHeaderValue(1, 3)); + Assert.Equal("myunit=1-3", range.ToString()); + + range.Ranges.Add(new RangeItemHeaderValue(5, null)); + range.Ranges.Add(new RangeItemHeaderValue(null, 17)); + Assert.Equal("myunit=1-3, 5-, -17", range.ToString()); + } + + [Fact] + public void GetHashCode_UseSameAndDifferentRanges_SameOrDifferentHashCodes() + { + var range1 = new RangeHeaderValue(1, 2); + var range2 = new RangeHeaderValue(1, 2); + range2.Unit = "BYTES"; + var range3 = new RangeHeaderValue(1, null); + var range4 = new RangeHeaderValue(null, 2); + var range5 = new RangeHeaderValue(); + range5.Ranges.Add(new RangeItemHeaderValue(1, 2)); + range5.Ranges.Add(new RangeItemHeaderValue(3, 4)); + var range6 = new RangeHeaderValue(); + range6.Ranges.Add(new RangeItemHeaderValue(3, 4)); // reverse order of range5 + range6.Ranges.Add(new RangeItemHeaderValue(1, 2)); + + Assert.Equal(range1.GetHashCode(), range2.GetHashCode()); + Assert.NotEqual(range1.GetHashCode(), range3.GetHashCode()); + Assert.NotEqual(range1.GetHashCode(), range4.GetHashCode()); + Assert.NotEqual(range1.GetHashCode(), range5.GetHashCode()); + Assert.Equal(range5.GetHashCode(), range6.GetHashCode()); + } + + [Fact] + public void Equals_UseSameAndDifferentRanges_EqualOrNotEqualNoExceptions() + { + var range1 = new RangeHeaderValue(1, 2); + var range2 = new RangeHeaderValue(1, 2); + range2.Unit = "BYTES"; + var range3 = new RangeHeaderValue(1, null); + var range4 = new RangeHeaderValue(null, 2); + var range5 = new RangeHeaderValue(); + range5.Ranges.Add(new RangeItemHeaderValue(1, 2)); + range5.Ranges.Add(new RangeItemHeaderValue(3, 4)); + var range6 = new RangeHeaderValue(); + range6.Ranges.Add(new RangeItemHeaderValue(3, 4)); // reverse order of range5 + range6.Ranges.Add(new RangeItemHeaderValue(1, 2)); + var range7 = new RangeHeaderValue(1, 2); + range7.Unit = "other"; + + Assert.False(range1.Equals(null), "bytes=1-2 vs. "); + Assert.True(range1.Equals(range2), "bytes=1-2 vs. BYTES=1-2"); + Assert.False(range1.Equals(range3), "bytes=1-2 vs. bytes=1-"); + Assert.False(range1.Equals(range4), "bytes=1-2 vs. bytes=-2"); + Assert.False(range1.Equals(range5), "bytes=1-2 vs. bytes=1-2,3-4"); + Assert.True(range5.Equals(range6), "bytes=1-2,3-4 vs. bytes=3-4,1-2"); + Assert.False(range1.Equals(range7), "bytes=1-2 vs. other=1-2"); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidParse(" bytes=1-2 ", new RangeHeaderValue(1, 2)); + + var expected = new RangeHeaderValue(); + expected.Unit = "custom"; + expected.Ranges.Add(new RangeItemHeaderValue(null, 5)); + expected.Ranges.Add(new RangeItemHeaderValue(1, 4)); + CheckValidParse("custom = - 5 , 1 - 4 ,,", expected); + + expected = new RangeHeaderValue(); + expected.Unit = "custom"; + expected.Ranges.Add(new RangeItemHeaderValue(1, 2)); + CheckValidParse(" custom = 1 - 2", expected); + + expected = new RangeHeaderValue(); + expected.Ranges.Add(new RangeItemHeaderValue(1, 2)); + expected.Ranges.Add(new RangeItemHeaderValue(3, null)); + expected.Ranges.Add(new RangeItemHeaderValue(null, 4)); + CheckValidParse("bytes =1-2,,3-, , ,-4,,", expected); + } + + [Fact] + public void Parse_SetOfInvalidValueStrings_Throws() + { + CheckInvalidParse("bytes=1-2x"); // only delimiter ',' allowed after last range + CheckInvalidParse("x bytes=1-2"); + CheckInvalidParse("bytes=1-2.4"); + CheckInvalidParse(null); + CheckInvalidParse(string.Empty); + + CheckInvalidParse("bytes=1"); + CheckInvalidParse("bytes="); + CheckInvalidParse("bytes"); + CheckInvalidParse("bytes 1-2"); + CheckInvalidParse("bytes= ,,, , ,,"); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidTryParse(" bytes=1-2 ", new RangeHeaderValue(1, 2)); + + var expected = new RangeHeaderValue(); + expected.Unit = "custom"; + expected.Ranges.Add(new RangeItemHeaderValue(null, 5)); + expected.Ranges.Add(new RangeItemHeaderValue(1, 4)); + CheckValidTryParse("custom = - 5 , 1 - 4 ,,", expected); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidTryParse("bytes=1-2x"); // only delimiter ',' allowed after last range + CheckInvalidTryParse("x bytes=1-2"); + CheckInvalidTryParse("bytes=1-2.4"); + CheckInvalidTryParse(null); + CheckInvalidTryParse(string.Empty); + } + + #region Helper methods + + private void CheckValidParse(string input, RangeHeaderValue expectedResult) + { + var result = RangeHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidParse(string input) + { + Assert.Throws(() => RangeHeaderValue.Parse(input)); + } + + private void CheckValidTryParse(string input, RangeHeaderValue expectedResult) + { + RangeHeaderValue result = null; + Assert.True(RangeHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidTryParse(string input) + { + RangeHeaderValue result = null; + Assert.False(RangeHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + #endregion + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/RangeItemHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/RangeItemHeaderValueTest.cs new file mode 100644 index 0000000000..573ae8964e --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/RangeItemHeaderValueTest.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class RangeItemHeaderValueTest + { + [Fact] + public void Ctor_BothValuesNull_Throw() + { + Assert.Throws(() => new RangeItemHeaderValue(null, null)); + } + + [Fact] + public void Ctor_FromValueNegative_Throw() + { + Assert.Throws(() => new RangeItemHeaderValue(-1, null)); + } + + [Fact] + public void Ctor_FromGreaterThanToValue_Throw() + { + Assert.Throws(() => new RangeItemHeaderValue(2, 1)); + } + + [Fact] + public void Ctor_ToValueNegative_Throw() + { + Assert.Throws(() => new RangeItemHeaderValue(null, -1)); + } + + [Fact] + public void Ctor_ValidFormat_SuccessfullyCreated() + { + var rangeItem = new RangeItemHeaderValue(1, 2); + Assert.Equal(1, rangeItem.From); + Assert.Equal(2, rangeItem.To); + } + + [Fact] + public void ToString_UseDifferentRangeItems_AllSerializedCorrectly() + { + // Make sure ToString() doesn't add any separators. + var rangeItem = new RangeItemHeaderValue(1000000000, 2000000000); + Assert.Equal("1000000000-2000000000", rangeItem.ToString()); + + rangeItem = new RangeItemHeaderValue(5, null); + Assert.Equal("5-", rangeItem.ToString()); + + rangeItem = new RangeItemHeaderValue(null, 10); + Assert.Equal("-10", rangeItem.ToString()); + } + + [Fact] + public void GetHashCode_UseSameAndDifferentRangeItems_SameOrDifferentHashCodes() + { + var rangeItem1 = new RangeItemHeaderValue(1, 2); + var rangeItem2 = new RangeItemHeaderValue(1, null); + var rangeItem3 = new RangeItemHeaderValue(null, 2); + var rangeItem4 = new RangeItemHeaderValue(2, 2); + var rangeItem5 = new RangeItemHeaderValue(1, 2); + + Assert.NotEqual(rangeItem1.GetHashCode(), rangeItem2.GetHashCode()); + Assert.NotEqual(rangeItem1.GetHashCode(), rangeItem3.GetHashCode()); + Assert.NotEqual(rangeItem1.GetHashCode(), rangeItem4.GetHashCode()); + Assert.Equal(rangeItem1.GetHashCode(), rangeItem5.GetHashCode()); + } + + [Fact] + public void Equals_UseSameAndDifferentRanges_EqualOrNotEqualNoExceptions() + { + var rangeItem1 = new RangeItemHeaderValue(1, 2); + var rangeItem2 = new RangeItemHeaderValue(1, null); + var rangeItem3 = new RangeItemHeaderValue(null, 2); + var rangeItem4 = new RangeItemHeaderValue(2, 2); + var rangeItem5 = new RangeItemHeaderValue(1, 2); + + Assert.False(rangeItem1.Equals(rangeItem2), "1-2 vs. 1-."); + Assert.False(rangeItem2.Equals(rangeItem1), "1- vs. 1-2."); + Assert.False(rangeItem1.Equals(null), "1-2 vs. null."); + Assert.False(rangeItem1.Equals(rangeItem3), "1-2 vs. -2."); + Assert.False(rangeItem3.Equals(rangeItem1), "-2 vs. 1-2."); + Assert.False(rangeItem1.Equals(rangeItem4), "1-2 vs. 2-2."); + Assert.True(rangeItem1.Equals(rangeItem5), "1-2 vs. 1-2."); + } + + [Fact] + public void TryParse_DifferentValidScenarios_AllReturnNonZero() + { + CheckValidTryParse("1-2", 1, 2); + CheckValidTryParse(" 1-2", 1, 2); + CheckValidTryParse("0-0", 0, 0); + CheckValidTryParse(" 1-", 1, null); + CheckValidTryParse(" -2", null, 2); + + CheckValidTryParse(" 684684 - 123456789012345 ", 684684, 123456789012345); + + // The separator doesn't matter. It only parses until the first non-whitespace + CheckValidTryParse(" 1 - 2 ,", 1, 2); + + CheckValidTryParse(",,1-2, 3 - , , -6 , ,,", new Tuple(1, 2), new Tuple(3, null), + new Tuple(null, 6)); + CheckValidTryParse("1-2,", new Tuple(1, 2)); + CheckValidTryParse("1-", new Tuple(1, null)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(",,")] + [InlineData("1")] + [InlineData("1-2,3")] + [InlineData("1--2")] + [InlineData("1,-2")] + [InlineData("-")] + [InlineData("--")] + [InlineData("2-1")] + [InlineData("12345678901234567890123-")] // >>Int64.MaxValue + [InlineData("-12345678901234567890123")] // >>Int64.MaxValue + [InlineData("9999999999999999999-")] // 19-digit numbers outside the Int64 range. + [InlineData("-9999999999999999999")] // 19-digit numbers outside the Int64 range. + public void TryParse_DifferentInvalidScenarios_AllReturnFalse(string input) + { + RangeHeaderValue result; + Assert.False(RangeHeaderValue.TryParse("byte=" + input, out result)); + } + + private static void CheckValidTryParse(string input, long? expectedFrom, long? expectedTo) + { + RangeHeaderValue result; + Assert.True(RangeHeaderValue.TryParse("byte=" + input, out result), input); + + var ranges = result.Ranges.ToArray(); + Assert.Equal(1, ranges.Length); + + var range = ranges.First(); + + Assert.Equal(expectedFrom, range.From); + Assert.Equal(expectedTo, range.To); + } + + private static void CheckValidTryParse(string input, params Tuple[] expectedRanges) + { + RangeHeaderValue result; + Assert.True(RangeHeaderValue.TryParse("byte=" + input, out result), input); + + var ranges = result.Ranges.ToArray(); + Assert.Equal(expectedRanges.Length, ranges.Length); + + for (int i = 0; i < expectedRanges.Length; i++) + { + Assert.Equal(expectedRanges[i].Item1, ranges[i].From); + Assert.Equal(expectedRanges[i].Item2, ranges[i].To); + } + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs new file mode 100644 index 0000000000..ee3e12292b --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class SetCookieHeaderValueTest + { + public static TheoryData SetCookieHeaderDataSet + { + get + { + var dataset = new TheoryData(); + var header1 = new SetCookieHeaderValue("name1", "n1=v1&n2=v2&n3=v3") + { + Domain = "domain1", + Expires = new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero), + HttpOnly = true, + MaxAge = TimeSpan.FromDays(1), + Path = "path1", + Secure = true + }; + dataset.Add(header1, "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly"); + + var header2 = new SetCookieHeaderValue("name2", ""); + dataset.Add(header2, "name2="); + + var header3 = new SetCookieHeaderValue("name2", "value2"); + dataset.Add(header3, "name2=value2"); + + var header4 = new SetCookieHeaderValue("name4", "value4") + { + MaxAge = TimeSpan.FromDays(1), + }; + dataset.Add(header4, "name4=value4; max-age=86400"); + + var header5 = new SetCookieHeaderValue("name5", "value5") + { + Domain = "domain1", + Expires = new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero), + }; + dataset.Add(header5, "name5=value5; expires=Sun, 06 Nov 1994 08:49:37 GMT; domain=domain1"); + + return dataset; + } + } + + public static TheoryData InvalidSetCookieHeaderDataSet + { + get + { + return new TheoryData + { + "expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1", + "name=value; expires=Sun, 06 Nov 1994 08:49:37 ZZZ; max-age=86400; domain=domain1", + "name=value; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=-86400; domain=domain1", + }; + } + } + + public static TheoryData InvalidCookieNames + { + get + { + return new TheoryData + { + "", + "{acb}", + "[acb]", + "\"acb\"", + "a,b", + "a;b", + "a\\b", + }; + } + } + + public static TheoryData InvalidCookieValues + { + get + { + return new TheoryData + { + { "\"" }, + { "a,b" }, + { "a;b" }, + { "a\\b" }, + { "\"abc" }, + { "a\"bc" }, + { "abc\"" }, + }; + } + } + public static TheoryData, string[]> ListOfSetCookieHeaderDataSet + { + get + { + var dataset = new TheoryData, string[]>(); + var header1 = new SetCookieHeaderValue("name1", "n1=v1&n2=v2&n3=v3") + { + Domain = "domain1", + Expires = new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero), + HttpOnly = true, + MaxAge = TimeSpan.FromDays(1), + Path = "path1", + Secure = true + }; + var string1 = "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly"; + + var header2 = new SetCookieHeaderValue("name2", "value2"); + var string2 = "name2=value2"; + + var header3 = new SetCookieHeaderValue("name3", "value3") + { + MaxAge = TimeSpan.FromDays(1), + }; + var string3 = "name3=value3; max-age=86400"; + + var header4 = new SetCookieHeaderValue("name4", "value4") + { + Domain = "domain1", + Expires = new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero), + }; + var string4 = "name4=value4; expires=Sun, 06 Nov 1994 08:49:37 GMT; domain=domain1"; + + dataset.Add(new[] { header1 }.ToList(), new[] { string1 }); + dataset.Add(new[] { header1, header1 }.ToList(), new[] { string1, string1 }); + dataset.Add(new[] { header1, header1 }.ToList(), new[] { string1, null, "", " ", ",", " , ", string1 }); + dataset.Add(new[] { header2 }.ToList(), new[] { string2 }); + dataset.Add(new[] { header1, header2 }.ToList(), new[] { string1, string2 }); + dataset.Add(new[] { header1, header2 }.ToList(), new[] { string1 + ", " + string2 }); + dataset.Add(new[] { header2, header1 }.ToList(), new[] { string2 + ", " + string1 }); + dataset.Add(new[] { header1, header2, header3, header4 }.ToList(), new[] { string1, string2, string3, string4 }); + dataset.Add(new[] { header1, header2, header3, header4 }.ToList(), new[] { string.Join(",", string1, string2, string3, string4) }); + + return dataset; + } + } + + // TODO: [Fact] + public void SetCookieHeaderValue_CtorThrowsOnNullName() + { + Assert.Throws(() => new SetCookieHeaderValue(null, "value")); + } + + [Theory] + [MemberData(nameof(InvalidCookieNames))] + public void SetCookieHeaderValue_CtorThrowsOnInvalidName(string name) + { + Assert.Throws(() => new SetCookieHeaderValue(name, "value")); + } + + [Theory] + [MemberData(nameof(InvalidCookieValues))] + public void SetCookieHeaderValue_CtorThrowsOnInvalidValue(string value) + { + Assert.Throws(() => new SetCookieHeaderValue("name", value)); + } + + [Fact] + public void SetCookieHeaderValue_Ctor1_InitializesCorrectly() + { + var header = new SetCookieHeaderValue("cookie"); + Assert.Equal("cookie", header.Name); + Assert.Equal(string.Empty, header.Value); + } + + [Theory] + [InlineData("name", "")] + [InlineData("name", "value")] + [InlineData("name", "\"acb\"")] + public void SetCookieHeaderValue_Ctor2InitializesCorrectly(string name, string value) + { + var header = new SetCookieHeaderValue(name, value); + Assert.Equal(name, header.Name); + Assert.Equal(value, header.Value); + } + + [Fact] + public void SetCookieHeaderValue_Value() + { + var cookie = new SetCookieHeaderValue("name"); + Assert.Equal(String.Empty, cookie.Value); + + cookie.Value = "value1"; + Assert.Equal("value1", cookie.Value); + } + + [Theory] + [MemberData(nameof(SetCookieHeaderDataSet))] + public void SetCookieHeaderValue_ToString(SetCookieHeaderValue input, string expectedValue) + { + Assert.Equal(expectedValue, input.ToString()); + } + + [Theory] + [MemberData(nameof(SetCookieHeaderDataSet))] + public void SetCookieHeaderValue_Parse_AcceptsValidValues(SetCookieHeaderValue cookie, string expectedValue) + { + var header = SetCookieHeaderValue.Parse(expectedValue); + + Assert.Equal(cookie, header); + Assert.Equal(expectedValue, header.ToString()); + } + + [Theory] + [MemberData(nameof(SetCookieHeaderDataSet))] + public void SetCookieHeaderValue_TryParse_AcceptsValidValues(SetCookieHeaderValue cookie, string expectedValue) + { + SetCookieHeaderValue header; + bool result = SetCookieHeaderValue.TryParse(expectedValue, out header); + Assert.True(result); + + Assert.Equal(cookie, header); + Assert.Equal(expectedValue, header.ToString()); + } + + [Theory] + [MemberData(nameof(InvalidSetCookieHeaderDataSet))] + public void SetCookieHeaderValue_Parse_RejectsInvalidValues(string value) + { + Assert.Throws(() => SetCookieHeaderValue.Parse(value)); + } + + [Theory] + [MemberData(nameof(InvalidSetCookieHeaderDataSet))] + public void SetCookieHeaderValue_TryParse_RejectsInvalidValues(string value) + { + SetCookieHeaderValue header; + bool result = SetCookieHeaderValue.TryParse(value, out header); + + Assert.False(result); + } + + [Theory] + [MemberData(nameof(ListOfSetCookieHeaderDataSet))] + public void SetCookieHeaderValue_ParseList_AcceptsValidValues(IList cookies, string[] input) + { + var results = SetCookieHeaderValue.ParseList(input); + + Assert.Equal(cookies, results); + } + + [Theory] + [MemberData(nameof(ListOfSetCookieHeaderDataSet))] + public void SetCookieHeaderValue_TryParseList_AcceptsValidValues(IList cookies, string[] input) + { + IList results; + bool result = SetCookieHeaderValue.TryParseList(input, out results); + Assert.True(result); + + Assert.Equal(cookies, results); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueComparerTest.cs b/test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueComparerTest.cs new file mode 100644 index 0000000000..df4e729493 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueComparerTest.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class StringWithQualityHeaderValueComparerTest + { + public static TheoryData StringWithQualityHeaderValueComparerTestsBeforeAfterSortedValues + { + get + { + return new TheoryData + { + { + new string[] + { + "text", + "text;q=1.0", + "text", + "text;q=0", + "*;q=0.8", + "*;q=1", + "text;q=0.8", + "*;q=0.6", + "text;q=1.0", + "*;q=0.4", + "text;q=0.6", + }, + new string[] + { + "text", + "text;q=1.0", + "text", + "text;q=1.0", + "*;q=1", + "text;q=0.8", + "*;q=0.8", + "text;q=0.6", + "*;q=0.6", + "*;q=0.4", + "text;q=0", + } + } + }; + } + } + + [Theory] + [MemberData(nameof(StringWithQualityHeaderValueComparerTestsBeforeAfterSortedValues))] + public void SortStringWithQualityHeaderValuesByQFactor_SortsCorrectly(IEnumerable unsorted, IEnumerable expectedSorted) + { + var unsortedValues = StringWithQualityHeaderValue.ParseList(unsorted.ToList()); + var expectedSortedValues = StringWithQualityHeaderValue.ParseList(expectedSorted.ToList()); + + var actualSorted = unsortedValues.OrderByDescending(k => k, StringWithQualityHeaderValueComparer.QualityComparer).ToList(); + + Assert.True(expectedSortedValues.SequenceEqual(actualSorted)); + } + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueTest.cs new file mode 100644 index 0000000000..0a57c61958 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/StringWithQualityHeaderValueTest.cs @@ -0,0 +1,339 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Net.Http.Headers +{ + public class StringWithQualityHeaderValueTest + { + [Fact] + public void Ctor_StringOnlyOverload_MatchExpectation() + { + var value = new StringWithQualityHeaderValue("token"); + Assert.Equal("token", value.Value); + Assert.Null(value.Quality); + + Assert.Throws(() => new StringWithQualityHeaderValue(null)); + Assert.Throws(() => new StringWithQualityHeaderValue("")); + Assert.Throws(() => new StringWithQualityHeaderValue("in valid")); + } + + [Fact] + public void Ctor_StringWithQualityOverload_MatchExpectation() + { + var value = new StringWithQualityHeaderValue("token", 0.5); + Assert.Equal("token", value.Value); + Assert.Equal(0.5, value.Quality); + + Assert.Throws(() => new StringWithQualityHeaderValue(null, 0.1)); + Assert.Throws(() => new StringWithQualityHeaderValue("", 0.1)); + Assert.Throws(() => new StringWithQualityHeaderValue("in valid", 0.1)); + + Assert.Throws(() => new StringWithQualityHeaderValue("t", 1.1)); + Assert.Throws(() => new StringWithQualityHeaderValue("t", -0.1)); + } + + [Fact] + public void ToString_UseDifferentValues_AllSerializedCorrectly() + { + var value = new StringWithQualityHeaderValue("token"); + Assert.Equal("token", value.ToString()); + + value = new StringWithQualityHeaderValue("token", 0.1); + Assert.Equal("token; q=0.1", value.ToString()); + + value = new StringWithQualityHeaderValue("token", 0); + Assert.Equal("token; q=0.0", value.ToString()); + + value = new StringWithQualityHeaderValue("token", 1); + Assert.Equal("token; q=1.0", value.ToString()); + + // Note that the quality value gets rounded + value = new StringWithQualityHeaderValue("token", 0.56789); + Assert.Equal("token; q=0.568", value.ToString()); + } + + [Fact] + public void GetHashCode_UseSameAndDifferentValues_SameOrDifferentHashCodes() + { + var value1 = new StringWithQualityHeaderValue("t", 0.123); + var value2 = new StringWithQualityHeaderValue("t", 0.123); + var value3 = new StringWithQualityHeaderValue("T", 0.123); + var value4 = new StringWithQualityHeaderValue("t"); + var value5 = new StringWithQualityHeaderValue("x", 0.123); + var value6 = new StringWithQualityHeaderValue("t", 0.5); + var value7 = new StringWithQualityHeaderValue("t", 0.1234); + var value8 = new StringWithQualityHeaderValue("T"); + var value9 = new StringWithQualityHeaderValue("x"); + + Assert.Equal(value1.GetHashCode(), value2.GetHashCode()); + Assert.Equal(value1.GetHashCode(), value3.GetHashCode()); + Assert.NotEqual(value1.GetHashCode(), value4.GetHashCode()); + Assert.NotEqual(value1.GetHashCode(), value5.GetHashCode()); + Assert.NotEqual(value1.GetHashCode(), value6.GetHashCode()); + Assert.NotEqual(value1.GetHashCode(), value7.GetHashCode()); + Assert.Equal(value4.GetHashCode(), value8.GetHashCode()); + Assert.NotEqual(value4.GetHashCode(), value9.GetHashCode()); + } + + [Fact] + public void Equals_UseSameAndDifferentRanges_EqualOrNotEqualNoExceptions() + { + var value1 = new StringWithQualityHeaderValue("t", 0.123); + var value2 = new StringWithQualityHeaderValue("t", 0.123); + var value3 = new StringWithQualityHeaderValue("T", 0.123); + var value4 = new StringWithQualityHeaderValue("t"); + var value5 = new StringWithQualityHeaderValue("x", 0.123); + var value6 = new StringWithQualityHeaderValue("t", 0.5); + var value7 = new StringWithQualityHeaderValue("t", 0.1234); + var value8 = new StringWithQualityHeaderValue("T"); + var value9 = new StringWithQualityHeaderValue("x"); + + Assert.False(value1.Equals(null), "t; q=0.123 vs. "); + Assert.True(value1.Equals(value2), "t; q=0.123 vs. t; q=0.123"); + Assert.True(value1.Equals(value3), "t; q=0.123 vs. T; q=0.123"); + Assert.False(value1.Equals(value4), "t; q=0.123 vs. t"); + Assert.False(value4.Equals(value1), "t vs. t; q=0.123"); + Assert.False(value1.Equals(value5), "t; q=0.123 vs. x; q=0.123"); + Assert.False(value1.Equals(value6), "t; q=0.123 vs. t; q=0.5"); + Assert.False(value1.Equals(value7), "t; q=0.123 vs. t; q=0.1234"); + Assert.True(value4.Equals(value8), "t vs. T"); + Assert.False(value4.Equals(value9), "t vs. T"); + } + + [Fact] + public void Parse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidParse("text", new StringWithQualityHeaderValue("text")); + CheckValidParse("text;q=0.5", new StringWithQualityHeaderValue("text", 0.5)); + CheckValidParse("text ; q = 0.5", new StringWithQualityHeaderValue("text", 0.5)); + CheckValidParse("\r\n text ; q = 0.5 ", new StringWithQualityHeaderValue("text", 0.5)); + CheckValidParse(" text ", new StringWithQualityHeaderValue("text")); + CheckValidParse(" \r\n text \r\n ; \r\n q = 0.123", new StringWithQualityHeaderValue("text", 0.123)); + CheckValidParse(" text ; q = 0.123 ", new StringWithQualityHeaderValue("text", 0.123)); + CheckValidParse("text;q=1 ", new StringWithQualityHeaderValue("text", 1)); + CheckValidParse("*", new StringWithQualityHeaderValue("*")); + CheckValidParse("*;q=0.7", new StringWithQualityHeaderValue("*", 0.7)); + CheckValidParse(" t", new StringWithQualityHeaderValue("t")); + CheckValidParse("t;q=0.", new StringWithQualityHeaderValue("t", 0)); + CheckValidParse("t;q=1.", new StringWithQualityHeaderValue("t", 1)); + CheckValidParse("t ; q = 0", new StringWithQualityHeaderValue("t", 0)); + CheckValidParse("iso-8859-5", new StringWithQualityHeaderValue("iso-8859-5")); + CheckValidParse("unicode-1-1; q=0.8", new StringWithQualityHeaderValue("unicode-1-1", 0.8)); + } + + [Theory] + [InlineData("text,")] + [InlineData("\r\n text ; q = 0.5, next_text ")] + [InlineData(" text,next_text ")] + [InlineData(" ,, text, , ,next")] + [InlineData(" ,, text, , ,")] + [InlineData(", \r\n text \r\n ; \r\n q = 0.123")] + [InlineData("teäxt")] + [InlineData("text会")] + [InlineData("会")] + [InlineData("t;q=会")] + [InlineData("t;q=")] + [InlineData("t;q")] + [InlineData("t;会=1")] + [InlineData("t;q会=1")] + [InlineData("t y")] + [InlineData("t;q=1 y")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ,,")] + [InlineData("t;q=-1")] + [InlineData("t;q=1.00001")] + [InlineData("t;")] + [InlineData("t;;q=1")] + [InlineData("t;q=a")] + [InlineData("t;qa")] + [InlineData("t;q1")] + public void Parse_SetOfInvalidValueStrings_Throws(string input) + { + Assert.Throws(() => StringWithQualityHeaderValue.Parse(input)); + } + + [Fact] + public void TryParse_SetOfValidValueStrings_ParsedCorrectly() + { + CheckValidTryParse("text", new StringWithQualityHeaderValue("text")); + CheckValidTryParse("text;q=0.5", new StringWithQualityHeaderValue("text", 0.5)); + CheckValidTryParse("text ; q = 0.5", new StringWithQualityHeaderValue("text", 0.5)); + CheckValidTryParse("\r\n text ; q = 0.5 ", new StringWithQualityHeaderValue("text", 0.5)); + CheckValidTryParse(" text ", new StringWithQualityHeaderValue("text")); + CheckValidTryParse(" \r\n text \r\n ; \r\n q = 0.123", new StringWithQualityHeaderValue("text", 0.123)); + } + + [Fact] + public void TryParse_SetOfInvalidValueStrings_ReturnsFalse() + { + CheckInvalidTryParse("text,"); + CheckInvalidTryParse("\r\n text ; q = 0.5, next_text "); + CheckInvalidTryParse(" text,next_text "); + CheckInvalidTryParse(" ,, text, , ,next"); + CheckInvalidTryParse(" ,, text, , ,"); + CheckInvalidTryParse(", \r\n text \r\n ; \r\n q = 0.123"); + CheckInvalidTryParse("teäxt"); + CheckInvalidTryParse("text会"); + CheckInvalidTryParse("会"); + CheckInvalidTryParse("t;q=会"); + CheckInvalidTryParse("t;q="); + CheckInvalidTryParse("t;q"); + CheckInvalidTryParse("t;会=1"); + CheckInvalidTryParse("t;q会=1"); + CheckInvalidTryParse("t y"); + CheckInvalidTryParse("t;q=1 y"); + + CheckInvalidTryParse(null); + CheckInvalidTryParse(string.Empty); + CheckInvalidTryParse(" "); + CheckInvalidTryParse(" ,,"); + } + + [Fact] + public void ParseList_SetOfValidValueStrings_ParsedCorrectly() + { + var inputs = new[] + { + "", + "text1", + "text2,", + "textA,textB", + "text3;q=0.5", + "text4;q=0.5,", + " text5 ; q = 0.50 ", + "\r\n text6 ; q = 0.05 ", + "text7,text8;q=0.5", + " text9 , text10 ; q = 0.5 ", + }; + IList results = StringWithQualityHeaderValue.ParseList(inputs); + + var expectedResults = new[] + { + new StringWithQualityHeaderValue("text1"), + new StringWithQualityHeaderValue("text2"), + new StringWithQualityHeaderValue("textA"), + new StringWithQualityHeaderValue("textB"), + new StringWithQualityHeaderValue("text3", 0.5), + new StringWithQualityHeaderValue("text4", 0.5), + new StringWithQualityHeaderValue("text5", 0.5), + new StringWithQualityHeaderValue("text6", 0.05), + new StringWithQualityHeaderValue("text7"), + new StringWithQualityHeaderValue("text8", 0.5), + new StringWithQualityHeaderValue("text9"), + new StringWithQualityHeaderValue("text10", 0.5), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void TryParseList_SetOfValidValueStrings_ParsedCorrectly() + { + var inputs = new[] + { + "", + "text1", + "text2,", + "textA,textB", + "text3;q=0.5", + "text4;q=0.5,", + " text5 ; q = 0.50 ", + "\r\n text6 ; q = 0.05 ", + "text7,text8;q=0.5", + " text9 , text10 ; q = 0.5 ", + }; + IList results; + Assert.True(StringWithQualityHeaderValue.TryParseList(inputs, out results)); + + var expectedResults = new[] + { + new StringWithQualityHeaderValue("text1"), + new StringWithQualityHeaderValue("text2"), + new StringWithQualityHeaderValue("textA"), + new StringWithQualityHeaderValue("textB"), + new StringWithQualityHeaderValue("text3", 0.5), + new StringWithQualityHeaderValue("text4", 0.5), + new StringWithQualityHeaderValue("text5", 0.5), + new StringWithQualityHeaderValue("text6", 0.05), + new StringWithQualityHeaderValue("text7"), + new StringWithQualityHeaderValue("text8", 0.5), + new StringWithQualityHeaderValue("text9"), + new StringWithQualityHeaderValue("text10", 0.5), + }.ToList(); + + Assert.Equal(expectedResults, results); + } + + [Fact] + public void ParseList_WithSomeInvlaidValues_Throws() + { + var inputs = new[] + { + "", + "text1", + "text 1", + "text2", + "\"text 2\",", + "text3;q=0.5", + "text4;q=0.5, extra stuff", + " text5 ; q = 0.50 ", + "\r\n text6 ; q = 0.05 ", + "text7,text8;q=0.5", + " text9 , text10 ; q = 0.5 ", + }; + Assert.Throws(() => StringWithQualityHeaderValue.ParseList(inputs)); + } + + [Fact] + public void TryParseList_WithSomeInvlaidValues_ReturnsFalse() + { + var inputs = new[] + { + "", + "text1", + "text 1", + "text2", + "\"text 2\",", + "text3;q=0.5", + "text4;q=0.5, extra stuff", + " text5 ; q = 0.50 ", + "\r\n text6 ; q = 0.05 ", + "text7,text8;q=0.5", + " text9 , text10 ; q = 0.5 ", + }; + IList results; + Assert.False(StringWithQualityHeaderValue.TryParseList(inputs, out results)); + } + + #region Helper methods + + private void CheckValidParse(string input, StringWithQualityHeaderValue expectedResult) + { + var result = StringWithQualityHeaderValue.Parse(input); + Assert.Equal(expectedResult, result); + } + + private void CheckValidTryParse(string input, StringWithQualityHeaderValue expectedResult) + { + StringWithQualityHeaderValue result = null; + Assert.True(StringWithQualityHeaderValue.TryParse(input, out result)); + Assert.Equal(expectedResult, result); + } + + private void CheckInvalidTryParse(string input) + { + StringWithQualityHeaderValue result = null; + Assert.False(StringWithQualityHeaderValue.TryParse(input, out result)); + Assert.Null(result); + } + + #endregion + } +} diff --git a/test/Microsoft.Net.Http.Headers.Tests/project.json b/test/Microsoft.Net.Http.Headers.Tests/project.json new file mode 100644 index 0000000000..4ffa75e704 --- /dev/null +++ b/test/Microsoft.Net.Http.Headers.Tests/project.json @@ -0,0 +1,18 @@ +{ + "version": "1.0.0-*", + "dependencies": { + "Microsoft.Net.Http.Headers": "1.0.0-*", + "xunit.runner.kre": "1.0.0-*" + }, + "commands": { + "test": "xunit.runner.kre" + }, + "frameworks": { + "aspnet50": { }, + "aspnetcore50": { + "dependencies": { + "System.Diagnostics.Debug": "4.0.10-beta-*" + } + } + } +}