cookies, headers, and new collection types (#541)

This commit is contained in:
Todd 2020-08-07 09:56:04 -05:00
parent b2658232c4
commit 5f59f4f071
24 changed files with 460 additions and 307 deletions

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net45;netcoreapp2.0;</TargetFrameworks>
<TargetFrameworks>net471;netcoreapp2.0;</TargetFrameworks>
<TargetFrameworks Condition="'$(OS)' != 'Windows_NT'">netcoreapp2.0</TargetFrameworks>
</PropertyGroup>
@ -19,7 +19,7 @@
<ProjectReference Include="..\..\src\Flurl.Http\Flurl.Http.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net45'">
<ItemGroup Condition="'$(TargetFramework)' == 'net471'">
<Reference Include="System.Net.Http" />
<Reference Include="Microsoft.CSharp" />
</ItemGroup>

View File

@ -28,7 +28,7 @@ namespace Flurl.Test.Http
HttpTest.ShouldHaveMadeACall().WithCookies(new { y = "bar", z = "fizz" }).Times(1);
HttpTest.ShouldHaveMadeACall().WithoutCookies().Times(1);
Assert.AreEqual("bar", responses[0].Cookies.TryGetValue("x", out var c) ? c.Value : null);
Assert.AreEqual("bar", responses[0].Cookies.FirstOrDefault(c => c.Name == "x")?.Value);
Assert.IsEmpty(responses[1].Cookies);
Assert.IsEmpty(responses[2].Cookies);
}
@ -57,8 +57,8 @@ namespace Flurl.Test.Http
HttpTest.ShouldHaveMadeACall().WithCookies(new { x = "foo", y = "bazz" }).Times(1);
Assert.AreEqual(2, jar.Count);
Assert.AreEqual("foo", jar["x"].Value);
Assert.AreEqual("bazz", jar["y"].Value);
Assert.AreEqual(1, jar.Count(c => c.Name == "x" && c.Value == "foo"));
Assert.AreEqual(1, jar.Count(c => c.Name == "y" && c.Value == "bazz"));
}
[Test]
@ -82,8 +82,8 @@ namespace Flurl.Test.Http
HttpTest.ShouldHaveMadeACall().WithCookies(new { x = "foo", y = "bazz" }).Times(1);
Assert.AreEqual(2, jar.Count);
Assert.AreEqual("foo", jar["x"].Value);
Assert.AreEqual("bazz", jar["y"].Value);
Assert.AreEqual(1, jar.Count(c => c.Name == "x" && c.Value == "foo"));
Assert.AreEqual(1, jar.Count(c => c.Name == "y" && c.Value == "bazz"));
}
[Test]
@ -106,15 +106,15 @@ namespace Flurl.Test.Http
HttpTest.ShouldHaveMadeACall().WithCookies(new { x = "foo", y = "bazz" }).Times(1);
Assert.AreEqual(2, cs.Cookies.Count);
Assert.AreEqual("foo", cs.Cookies["x"].Value);
Assert.AreEqual("bazz", cs.Cookies["y"].Value);
Assert.AreEqual(1, cs.Cookies.Count(c => c.Name == "x" && c.Value == "foo"));
Assert.AreEqual(1, cs.Cookies.Count(c => c.Name == "y" && c.Value == "bazz"));
}
}
[Test]
public void can_parse_set_cookie_header() {
var start = DateTimeOffset.UtcNow;
var cookie = CookieCutter.FromResponseHeader("https://www.cookies.com/a/b", "x=foo ; DoMaIn=cookies.com ; path=/ ; MAX-AGE=999 ; expires= ; secure ;HTTPONLY ;samesite=none");
var cookie = CookieCutter.ParseResponseHeader("https://www.cookies.com/a/b", "x=foo ; DoMaIn=cookies.com ; path=/ ; MAX-AGE=999 ; expires= ; secure ;HTTPONLY ;samesite=none");
Assert.AreEqual("https://www.cookies.com/a/b", cookie.OriginUrl.ToString());
Assert.AreEqual("x", cookie.Name);
Assert.AreEqual("foo", cookie.Value);
@ -130,7 +130,7 @@ namespace Flurl.Test.Http
// simpler case
start = DateTimeOffset.UtcNow;
cookie = CookieCutter.FromResponseHeader("https://www.cookies.com/a/b", "y=bar");
cookie = CookieCutter.ParseResponseHeader("https://www.cookies.com/a/b", "y=bar");
Assert.AreEqual("https://www.cookies.com/a/b", cookie.OriginUrl.ToString());
Assert.AreEqual("y", cookie.Name);
Assert.AreEqual("bar", cookie.Value);
@ -145,68 +145,79 @@ namespace Flurl.Test.Http
Assert.LessOrEqual(cookie.DateReceived, DateTimeOffset.UtcNow);
}
[Test]
public void cannot_change_cookie_after_adding_to_jar() {
var cookie = new FlurlCookie("x", "foo", "https://cookies.com");
// good
cookie.Value = "value2";
cookie.Path = "/";
cookie.Secure = true;
var jar = new CookieJar().AddOrUpdate(cookie);
// bad
Assert.Throws<Exception>(() => cookie.Value = "value3");
Assert.Throws<Exception>(() => cookie.Path = "/a");
Assert.Throws<Exception>(() => cookie.Secure = false);
}
[Test]
public void url_decodes_cookie_value() {
var cookie = CookieCutter.FromResponseHeader("https://cookies.com", "x=one%3A%20for%20the%20money");
var cookie = CookieCutter.ParseResponseHeader("https://cookies.com", "x=one%3A%20for%20the%20money");
Assert.AreEqual("one: for the money", cookie.Value);
}
[Test]
public void unquotes_cookie_value() {
var cookie = CookieCutter.FromResponseHeader("https://cookies.com", "x=\"hello there\"" );
var cookie = CookieCutter.ParseResponseHeader("https://cookies.com", "x=\"hello there\"" );
Assert.AreEqual("hello there", cookie.Value);
}
[Test]
public void jar_syncs_to_request_cookies() {
var jar = new CookieJar().AddOrUpdate("x", "foo", "https://cookies.com");
public void jar_overwrites_request_cookies() {
var jar = new CookieJar()
.AddOrUpdate("b", 10, "https://cookies.com")
.AddOrUpdate("c", 11, "https://cookies.com");
var req = new FlurlRequest("http://cookies.com").WithCookies(jar);
Assert.IsTrue(req.Cookies.ContainsKey("x"));
Assert.AreEqual("foo", req.Cookies["x"]);
var req = new FlurlRequest("http://cookies.com")
.WithCookies(new { a = 1, b = 2 })
.WithCookies(jar);
jar["x"].Value = "new val!";
Assert.AreEqual("new val!", req.Cookies["x"]);
jar.AddOrUpdate("y", "bar", "https://cookies.com");
Assert.IsTrue(req.Cookies.ContainsKey("y"));
Assert.AreEqual("bar", req.Cookies["y"]);
jar["x"].Secure = true;
Assert.IsFalse(req.Cookies.ContainsKey("x"));
jar.Clear();
Assert.IsFalse(req.Cookies.Any());
Assert.AreEqual(3, req.Cookies.Count());
Assert.IsTrue(req.Cookies.Contains(("a", "1")));
Assert.IsTrue(req.Cookies.Contains(("b", "10"))); // the important one
Assert.IsTrue(req.Cookies.Contains(("c", "11")));
}
[Test]
public async Task request_cookies_do_not_sync_to_jar() {
var jar = new CookieJar().AddOrUpdate("x", "foo", "https://cookies.com");
public async Task request_cookies_do_not_overwrite_jar() {
var jar = new CookieJar()
.AddOrUpdate("b", 10, "https://cookies.com")
.AddOrUpdate("c", 11, "https://cookies.com");
var req = new FlurlRequest("http://cookies.com").WithCookies(jar);
Assert.IsTrue(req.Cookies.ContainsKey("x"));
Assert.AreEqual("foo", req.Cookies["x"]);
var req = new FlurlRequest("http://cookies.com")
.WithCookies(jar)
.WithCookies(new { a = 1, b = 2 });
// changing cookie at request level shouldn't touch jar
req.Cookies["x"] = "bar";
Assert.AreEqual("foo", jar["x"].Value);
await req.GetAsync();
HttpTest.ShouldHaveMadeACall().WithCookies(new { x = "bar" });
Assert.AreEqual(3, req.Cookies.Count());
Assert.IsTrue(req.Cookies.Contains(("a", "1")));
Assert.IsTrue(req.Cookies.Contains(("b", "2"))); // value overwritten but just for this request
Assert.IsTrue(req.Cookies.Contains(("c", "11")));
// re-check after send
Assert.AreEqual("foo", jar["x"].Value);
// b in jar wasn't touched
Assert.AreEqual("10", jar.FirstOrDefault(c => c.Name == "b")?.Value);
}
[Test]
public void request_cookies_sync_with_cookie_header() {
var req = new FlurlRequest("http://cookies.com").WithCookie("x", "foo");
Assert.AreEqual("x=foo", req.Headers.TryGetValue("Cookie", out var val) ? val : null);
Assert.AreEqual("x=foo", req.Headers.FirstOrDefault("Cookie"));
// should flow from CookieJar too
var jar = new CookieJar().AddOrUpdate("y", "bar", "http://cookies.com");
req = new FlurlRequest("http://cookies.com").WithCookies(jar);
Assert.AreEqual("y=bar", req.Headers.TryGetValue("Cookie", out val) ? val : null);
Assert.AreEqual("y=bar", req.Headers.FirstOrDefault("Cookie"));
}
[TestCase("https://domain1.com", "https://domain1.com", true)]
@ -321,7 +332,7 @@ namespace Flurl.Test.Http
Assert.IsEmpty(jar);
// even though the CookieJar rejected the cookie, it doesn't change the fact
// that it exists in the response.
Assert.AreEqual("foo", resp.Cookies.TryGetValue("x", out var c) ? c.Value : null);
Assert.AreEqual("foo", resp.Cookies.FirstOrDefault(c => c.Name == "x")?.Value);
}
/// <summary>
@ -336,25 +347,19 @@ namespace Flurl.Test.Http
Assert.AreEqual(shouldAddToJar, jar.TryAddOrUpdate(cookie, out reason));
if (shouldAddToJar)
CollectionAssert.Contains(jar.Keys, cookie.Name);
Assert.AreEqual(cookie.Name, jar.SingleOrDefault()?.Name);
else {
Assert.Throws<InvalidCookieException>(() => jar.AddOrUpdate(cookie));
CollectionAssert.DoesNotContain(jar.Keys, cookie.Name);
CollectionAssert.IsEmpty(jar);
}
var req = cookie.OriginUrl.WithCookies(jar);
if (shouldAddToJar)
CollectionAssert.Contains(req.Cookies.Keys, cookie.Name);
else
CollectionAssert.DoesNotContain(req.Cookies.Keys, cookie.Name);
Assert.AreEqual(shouldAddToJar, req.Cookies.Contains((cookie.Name, cookie.Value)));
if (requestUrl != null) {
Assert.AreEqual(shouldSend, cookie.ShouldSendTo(requestUrl, out reason), reason);
req = requestUrl.WithCookies(jar);
if (shouldSend)
CollectionAssert.Contains(req.Cookies.Keys, cookie.Name);
else
CollectionAssert.DoesNotContain(req.Cookies.Keys, cookie.Name);
Assert.AreEqual(shouldSend, req.Cookies.Contains((cookie.Name, cookie.Value)));
}
}
}

View File

@ -34,7 +34,7 @@ namespace Flurl.Test.Http
var resp = await "http://some-api.com".GetAsync();
Assert.AreEqual(234, resp.StatusCode);
Assert.IsTrue(resp.Headers.TryGetValue("my-header", out var headerVal));
Assert.IsTrue(resp.Headers.TryGetFirst("my-header", out var headerVal));
Assert.AreEqual("hi", headerVal);
var data = await resp.GetJsonAsync<TestData>();

View File

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;
@ -289,8 +289,9 @@ namespace Flurl.Test.Http
[Test]
public async Task can_send_cookies() {
var req = "https://httpbin.org/cookies".WithCookies(new { x = 1, y = 2 });
Assert.AreEqual("1", req.Cookies["x"]);
Assert.AreEqual("2", req.Cookies["y"]);
Assert.AreEqual(2, req.Cookies.Count());
Assert.IsTrue(req.Cookies.Contains(("x", "1")));
Assert.IsTrue(req.Cookies.Contains(("y", "2")));
var s = await req.GetStringAsync();
@ -304,16 +305,17 @@ namespace Flurl.Test.Http
public async Task can_receive_cookies() {
// endpoint does a redirect, so we need to disable auto-redirect in order to see the cookie in the response
var resp = await "https://httpbin.org/cookies/set?z=999".WithAutoRedirect(false).GetAsync();
Assert.AreEqual("999", resp.Cookies["z"].Value);
Assert.AreEqual("999", resp.Cookies.FirstOrDefault(c => c.Name == "z")?.Value);
// but using WithCookies we can capture it even with redirects enabled
await "https://httpbin.org/cookies/set?z=999".WithCookies(out var cookies).GetAsync();
Assert.AreEqual("999", cookies["z"].Value);
Assert.AreEqual("999", cookies.FirstOrDefault(c => c.Name == "z")?.Value);
// this works with redirects too
using (var session = new CookieSession("https://httpbin.org/cookies")) {
await session.Request("set?z=999").GetAsync();
Assert.AreEqual("999", session.Cookies["z"].Value);
Assert.AreEqual("999", session.Cookies.FirstOrDefault(c => c.Name == "z")?.Value);
}
}

View File

@ -32,8 +32,7 @@ namespace Flurl.Test.Http
[Test]
public void can_set_header() {
var sc = GetSettingsContainer().WithHeader("a", 1);
Assert.AreEqual(1, sc.Headers.Count);
Assert.AreEqual(1, sc.Headers["a"]);
Assert.AreEqual(("a", 1), sc.Headers.Single());
}
[Test]
@ -41,8 +40,8 @@ namespace Flurl.Test.Http
// null values shouldn't be added
var sc = GetSettingsContainer().WithHeaders(new { a = "b", one = 2, three = (object)null });
Assert.AreEqual(2, sc.Headers.Count);
Assert.AreEqual("b", sc.Headers["a"]);
Assert.AreEqual(2, sc.Headers["one"]);
Assert.IsTrue(sc.Headers.Contains("a", "b"));
Assert.IsTrue(sc.Headers.Contains("one", 2));
}
[Test]
@ -51,48 +50,48 @@ namespace Flurl.Test.Http
Assert.AreEqual(2, sc.Headers.Count);
sc.WithHeader("b", null);
Assert.AreEqual(1, sc.Headers.Count);
Assert.AreEqual("a", sc.Headers.Keys.Single());
Assert.IsFalse(sc.Headers.Contains("b"));
}
[Test]
public void can_set_headers_from_dictionary() {
var sc = GetSettingsContainer().WithHeaders(new Dictionary<string, object> { { "a", "b" }, { "one", 2 } });
Assert.AreEqual(2, sc.Headers.Count);
Assert.AreEqual("b", sc.Headers["a"]);
Assert.AreEqual(2, sc.Headers["one"]);
Assert.IsTrue(sc.Headers.Contains("a", "b"));
Assert.IsTrue(sc.Headers.Contains("one", 2));
}
[Test]
public void underscores_in_properties_convert_to_hyphens_in_header_names() {
var sc = GetSettingsContainer().WithHeaders(new { User_Agent = "Flurl", Cache_Control = "no-cache" });
Assert.IsTrue(sc.Headers.ContainsKey("User-Agent"));
Assert.IsTrue(sc.Headers.ContainsKey("Cache-Control"));
Assert.IsTrue(sc.Headers.Contains("User-Agent"));
Assert.IsTrue(sc.Headers.Contains("Cache-Control"));
// make sure we can disable the behavior
sc.WithHeaders(new { no_i_really_want_underscores = "foo" }, false);
Assert.IsTrue(sc.Headers.ContainsKey("no_i_really_want_underscores"));
Assert.IsTrue(sc.Headers.Contains("no_i_really_want_underscores"));
// dictionaries don't get this behavior since you can use hyphens explicitly
sc.WithHeaders(new Dictionary<string, string> { { "exclude_dictionaries", "bar" } });
Assert.IsTrue(sc.Headers.ContainsKey("exclude_dictionaries"));
Assert.IsTrue(sc.Headers.Contains("exclude_dictionaries"));
// same with strings
sc.WithHeaders("exclude_strings=123");
Assert.IsTrue(sc.Headers.ContainsKey("exclude_strings"));
Assert.IsTrue(sc.Headers.Contains("exclude_strings"));
}
[Test]
public void can_setup_oauth_bearer_token() {
var sc = GetSettingsContainer().WithOAuthBearerToken("mytoken");
Assert.AreEqual(1, sc.Headers.Count);
Assert.AreEqual("Bearer mytoken", sc.Headers["Authorization"]);
Assert.IsTrue(sc.Headers.Contains("Authorization", "Bearer mytoken"));
}
[Test]
public void can_setup_basic_auth() {
var sc = GetSettingsContainer().WithBasicAuth("user", "pass");
Assert.AreEqual(1, sc.Headers.Count);
Assert.AreEqual("Basic dXNlcjpwYXNz", sc.Headers["Authorization"]);
Assert.IsTrue(sc.Headers.Contains("Authorization", "Basic dXNlcjpwYXNz"));
}
[Test]

View File

@ -352,9 +352,7 @@ namespace Flurl.Test.Http
HttpTest.RespondWith(headers: new { h1 = "foo" });
var resp = await "http://www.api.com".GetAsync();
Assert.AreEqual(1, resp.Headers.Count());
Assert.AreEqual("h1", resp.Headers.First().Key);
Assert.AreEqual("foo", resp.Headers.First().Value);
Assert.AreEqual(("h1", "foo"), resp.Headers.Single());
}
[Test]
@ -363,7 +361,7 @@ namespace Flurl.Test.Http
var resp = await "http://www.api.com".GetAsync();
Assert.AreEqual(1, resp.Cookies.Count);
Assert.AreEqual("foo", resp.Cookies["c1"].Value);
Assert.AreEqual("foo", resp.Cookies.FirstOrDefault(c => c.Name == "c1")?.Value);
}
// https://github.com/tmenier/Flurl/issues/175
@ -423,7 +421,7 @@ namespace Flurl.Test.Http
public async Task can_fake_content_headers() {
HttpTest.RespondWith("<xml></xml>", 200, new { Content_Type = "text/xml" });
await "http://api.com".GetAsync();
HttpTest.ShouldHaveMadeACall().With(call => call.Response.Headers.Any(kv => kv.Key == "Content-Type" && kv.Value == "text/xml"));
HttpTest.ShouldHaveMadeACall().With(call => call.Response.Headers.Contains(("Content-Type", "text/xml")));
HttpTest.ShouldHaveMadeACall().With(call => call.HttpResponseMessage.Content.Headers.ContentType.MediaType == "text/xml");
}

View File

@ -83,7 +83,7 @@ namespace Flurl.Http.CodeGen
yield return Create("WithHeader", "Creates a new FlurlRequest and sets a request header.")
.AddArg("name", "string", "The header name.")
.AddArg("value", "object", "The header value.");
yield return Create("WithHeaders", "Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent")
yield return Create("WithHeaders", "Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent.")
.AddArg("headers", "object", "Names/values of HTTP headers to set. Typically an anonymous object or IDictionary.")
.AddArg("replaceUnderscoreWithHyphen", "bool", "If true, underscores in property names will be replaced by hyphens. Default is true.", "true");
yield return Create("WithBasicAuth", "Creates a new FlurlRequest and sets the Authorization header according to Basic Authentication protocol.")
@ -93,11 +93,12 @@ namespace Flurl.Http.CodeGen
.AddArg("token", "string", "The acquired oAuth bearer token.");
// cookie extensions
yield return Create("WithCookie", "Creates a new FlurlRequest and sets an HTTP cookie to be sent with this request only. To maintain a cookie \"session\", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead.")
yield return Create("WithCookie", "Creates a new FlurlRequest and adds a name-value pair to its Cookie header. " +
"To automatically maintain a cookie \"session\", consider using a CookieJar or CookieSession instead.")
.AddArg("name", "string", "The cookie name.")
.AddArg("value", "object", "The cookie value.");
yield return Create("WithCookies", "Creates a new FlurlRequest and sets HTTP cookies to be sent with this request only, based on property names/values of the provided object, or keys/values " +
"if object is a dictionary. To maintain a cookie \"session\", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead")
yield return Create("WithCookies", "Creates a new FlurlRequest and adds name-value pairs to its Cookie header based on property names/values of the provided object, or keys/values if object is a dictionary. " +
"To automatically maintain a cookie \"session\", consider using a CookieJar or CookieSession instead.")
.AddArg("values", "object", "Names/values of HTTP cookies to set. Typically an anonymous object or IDictionary.");
yield return Create("WithCookies", "Creates a new FlurlRequest and sets the CookieJar associated with this request, which will be updated with any Set-Cookie headers present in the response and is suitable for reuse in subsequent requests.")
.AddArg("cookieJar", "CookieJar", "The CookieJar.");

View File

@ -10,21 +10,29 @@ namespace Flurl.Http
/// </summary>
public static class CookieCutter
{
/// <summary>
/// Parses a Cookie request header to name-value pairs.
/// </summary>
/// <param name="headerValue">Value of the Cookie request header.</param>
/// <returns></returns>
public static IEnumerable<(string Name, string Value)> ParseRequestHeader(string headerValue) {
if (string.IsNullOrEmpty(headerValue)) yield break;
foreach (var pair in GetPairs(headerValue))
yield return (pair.Name, Url.Decode(pair.Value, false));
}
/// <summary>
/// Parses a Set-Cookie response header to a FlurlCookie.
/// </summary>
/// <param name="url">The URL that sent the response.</param>
/// <param name="headerValue">Value of the Set-Cookie header.</param>
/// <returns></returns>
public static FlurlCookie FromResponseHeader(string url, string headerValue) {
public static FlurlCookie ParseResponseHeader(string url, string headerValue) {
if (string.IsNullOrEmpty(headerValue)) return null;
var pairs = (
from part in headerValue.Split(';')
let pair = part.SplitOnFirstOccurence("=")
select new { Name = pair[0].Trim(), Value = pair.Last().Trim() });
FlurlCookie cookie = null;
foreach (var pair in pairs) {
foreach (var pair in GetPairs(headerValue)) {
if (cookie == null)
cookie = new FlurlCookie(pair.Name, Url.Decode(pair.Value.Trim('"'), false), url, DateTimeOffset.UtcNow);
@ -49,15 +57,22 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a Cookie request header value from a key-value dictionary.
/// Parses list of semicolon-delimited "name=value" pairs.
/// </summary>
/// <param name="values">Cookie values.</param>
/// <returns>a header value if cookie values are present, otherwise null.</returns>
public static string ToRequestHeader(IDictionary<string, object> values) {
if (values?.Any() != true) return null;
private static IEnumerable<(string Name, string Value)> GetPairs(string list) =>
from part in list.Split(';')
let pair = part.SplitOnFirstOccurence("=")
select (pair[0].Trim(), pair.Last().Trim());
return string.Join("; ", values.Select(c =>
$"{Url.Encode(c.Key)}={Url.Encode(c.Value.ToInvariantString())}"));
/// <summary>
/// Creates a Cookie request header value from a list of cookie name-value pairs.
/// </summary>
/// <returns>A header value if cookie values are present, otherwise null.</returns>
public static string ToRequestHeader(IEnumerable<(string Name, string Value)> cookies) {
if (cookies?.Any() != true) return null;
return string.Join("; ", cookies.Select(c =>
$"{Url.Encode(c.Name)}={Url.Encode(c.Value)}"));
}
/// <summary>

View File

@ -1,3 +1,4 @@
using System.Linq;
using Flurl.Util;
namespace Flurl.Http
@ -8,31 +9,38 @@ namespace Flurl.Http
public static class CookieExtensions
{
/// <summary>
/// Sets an HTTP cookie to be sent with this request only.
/// To maintain a cookie "session", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead.
/// Adds or updates a name-value pair in this request's Cookie header.
/// To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="request">The IFlurlRequest.</param>
/// <param name="name">The cookie name.</param>
/// <param name="value">The cookie value.</param>
/// <returns>This IFlurlClient instance.</returns>
public static IFlurlRequest WithCookie(this IFlurlRequest request, string name, object value) {
request.Cookies[name] = value;
return request;
var cookies = new NameValueList<string>(request.Cookies);
cookies.AddOrReplace(name, value.ToInvariantString());
return request.WithHeader("Cookie", CookieCutter.ToRequestHeader(cookies));
}
/// <summary>
/// Sets HTTP cookies to be sent with this request only, based on property names/values of the provided object, or
/// keys/values if object is a dictionary. To maintain a cookie "session", consider using WithCookies(CookieJar)
/// or FlurlClient.StartCookieSession instead.
/// Adds or updates name-value pairs in this request's Cookie header, based on property names/values
/// of the provided object, or keys/values if object is a dictionary.
/// To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="request">The IFlurlRequest.</param>
/// <param name="values">Names/values of HTTP cookies to set. Typically an anonymous object or IDictionary.</param>
/// <returns>This IFlurlClient.</returns>
public static IFlurlRequest WithCookies(this IFlurlRequest request, object values) {
foreach (var kv in values.ToKeyValuePairs())
request.Cookies[kv.Key] = kv.Value?.ToInvariantString();
return request;
var cookies = new NameValueList<string>(request.Cookies);
// although rare, we need to accommodate the possibility of multiple cookies with the same name
foreach (var group in values.ToKeyValuePairs().GroupBy(x => x.Key)) {
// add or replace the first one (by name)
cookies.AddOrReplace(group.Key, group.First().Value.ToInvariantString());
// append the rest
foreach (var kv in group.Skip(1))
cookies.Add(kv.Key, kv.Value.ToInvariantString());
}
return request.WithHeader("Cookie", CookieCutter.ToRequestHeader(cookies));
}
/// <summary>

View File

@ -8,20 +8,15 @@ using Flurl.Util;
namespace Flurl.Http
{
/// <summary>
/// A collection of FlurlCookies that can be passed to one or more FlurlRequests, either
/// explicitly via WithCookies or implicitly via FlurlClient.StartCookieSession. Automatically
/// populated/synchronized with cookies received via Set-Cookie response headers. Chooses
/// which cookies to send in Cookie request per RFC 6265.
/// A collection of FlurlCookies that can be attached to one or more FlurlRequests, either explicitly via WithCookies
/// or implicitly via a CookieSession. Stores cookies received via Set-Cookie response headers.
/// </summary>
public class CookieJar : IReadOnlyDictionary<string, FlurlCookie>
public class CookieJar : IReadOnlyCollection<FlurlCookie>
{
private readonly ConcurrentDictionary<string, FlurlCookie> _dict = new ConcurrentDictionary<string, FlurlCookie>();
// requests whose Cookies collection should be kept in sync with changes to this CookieJar
private readonly HashSet<IFlurlRequest> _syncdRequests = new HashSet<IFlurlRequest>();
/// <summary>
/// Add a cookie to the jar or update if one with the same Name already exists.
/// Adds a cookie to the jar or updates if one with the same Name/Domain/Path already exists.
/// </summary>
/// <param name="name">Name of the cookie.</param>
/// <param name="value">Value of the cookie.</param>
@ -31,7 +26,7 @@ namespace Flurl.Http
AddOrUpdate(new FlurlCookie(name, value.ToInvariantString(), originUrl, dateReceived));
/// <summary>
/// Adds a cookie to the jar or updates if one with the same Name already exists.
/// Adds a cookie to the jar or updates if one with the same Name/Domain/Path already exists.
/// Throws FlurlHttpException if cookie is invalid.
/// </summary>
public CookieJar AddOrUpdate(FlurlCookie cookie) {
@ -42,28 +37,27 @@ namespace Flurl.Http
}
/// <summary>
/// Adds a cookie to the jar or updates if one with the same Name already exists, if it is valid.
/// Returns true if cookie is valid and was added. If false, provides descriptive reason.
/// Adds a cookie to the jar or updates if one with the same Name/Domain/Path already exists,
/// but only if it is valid and not expired.
/// </summary>
/// <returns>true if cookie is valid and was added or updated. If false, provides descriptive reason.</returns>
public bool TryAddOrUpdate(FlurlCookie cookie, out string reason) {
if (!cookie.IsValid(out reason) || cookie.IsExpired(out reason))
return false;
cookie.Changed += (_, name) => SyncToRequests(cookie, false);
_dict[cookie.Name] = cookie;
SyncToRequests(cookie, false);
cookie.Lock(); // makes immutable
_dict[cookie.GetKey()] = cookie;
return true;
}
/// <summary>
/// Removes a cookie from the CookieJar.
/// Removes all cookies matching the given predicate.
/// </summary>
/// <param name="name">The cookie name.</param>
public CookieJar Remove(string name) {
if (_dict.TryRemove(name, out var cookie))
SyncToRequests(cookie, true);
public CookieJar Remove(Func<FlurlCookie, bool> predicate) {
var keys = _dict.Where(kv => predicate(kv.Value)).Select(kv => kv.Key).ToList();
foreach (var key in keys)
_dict.TryRemove(key, out _);
return this;
}
@ -71,59 +65,18 @@ namespace Flurl.Http
/// Removes all cookies from this CookieJar
/// </summary>
public CookieJar Clear() {
var all = _dict.Values;
_dict.Clear();
foreach (var cookie in all)
SyncToRequests(cookie, true);
return this;
}
/// <summary>
/// Ensures changes to the CookieJar are kept in sync with the Cookies collection of the FlurlRequest
/// </summary>
internal void SyncWith(IFlurlRequest req) {
foreach (var cookie in this.Values.Where(c => c.ShouldSendTo(req.Url, out _)))
req.Cookies[cookie.Name] = cookie.Value;
_syncdRequests.Add(req);
}
/// <summary>
/// Stops synchronization of changes to the CookieJar with the Cookies collection of the FlurlRequest
/// </summary>
internal void UnsyncWith(IFlurlRequest req) => _syncdRequests.Remove(req);
private void SyncToRequests(FlurlCookie cookie, bool removed) {
foreach (var req in _syncdRequests) {
if (removed || !cookie.ShouldSendTo(req.Url, out _))
req.Cookies.Remove(cookie.Name);
else
req.Cookies[cookie.Name] = cookie.Value;
}
}
/// <inheritdoc/>
public IEnumerator<KeyValuePair<string, FlurlCookie>> GetEnumerator() => _dict.GetEnumerator();
public IEnumerator<FlurlCookie> GetEnumerator() => _dict.Values.GetEnumerator();
/// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator() => _dict.GetEnumerator();
/// <inheritdoc/>
public int Count => _dict.Count;
/// <inheritdoc/>
public bool ContainsKey(string key) => _dict.ContainsKey(key);
/// <inheritdoc/>
public bool TryGetValue(string key, out FlurlCookie value) => _dict.TryGetValue(key, out value);
/// <inheritdoc/>
public FlurlCookie this[string key] => _dict[key];
/// <inheritdoc/>
public IEnumerable<string> Keys => _dict.Keys;
/// <inheritdoc/>
public IEnumerable<FlurlCookie> Values => _dict.Values;
}
/// <summary>

View File

@ -90,10 +90,7 @@ namespace Flurl.Http
}
/// <inheritdoc />
public IDictionary<string, object> Headers { get; } = new ConcurrentDictionary<string, object>();
/// <inheritdoc />
public IDictionary<string, string> Cookies { get; set; } = new ConcurrentDictionary<string, string>();
public INameValueList<object> Headers { get; } = new NameValueList<object>();
/// <inheritdoc />
public HttpClient HttpClient => HttpTest.Current?.HttpClient ?? _injectedClient ?? GetHttpClient();

View File

@ -37,6 +37,8 @@ namespace Flurl.Http
private bool _httpOnly;
private SameSite? _sameSite;
private bool _locked;
/// <summary>
/// Creates a new FlurlCookie.
/// </summary>
@ -51,11 +53,6 @@ namespace Flurl.Http
DateReceived = dateReceived ?? DateTimeOffset.UtcNow;
}
/// <summary>
/// Event raised when a cookie is changed.
/// </summary>
internal event EventHandler<string> Changed;
/// <summary>
/// The URL that originally sent the Set-Cookie response header. If adding to a CookieJar, this is required unless
/// both Domain AND Path are specified.
@ -78,7 +75,7 @@ namespace Flurl.Http
/// </summary>
public string Value {
get => _value;
set => UpdateAndNotify(ref _value, value);
set => Update(ref _value, value);
}
/// <summary>
@ -86,7 +83,7 @@ namespace Flurl.Http
/// </summary>
public DateTimeOffset? Expires {
get => _expires;
set => UpdateAndNotify(ref _expires, value);
set => Update(ref _expires, value);
}
/// <summary>
@ -94,7 +91,7 @@ namespace Flurl.Http
/// </summary>
public int? MaxAge {
get => _maxAge;
set => UpdateAndNotify(ref _maxAge, value);
set => Update(ref _maxAge, value);
}
/// <summary>
@ -102,7 +99,7 @@ namespace Flurl.Http
/// </summary>
public string Domain {
get => _domain;
set => UpdateAndNotify(ref _domain, value);
set => Update(ref _domain, value);
}
/// <summary>
@ -110,7 +107,7 @@ namespace Flurl.Http
/// </summary>
public string Path {
get => _path;
set => UpdateAndNotify(ref _path, value);
set => Update(ref _path, value);
}
/// <summary>
@ -118,7 +115,7 @@ namespace Flurl.Http
/// </summary>
public bool Secure {
get => _secure;
set => UpdateAndNotify(ref _secure, value);
set => Update(ref _secure, value);
}
/// <summary>
@ -126,7 +123,7 @@ namespace Flurl.Http
/// </summary>
public bool HttpOnly {
get => _httpOnly;
set => UpdateAndNotify(ref _httpOnly, value);
set => Update(ref _httpOnly, value);
}
/// <summary>
@ -134,17 +131,37 @@ namespace Flurl.Http
/// </summary>
public SameSite? SameSite {
get => _sameSite;
set => UpdateAndNotify(ref _sameSite, value);
set => Update(ref _sameSite, value);
}
private void UpdateAndNotify<T>(ref T field, T newVal, [CallerMemberName]string propName = null) {
/// <summary>
/// Generates a key based on cookie Name, Domain, and Path (using OriginalUrl in the absence of Domain/Path).
/// Used by CookieJar to determine whether to add a cookie or update an existing one.
/// </summary>
public string GetKey() {
var domain = string.IsNullOrEmpty(Domain) ? "*" + OriginUrl.Host : Domain;
var path = string.IsNullOrEmpty(Path) ? OriginUrl.Path : Path;
if (path.Length == 0) path = "/";
return $"{domain}{path}:{Name.ToLowerInvariant()}";
}
/// <summary>
/// Makes this cookie immutable. Call when added to a jar.
/// </summary>
internal void Lock() {
_locked = true;
}
private void Update<T>(ref T field, T newVal, [CallerMemberName]string propName = null) {
// == throws with generics (strangely), and .Equals needs a null check. Jon Skeet to the rescue.
// https://stackoverflow.com/a/390974/62600
if (EqualityComparer<T>.Default.Equals(field, newVal))
return;
if (_locked)
throw new Exception("After a cookie has been added to a CookieJar, it becomes immutable and cannot be changed.");
field = newVal;
Changed?.Invoke(this, propName);
}
}
}

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@ -22,8 +21,8 @@ namespace Flurl.Http
IFlurlClient Client { get; set; }
/// <summary>
/// The HTTP method of the request. Normally you don't need to set this explicitly; it will be set
/// when you call the sending method (GetAsync, PostAsync, etc.)
/// Gets or sets the HTTP method of the request. Normally you don't need to set this explicitly; it will be set
/// when you call the sending method, such as GetAsync, PostAsync, etc.
/// </summary>
HttpMethod Verb { get; set; }
@ -33,17 +32,14 @@ namespace Flurl.Http
Url Url { get; set; }
/// <summary>
/// Collection of HTTP cookie values to be sent in this request's Cookie header. If a CookieJar is used, values
/// from the jar that will be sent in this request will be sync'd to this collection automatically, but NOT
/// vice-versa. Therefore, you can use this collection to override values set by the jar for this request only,
/// but for a multi-request cookie "session" it is better to set values in the CookieJar and reuse it.
/// Gets Name/Value pairs parsed from the Cookie request header.
/// </summary>
IDictionary<string, object> Cookies { get; }
IEnumerable<(string Name, string Value)> Cookies { get; }
/// <summary>
/// Collection of HTTP cookies that can be shared between multiple requests. Automatically adds/updates cookies
/// received via Set-Cookie headers in this response. Processes rules based on attributes (Domain, Path, Expires, etc.)
/// to determine which cookies to send with this specific request, and synchronizes those with the Cookies collection.
/// Gets or sets the collection of HTTP cookies that can be shared between multiple requests. When set, values that
/// should be sent with this request (based on Domain, Path, and other rules) are immediately copied to the Cookie
/// request header, and any Set-Cookie headers received in the response will be written to the CookieJar.
/// </summary>
CookieJar CookieJar { get; set; }
@ -64,9 +60,8 @@ namespace Flurl.Http
private FlurlHttpSettings _settings;
private IFlurlClient _client;
private Url _url;
private IDictionary<string, object> _headers = new ConcurrentDictionary<string, object>();
private CookieJar _cookieJar;
private FlurlCall _redirectedFrom;
private CookieJar _jar;
/// <summary>
/// Initializes a new instance of the <see cref="FlurlRequest"/> class.
@ -139,27 +134,24 @@ namespace Flurl.Http
}
/// <inheritdoc />
public IDictionary<string, object> Headers {
get {
if (Cookies.Any())
_headers["Cookie"] = CookieCutter.ToRequestHeader(Cookies);
return _headers;
}
}
public INameValueList<object> Headers { get; } = new NameValueList<object>();
/// <inheritdoc />
public IDictionary<string, object> Cookies { get; } = new ConcurrentDictionary<string, object>();
public IEnumerable<(string Name, string Value)> Cookies =>
CookieCutter.ParseRequestHeader(Headers.FirstOrDefault("Cookie")?.ToInvariantString());
/// <inheritdoc />
public CookieJar CookieJar {
get => _cookieJar;
get => _jar;
set {
_cookieJar?.UnsyncWith(this);
_cookieJar = value;
_cookieJar?.SyncWith(this);
_jar = value;
this.WithCookies(
from c in CookieJar
where c.ShouldSendTo(this.Url, out _)
select (c.Name, c.Value));
}
}
/// <inheritdoc />
public async Task<IFlurlResponse> SendAsync(HttpMethod verb, HttpContent content = null, CancellationToken cancellationToken = default, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) {
_client = Client; // "freeze" the client at this point to avoid excessive calls to FlurlClientFactory.Get (#374)
@ -187,13 +179,12 @@ namespace Flurl.Http
call.StartedUtc = DateTime.UtcNow;
try {
SyncHeadersAndCookies(request);
_cookieJar?.UnsyncWith(this);
SyncHeaders(request);
var response = await Client.HttpClient.SendAsync(request, completionOption, cancellationTokenWithTimeout).ConfigureAwait(false);
call.HttpResponseMessage = response;
call.HttpResponseMessage.RequestMessage = request;
call.Response = new FlurlResponse(call.HttpResponseMessage, _cookieJar);
call.Response = new FlurlResponse(call.HttpResponseMessage, CookieJar);
if (Settings.Redirects.Enabled)
call.Redirect = GetRedirect(call);
@ -242,18 +233,19 @@ namespace Flurl.Http
}
}
private void SyncHeadersAndCookies(HttpRequestMessage request) {
// copy any client-level (default) to FlurlRequest
Headers.Merge(Client.Headers);
private void SyncHeaders(HttpRequestMessage request) {
// copy any client-level (default) headers to this request
foreach (var header in Client.Headers.Where(h => !this.Headers.Contains(h.Name)))
this.Headers.Add(header.Name, header.Value);
// copy headers from FlurlRequest to HttpRequestMessage
foreach (var header in Headers)
request.SetHeader(header.Key, header.Value);
request.SetHeader(header.Name, header.Value);
// copy headers from HttpContent to FlurlRequest
if (request.Content != null) {
foreach (var header in request.Content.Headers)
Headers[header.Key] = string.Join(",", header.Value);
Headers.AddOrReplace(header.Key, string.Join(",", header.Value));
}
}
@ -262,7 +254,7 @@ namespace Flurl.Http
if (call.Response.StatusCode < 300 || call.Response.StatusCode > 399)
return null;
if (!call.Response.Headers.TryGetValue("Location", out var location))
if (!call.Response.Headers.TryGetFirst("Location", out var location))
return null;
var redir = new FlurlRedirect();

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.IO;
@ -18,12 +18,12 @@ namespace Flurl.Http
/// <summary>
/// Gets the collection of response headers received.
/// </summary>
IReadOnlyDictionary<string, string> Headers { get; }
IReadOnlyNameValueList<string> Headers { get; }
/// <summary>
/// Gets the collection of HTTP cookies received in this response via Set-Cookie headers.
/// </summary>
IReadOnlyDictionary<string, FlurlCookie> Cookies { get; }
IReadOnlyList<FlurlCookie> Cookies { get; }
/// <summary>
/// Gets the raw HttpResponseMessage that this IFlurlResponse wraps.
@ -85,17 +85,17 @@ namespace Flurl.Http
/// <inheritdoc />
public class FlurlResponse : IFlurlResponse
{
private readonly Lazy<IReadOnlyDictionary<string, string>> _headers;
private readonly Lazy<IReadOnlyDictionary<string, FlurlCookie>> _cookies;
private readonly Lazy<IReadOnlyNameValueList<string>> _headers;
private readonly Lazy<IReadOnlyList<FlurlCookie>> _cookies;
private object _capturedBody = null;
private bool _streamRead = false;
private ISerializer _serializer = null;
/// <inheritdoc />
public IReadOnlyDictionary<string, string> Headers => _headers.Value;
public IReadOnlyNameValueList<string> Headers => _headers.Value;
/// <inheritdoc />
public IReadOnlyDictionary<string, FlurlCookie> Cookies => _cookies.Value;
public IReadOnlyList<FlurlCookie> Cookies => _cookies.Value;
/// <inheritdoc />
public HttpResponseMessage ResponseMessage { get; }
@ -108,28 +108,38 @@ namespace Flurl.Http
/// </summary>
public FlurlResponse(HttpResponseMessage resp, CookieJar cookieJar = null) {
ResponseMessage = resp;
_headers = new Lazy<IReadOnlyDictionary<string, string>>(LoadHeaders);
_cookies = new Lazy<IReadOnlyDictionary<string, FlurlCookie>>(LoadCookies);
_headers = new Lazy<IReadOnlyNameValueList<string>>(LoadHeaders);
_cookies = new Lazy<IReadOnlyList<FlurlCookie>>(LoadCookies);
LoadCookieJar(cookieJar);
}
private IReadOnlyDictionary<string, string> LoadHeaders() => ResponseMessage.Headers
.Concat(ResponseMessage.Content?.Headers ?? Enumerable.Empty<KeyValuePair<string, IEnumerable<string>>>())
.GroupBy(h => h.Key)
.ToDictionary(g => g.Key, g => string.Join(", ", g.SelectMany(h => h.Value)));
private IReadOnlyNameValueList<string> LoadHeaders() {
var result = new NameValueList<string>();
private IReadOnlyDictionary<string, FlurlCookie> LoadCookies() {
foreach (var h in ResponseMessage.Headers)
foreach (var v in h.Value)
result.Add(h.Key, v);
if (ResponseMessage.Content?.Headers == null)
return result;
foreach (var h in ResponseMessage.Content.Headers)
foreach (var v in h.Value)
result.Add(h.Key, v);
return result;
}
private IReadOnlyList<FlurlCookie> LoadCookies() {
var url = ResponseMessage.RequestMessage.RequestUri.ToString();
return ResponseMessage.Headers.TryGetValues("Set-Cookie", out var headerValues) ?
headerValues
.Select(hv => CookieCutter.FromResponseHeader(url, hv))
.ToDictionary(c => c.Name) :
new Dictionary<string, FlurlCookie>();
headerValues.Select(hv => CookieCutter.ParseResponseHeader(url, hv)).ToList() :
new List<FlurlCookie>();
}
private void LoadCookieJar(CookieJar jar) {
if (jar == null) return;
foreach (var cookie in Cookies.Values)
foreach (var cookie in Cookies)
jar.TryAddOrUpdate(cookie, out _); // not added if cookie fails validation
}

View File

@ -589,7 +589,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent
/// Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent.
/// </summary>
/// <param name="url">This Flurl.Url.</param>
/// <param name="headers">Names/values of HTTP headers to set. Typically an anonymous object or IDictionary.</param>
@ -621,7 +621,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets an HTTP cookie to be sent with this request only. To maintain a cookie "session", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead.
/// Creates a new FlurlRequest and adds a name-value pair to its Cookie header. To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="url">This Flurl.Url.</param>
/// <param name="name">The cookie name.</param>
@ -632,7 +632,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets HTTP cookies to be sent with this request only, based on property names/values of the provided object, or keys/values if object is a dictionary. To maintain a cookie "session", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead
/// Creates a new FlurlRequest and adds name-value pairs to its Cookie header based on property names/values of the provided object, or keys/values if object is a dictionary. To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="url">This Flurl.Url.</param>
/// <param name="values">Names/values of HTTP cookies to set. Typically an anonymous object or IDictionary.</param>
@ -1058,7 +1058,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent
/// Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent.
/// </summary>
/// <param name="url">This URL.</param>
/// <param name="headers">Names/values of HTTP headers to set. Typically an anonymous object or IDictionary.</param>
@ -1090,7 +1090,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets an HTTP cookie to be sent with this request only. To maintain a cookie "session", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead.
/// Creates a new FlurlRequest and adds a name-value pair to its Cookie header. To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="url">This URL.</param>
/// <param name="name">The cookie name.</param>
@ -1101,7 +1101,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets HTTP cookies to be sent with this request only, based on property names/values of the provided object, or keys/values if object is a dictionary. To maintain a cookie "session", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead
/// Creates a new FlurlRequest and adds name-value pairs to its Cookie header based on property names/values of the provided object, or keys/values if object is a dictionary. To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="url">This URL.</param>
/// <param name="values">Names/values of HTTP cookies to set. Typically an anonymous object or IDictionary.</param>
@ -1527,7 +1527,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent
/// Creates a new FlurlRequest and sets request headers based on property names/values of the provided object, or keys/values if object is a dictionary, to be sent.
/// </summary>
/// <param name="uri">This System.Uri.</param>
/// <param name="headers">Names/values of HTTP headers to set. Typically an anonymous object or IDictionary.</param>
@ -1559,7 +1559,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets an HTTP cookie to be sent with this request only. To maintain a cookie "session", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead.
/// Creates a new FlurlRequest and adds a name-value pair to its Cookie header. To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="uri">This System.Uri.</param>
/// <param name="name">The cookie name.</param>
@ -1570,7 +1570,7 @@ namespace Flurl.Http
}
/// <summary>
/// Creates a new FlurlRequest and sets HTTP cookies to be sent with this request only, based on property names/values of the provided object, or keys/values if object is a dictionary. To maintain a cookie "session", consider using WithCookies(CookieJar) or FlurlClient.StartCookieSession instead
/// Creates a new FlurlRequest and adds name-value pairs to its Cookie header based on property names/values of the provided object, or keys/values if object is a dictionary. To automatically maintain a cookie "session", consider using a CookieJar or CookieSession instead.
/// </summary>
/// <param name="uri">This System.Uri.</param>
/// <param name="values">Names/values of HTTP cookies to set. Typically an anonymous object or IDictionary.</param>

View File

@ -21,10 +21,10 @@ namespace Flurl.Http
/// <param name="value">HTTP header value.</param>
/// <returns>This IFlurlClient or IFlurlRequest.</returns>
public static T WithHeader<T>(this T clientOrRequest, string name, object value) where T : IHttpSettingsContainer {
if (value == null && clientOrRequest.Headers.ContainsKey(name))
if (value == null)
clientOrRequest.Headers.Remove(name);
else if (value != null)
clientOrRequest.Headers[name] = value;
else
clientOrRequest.Headers.AddOrReplace(name, value);
return clientOrRequest;
}

View File

@ -1,10 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Flurl.Http.Content;
using Flurl.Util;
@ -68,8 +64,9 @@ namespace Flurl.Http
Content.Headers.TryAddWithoutValidation(name, new[] { value.ToInvariantString() });
break;
default:
// it's a request-level header
Headers.Remove(name);
// it's a request/response-level header
if (!name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)) // multiple set-cookie headers are allowed
Headers.Remove(name);
if (value != null)
Headers.TryAddWithoutValidation(name, new[] { value.ToInvariantString() });
break;

View File

@ -14,9 +14,9 @@ namespace Flurl.Http
/// </summary>
FlurlHttpSettings Settings { get; set; }
/// <summary>
/// Collection of headers sent on this request or all requests using this client.
/// </summary>
IDictionary<string, object> Headers { get; }
/// <summary>
/// Collection of headers sent on this request or all requests using this client.
/// </summary>
INameValueList<object> Headers { get; }
}
}

View File

@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Flurl.Http
{
/// <summary>
/// Defines common methods for INameValueList and IReadOnlyNameValueList.
/// </summary>
public interface INameValueListBase<TValue>
{
/// <summary>
/// Returns the first Value of the given Name if one exists, otherwise null or default value.
/// </summary>
TValue FirstOrDefault(string name);
/// <summary>
/// Gets the first Value of the given Name, if one exists.
/// </summary>
/// <returns>true if any item of the given name is found, otherwise false.</returns>
bool TryGetFirst(string name, out TValue value);
/// <summary>
/// Gets all Values of the given Name.
/// </summary>
IEnumerable<TValue> GetAll(string name);
/// <summary>
/// True if any items with the given Name exist.
/// </summary>
bool Contains(string name);
/// <summary>
/// True if any item with the given Name and Value exists.
/// </summary>
bool Contains(string name, TValue value);
}
/// <summary>
/// Defines an ordered collection of Name/Value pairs where duplicate names are allowed but aren't typical.
/// </summary>
public interface INameValueList<TValue> : IList<(string Name, TValue Value)>, INameValueListBase<TValue>
{
/// <summary>
/// Adds a new Name/Value pair.
/// </summary>
void Add(string name, TValue value);
/// <summary>
/// Replaces the first occurence of the given Name with the given Value and removes any others,
/// or adds a new Name/Value pair if none exist.
/// </summary>
void AddOrReplace(string name, TValue value);
/// <summary>
/// Removes all items of the given Name.
/// </summary>
/// <returns>true if any item of the given name is found, otherwise false.</returns>
bool Remove(string name);
}
/// <summary>
/// Defines a read-only ordered collection of Name/Value pairs where duplicate names are allowed but aren't typical.
/// </summary>
public interface IReadOnlyNameValueList<TValue> : IReadOnlyList<(string Name, TValue Value)>, INameValueListBase<TValue>
{
}
/// <summary>
/// An ordered collection of Name/Value pairs where duplicate names are allowed but aren't typical.
/// Useful for things where a dictionary would work great if not for those pesky edge cases (headers, cookies, etc).
/// </summary>
public class NameValueList<TValue> : List<(string Name, TValue Value)>, INameValueList<TValue>, IReadOnlyNameValueList<TValue>
{
/// <summary>
/// Instantiates a new empty NameValueList.
/// </summary>
public NameValueList() { }
/// <summary>
/// Instantiates a new NameValueList with the Name/Value pairs provided.
/// </summary>
public NameValueList(IEnumerable<(string Name, TValue Value)> items) {
AddRange(items);
}
/// <inheritdoc />
public void Add(string name, TValue value) => Add((name, value));
/// <inheritdoc />
public void AddOrReplace(string name, TValue value) {
var i = 0;
var replaced = false;
while (i < this.Count) {
if (this[i].Name != name)
i++;
else if (replaced)
this.RemoveAt(i);
else {
this[i] = (name, value);
replaced = true;
i++;
}
}
if (!replaced)
this.Add(name, value);
}
/// <inheritdoc />
public bool Remove(string name) => RemoveAll(x => x.Name == name) > 0;
/// <inheritdoc />
public TValue FirstOrDefault(string name) => GetAll(name).FirstOrDefault();
/// <inheritdoc />
public bool TryGetFirst(string name, out TValue value) {
foreach (var v in GetAll(name)) {
value = v;
return true;
}
value = default;
return false;
}
/// <inheritdoc />
public IEnumerable<TValue> GetAll(string name) => this.Where(x => x.Name == name).Select(x => x.Value);
/// <inheritdoc />
public bool Contains(string name) => this.Any(x => x.Name == name);
/// <inheritdoc />
public bool Contains(string name, TValue value) => Contains((name, value));
}
}

View File

@ -210,7 +210,7 @@ namespace Flurl.Http.Testing
var descrip = $"any header {string.Join(", ", names)}".Trim();
return With(call => {
if (!names.Any()) return call.Request.Headers.Any();
return call.Request.Headers.Select(h => h.Key).Intersect(names).Any();
return call.Request.Headers.Select(h => h.Name).Intersect(names).Any();
}, descrip);
}
@ -271,7 +271,7 @@ namespace Flurl.Http.Testing
var descrip = $"any cookie {string.Join(", ", names)}".Trim();
return With(call => {
if (!names.Any()) return call.Request.Cookies.Any();
return call.Request.Cookies.Select(c => c.Key).Intersect(names).Any();
return call.Request.Cookies.Select(c => c.Name).Intersect(names).Any();
}, descrip);
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
@ -44,7 +44,7 @@ namespace Flurl.Http.Testing
}
/// <summary>
/// Adds an HttpResponseMessage to the response queue.
/// Adds a fake HTTP response to the response queue.
/// </summary>
/// <param name="body">The simulated response body string.</param>
/// <param name="status">The simulated HTTP status. Default is 200.</param>
@ -57,7 +57,7 @@ namespace Flurl.Http.Testing
}
/// <summary>
/// Adds an HttpResponseMessage to the response queue with the given data serialized to JSON as the content body.
/// Adds a fake HTTP response to the response queue with the given data serialized to JSON as the content body.
/// </summary>
/// <param name="body">The object to be JSON-serialized and used as the simulated response body.</param>
/// <param name="status">The simulated HTTP status. Default is 200.</param>
@ -71,7 +71,7 @@ namespace Flurl.Http.Testing
}
/// <summary>
/// Adds an HttpResponseMessage to the response queue.
/// Adds a fake HTTP response to the response queue.
/// </summary>
/// <param name="buildContent">A function that builds the simulated response body content. Optional.</param>
/// <param name="status">The simulated HTTP status. Optional. Default is 200.</param>

View File

@ -70,8 +70,8 @@ namespace Flurl.Http.Testing
/// </summary>
internal static bool HasHeader(this FlurlCall call, string name, object value = null) {
return (value == null) ?
call.Request.Headers.ContainsKey(name) :
call.Request.Headers.TryGetValue(name, out var val) && MatchesValueOrPattern(val, value);
call.Request.Headers.Contains(name) :
call.Request.Headers.TryGetFirst(name, out var val) && MatchesValueOrPattern(val, value);
}
/// <summary>
@ -79,8 +79,8 @@ namespace Flurl.Http.Testing
/// </summary>
internal static bool HasCookie(this FlurlCall call, string name, object value = null) {
return (value == null) ?
call.Request.Cookies.ContainsKey(name) :
call.Request.Cookies.TryGetValue(name, out var val) && MatchesValueOrPattern(val, value);
call.Request.Cookies.Any(c => c.Name == name) :
MatchesValueOrPattern(call.Request.Cookies.FirstOrDefault(c => c.Name == name).Value, value);
}
private static bool MatchesValueOrPattern(object valueToMatch, object value) {

View File

@ -39,11 +39,20 @@
</PropertyGroup>
<ItemGroup>
<None Include="..\..\icon.png" Pack="true" PackagePath="\"/>
<None Include="..\..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard1.0'">
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard1.3'">
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.4.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
</Project>

View File

@ -3,10 +3,8 @@ using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
#if !NET40
using System.Reflection;
#endif
using System.Text.RegularExpressions;
namespace Flurl.Util
{
@ -89,34 +87,51 @@ namespace Flurl.Util
}
private static IEnumerable<KeyValuePair<string, object>> CollectionToKV(IEnumerable col) {
// Accepts KeyValuePairs or any arbitrary types that contain a property called "Key" or "Name" and a property called "Value".
bool TryGetProp(object obj, string name, out object value) {
#if NETSTANDARD1_0
var prop = obj.GetType().GetRuntimeProperty(name);
var field = obj.GetType().GetRuntimeField(name);
#else
var prop = obj.GetType().GetProperty(name);
var field = obj.GetType().GetField(name);
#endif
if (prop != null) {
value = prop.GetValue(obj, null);
return true;
}
if (field != null) {
value = field.GetValue(obj);
return true;
}
value = null;
return false;
}
bool IsTuple2(object item, out object name, out object val) {
name = null;
val = null;
return
item.GetType().Name.Contains("Tuple") &&
TryGetProp(item, "Item1", out name) &&
TryGetProp(item, "Item2", out val) &&
!TryGetProp(item, "Item3", out _);
}
bool LooksLikeKV(object item, out object name, out object val) {
name = null;
val = null;
return
(TryGetProp(item, "Key", out name) || TryGetProp(item, "key", out name) || TryGetProp(item, "Name", out name) || TryGetProp(item, "name", out name)) &&
(TryGetProp(item, "Value", out val) || TryGetProp(item, "value", out val));
}
foreach (var item in col) {
if (item == null)
continue;
string key;
object val;
var type = item.GetType();
#if NETSTANDARD1_0
var keyProp = type.GetRuntimeProperty("Key") ?? type.GetRuntimeProperty("key") ?? type.GetRuntimeProperty("Name") ?? type.GetRuntimeProperty("name");
var valProp = type.GetRuntimeProperty("Value") ?? type.GetRuntimeProperty("value");
#else
var keyProp = type.GetProperty("Key") ?? type.GetProperty("key") ?? type.GetProperty("Name") ?? type.GetProperty("name");
var valProp = type.GetProperty("Value") ?? type.GetProperty("value");
#endif
if (keyProp != null && valProp != null) {
key = keyProp.GetValue(item, null)?.ToInvariantString();
val = valProp.GetValue(item, null);
}
else {
key = item.ToInvariantString();
val = null;
}
if (key != null)
yield return new KeyValuePair<string, object>(key, val);
if (!IsTuple2(item, out var name, out var val) && !LooksLikeKV(item, out name, out val))
yield return new KeyValuePair<string, object>(item.ToInvariantString(), null);
else if (name != null)
yield return new KeyValuePair<string, object>(name.ToInvariantString(), val);
}
}