diff --git a/Test/Flurl.Test/Http/CookieTests.cs b/Test/Flurl.Test/Http/CookieTests.cs
index 93f66525..049e7f3e 100644
--- a/Test/Flurl.Test/Http/CookieTests.cs
+++ b/Test/Flurl.Test/Http/CookieTests.cs
@@ -86,6 +86,50 @@ public async Task can_send_and_receive_cookies_with_jar_initialized() {
Assert.AreEqual(1, jar.Count(c => c.Name == "y" && c.Value == "bazz"));
}
+ [Test]
+ public void can_build_response_header() {
+ // simple
+ var cookie1 = new FlurlCookie("x", "foo");
+ Assert.AreEqual("x=foo", CookieCutter.BuildResponseHeader(cookie1));
+
+ // complex
+ var cookie = new FlurlCookie("y", "bar") {
+ Domain = "cookie.org",
+ Expires = new DateTimeOffset(2025, 12, 31, 1, 23, 45, TimeSpan.Zero),
+ HttpOnly = true,
+ MaxAge = 10,
+ Path = "/a/b",
+ Secure = true,
+ SameSite = SameSite.Lax
+ };
+ var headerVal = CookieCutter.BuildResponseHeader(cookie);
+ Assert.AreEqual("y=bar; Domain=cookie.org; Expires=Wed, 31 Dec 2025 01:23:45 GMT; HttpOnly; Max-Age=10; Path=/a/b; Secure; SameSite=Lax", headerVal);
+ }
+
+ [Test]
+ public void can_perist_and_load_jar() {
+ var jar1 = new CookieJar()
+ .AddOrReplace("x", "foo", "https://site1.com", DateTimeOffset.UtcNow)
+ .AddOrReplace("y", "bar", "https://site2.com", DateTimeOffset.UtcNow.AddMinutes(-10));
+
+ var s1 = jar1.ToString();
+
+ var jar2 = CookieJar.LoadFromString(s1);
+ Assert.AreEqual(jar1.Count, jar2.Count);
+
+ var cookies1 = jar1.ToArray();
+ var cookies2 = jar2.ToArray();
+
+ for (var i = 0; i < 2; i++) {
+ Assert.AreEqual(cookies1[i].Name, cookies2[i].Name);
+ Assert.AreEqual(cookies1[i].Value, cookies2[i].Value);
+ Assert.AreEqual(cookies1[i].OriginUrl, cookies2[i].OriginUrl);
+ }
+
+ var s2 = jar2.ToString();
+ Assert.AreEqual(s1, s2);
+ }
+
[Test]
public async Task can_do_cookie_session() {
HttpTest
@@ -114,7 +158,7 @@ public async Task can_do_cookie_session() {
[Test]
public void can_parse_set_cookie_header() {
var start = DateTimeOffset.UtcNow;
- var cookie = CookieCutter.ParseResponseHeader("https://www.cookies.com/a/b", "x=foo ; DoMaIn=cookies.com ; path=/ ; MAX-AGE=999 ; expires= ; secure ;HTTPONLY ;samesite=none");
+ var cookie = CookieCutter.ParseResponseHeader("x=foo ; DoMaIn=cookies.com ; path=/ ; MAX-AGE=999 ; expires= ; secure ;HTTPONLY ;samesite=none", "https://www.cookies.com/a/b");
Assert.AreEqual("https://www.cookies.com/a/b", cookie.OriginUrl.ToString());
Assert.AreEqual("x", cookie.Name);
Assert.AreEqual("foo", cookie.Value);
@@ -130,7 +174,7 @@ public void can_parse_set_cookie_header() {
// simpler case
start = DateTimeOffset.UtcNow;
- cookie = CookieCutter.ParseResponseHeader("https://www.cookies.com/a/b", "y=bar");
+ cookie = CookieCutter.ParseResponseHeader("y=bar", "https://www.cookies.com/a/b");
Assert.AreEqual("https://www.cookies.com/a/b", cookie.OriginUrl.ToString());
Assert.AreEqual("y", cookie.Name);
Assert.AreEqual("bar", cookie.Value);
@@ -164,7 +208,7 @@ public void cannot_change_cookie_after_adding_to_jar() {
[Test]
public void unquotes_cookie_value() {
- var cookie = CookieCutter.ParseResponseHeader("https://cookies.com", "x=\"hello there\"" );
+ var cookie = CookieCutter.ParseResponseHeader("x=\"hello there\"", "https://cookies.com");
Assert.AreEqual("hello there", cookie.Value);
}
diff --git a/src/Flurl.Http/CookieCutter.cs b/src/Flurl.Http/CookieCutter.cs
index 95cdc727..7f25ea5b 100644
--- a/src/Flurl.Http/CookieCutter.cs
+++ b/src/Flurl.Http/CookieCutter.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Text;
using Flurl.Util;
namespace Flurl.Http
@@ -25,23 +26,24 @@ public static class CookieCutter
///
/// Parses a Set-Cookie response header to a FlurlCookie.
///
- /// The URL that sent the response.
/// Value of the Set-Cookie header.
+ /// The URL that sent the response.
+ /// Date/time that original Set-Cookie header was received. Defaults to current date/time. Important for Max-Age to be enforced correctly.
///
- public static FlurlCookie ParseResponseHeader(string url, string headerValue) {
+ public static FlurlCookie ParseResponseHeader(string headerValue, string url, DateTimeOffset? dateReceived = null) {
if (string.IsNullOrEmpty(headerValue)) return null;
FlurlCookie cookie = null;
foreach (var pair in GetPairs(headerValue)) {
if (cookie == null)
- cookie = new FlurlCookie(pair.Name, pair.Value.Trim('"'), url, DateTimeOffset.UtcNow);
+ cookie = new FlurlCookie(pair.Name, pair.Value.Trim('"'), url, dateReceived ?? DateTimeOffset.UtcNow);
// ordinal string compare is both safest and fastest
// https://docs.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings#recommendations-for-string-usage
else if (pair.Name.OrdinalEquals("Expires", true))
- cookie.Expires = DateTimeOffset.TryParse(pair.Value, out var d) ? d : (DateTimeOffset?)null;
+ cookie.Expires = DateTimeOffset.TryParse(pair.Value, out var d) ? d : null;
else if (pair.Name.OrdinalEquals("Max-Age", true))
- cookie.MaxAge = int.TryParse(pair.Value, out var i) ? i : (int?)null;
+ cookie.MaxAge = int.TryParse(pair.Value, out var i) ? i : null;
else if (pair.Name.OrdinalEquals("Domain", true))
cookie.Domain = pair.Value;
else if (pair.Name.OrdinalEquals("Path", true))
@@ -51,7 +53,7 @@ public static FlurlCookie ParseResponseHeader(string url, string headerValue) {
else if (pair.Name.OrdinalEquals("Secure", true))
cookie.Secure = true;
else if (pair.Name.OrdinalEquals("SameSite", true))
- cookie.SameSite = Enum.TryParse(pair.Value, true, out var val) ? val : (SameSite?)null;
+ cookie.SameSite = Enum.TryParse(pair.Value, true, out var val) ? val : null;
}
return cookie;
}
@@ -68,13 +70,33 @@ public static FlurlCookie ParseResponseHeader(string url, string headerValue) {
/// Creates a Cookie request header value from a list of cookie name-value pairs.
///
/// A header value if cookie values are present, otherwise null.
- public static string ToRequestHeader(IEnumerable<(string Name, string Value)> cookies) {
+ public static string BuildRequestHeader(IEnumerable<(string Name, string Value)> cookies) {
if (cookies?.Any() != true) return null;
return string.Join("; ", cookies.Select(c =>
$"{c.Name}={c.Value}"));
}
+ ///
+ /// Creates a Set-Cookie response header value from a FlurlCookie.
+ ///
+ /// A header value if cookie is non-null, otherwise null.
+ public static string BuildResponseHeader(FlurlCookie cookie) {
+ if (cookie == null) return null;
+
+ StringBuilder result = new StringBuilder();
+ result.Append($"{cookie.Name}={cookie.Value}");
+ if (cookie.Domain != null) result.Append($"; Domain={cookie.Domain}");
+ if (cookie.Expires != null) result.Append($"; Expires={cookie.Expires:r}");
+ if (cookie.HttpOnly) result.Append("; HttpOnly");
+ if (cookie.MaxAge != null) result.Append($"; Max-Age={cookie.MaxAge}");
+ if (cookie.Path != null) result.Append($"; Path={cookie.Path}");
+ if (cookie.Secure) result.Append("; Secure");
+ if (cookie.SameSite != null) result.Append($"; SameSite={cookie.SameSite}");
+
+ return result.ToString();
+ }
+
///
/// True if this cookie passes well-accepted rules for the Set-Cookie header. If false, provides a descriptive reason.
///
diff --git a/src/Flurl.Http/CookieExtensions.cs b/src/Flurl.Http/CookieExtensions.cs
index 6e6db51f..beaa5bab 100644
--- a/src/Flurl.Http/CookieExtensions.cs
+++ b/src/Flurl.Http/CookieExtensions.cs
@@ -19,7 +19,7 @@ public static class CookieExtensions
public static IFlurlRequest WithCookie(this IFlurlRequest request, string name, object value) {
var cookies = new NameValueList(request.Cookies, true); // cookie names are case-sensitive https://stackoverflow.com/a/11312272/62600
cookies.AddOrReplace(name, value.ToInvariantString());
- return request.WithHeader("Cookie", CookieCutter.ToRequestHeader(cookies));
+ return request.WithHeader("Cookie", CookieCutter.BuildRequestHeader(cookies));
}
///
@@ -40,7 +40,7 @@ public static IFlurlRequest WithCookies(this IFlurlRequest request, object value
foreach (var kv in group.Skip(1))
cookies.Add(kv.Key, kv.Value.ToInvariantString());
}
- return request.WithHeader("Cookie", CookieCutter.ToRequestHeader(cookies));
+ return request.WithHeader("Cookie", CookieCutter.BuildRequestHeader(cookies));
}
///
diff --git a/src/Flurl.Http/CookieJar.cs b/src/Flurl.Http/CookieJar.cs
index 56edefa2..93d48f0b 100644
--- a/src/Flurl.Http/CookieJar.cs
+++ b/src/Flurl.Http/CookieJar.cs
@@ -2,6 +2,7 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using Flurl.Util;
@@ -77,6 +78,42 @@ public CookieJar Clear() {
return this;
}
+ ///
+ /// Writes this CookieJar to a TextWriter. Useful for persisting to a file.
+ ///
+ public void WriteTo(TextWriter writer) {
+ foreach (var cookie in _dict.Values)
+ cookie.WriteTo(writer);
+ }
+
+ ///
+ /// Instantiates a CookieJar that was previously persisted using WriteTo.
+ ///
+ public static CookieJar LoadFrom(TextReader reader) {
+ if (reader == null) return null;
+ var jar = new CookieJar();
+ while (reader.Peek() >= 0) {
+ var cookie = FlurlCookie.LoadFrom(reader);
+ if (cookie != null)
+ jar.AddOrReplace(cookie);
+ }
+ return jar;
+ }
+
+ ///
+ /// Returns a string representing this CookieJar.
+ ///
+ public override string ToString() {
+ var writer = new StringWriter();
+ WriteTo(writer);
+ return writer.ToString();
+ }
+
+ ///
+ /// Instantiates a CookieJar that was previously persisted using ToString.
+ ///
+ public static CookieJar LoadFromString(string s) => LoadFrom(new StringReader(s));
+
///
public IEnumerator GetEnumerator() => _dict.Values.GetEnumerator();
diff --git a/src/Flurl.Http/FlurlCookie.cs b/src/Flurl.Http/FlurlCookie.cs
index 22af8c12..777683bd 100644
--- a/src/Flurl.Http/FlurlCookie.cs
+++ b/src/Flurl.Http/FlurlCookie.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
using System.Runtime.CompilerServices;
namespace Flurl.Http
@@ -145,6 +147,46 @@ public string GetKey() {
return $"{domain}{path}:{Name}";
}
+ ///
+ /// Writes this cookie to a TextWriter. Useful for persisting to a file.
+ ///
+ public void WriteTo(TextWriter writer) {
+ writer.WriteLine(DateReceived.ToString("s"));
+ writer.WriteLine(OriginUrl);
+ writer.WriteLine(CookieCutter.BuildResponseHeader(this));
+ }
+
+ ///
+ /// Instantiates a FlurlCookie that was previously persisted using WriteTo.
+ ///
+ public static FlurlCookie LoadFrom(TextReader reader) {
+ if (!DateTimeOffset.TryParse(reader?.ReadLine(), null, DateTimeStyles.AssumeUniversal, out var received))
+ return null;
+
+ var url = reader.ReadLine();
+ if (string.IsNullOrEmpty(url)) return null;
+
+ var headerVal = reader.ReadLine();
+ if (string.IsNullOrEmpty(headerVal)) return null;
+
+ return CookieCutter.ParseResponseHeader(headerVal, url, received);
+ }
+
+ ///
+ /// Returns a string representing this FlurlCookie.
+ ///
+ public override string ToString() {
+ var writer = new StringWriter();
+ WriteTo(writer);
+ return writer.ToString();
+ }
+
+ ///
+ /// Instantiates a FlurlCookie that was previously persisted using ToString.
+ ///
+ public static FlurlCookie LoadFromString(string s) => LoadFrom(new StringReader(s));
+
+
///
/// Makes this cookie immutable. Call when added to a jar.
///
diff --git a/src/Flurl.Http/FlurlResponse.cs b/src/Flurl.Http/FlurlResponse.cs
index 308a08ad..a38be29e 100644
--- a/src/Flurl.Http/FlurlResponse.cs
+++ b/src/Flurl.Http/FlurlResponse.cs
@@ -118,7 +118,7 @@ private IReadOnlyNameValueList LoadHeaders() {
private IReadOnlyList LoadCookies() {
var url = ResponseMessage.RequestMessage.RequestUri.AbsoluteUri;
return ResponseMessage.Headers.TryGetValues("Set-Cookie", out var headerValues) ?
- headerValues.Select(hv => CookieCutter.ParseResponseHeader(url, hv)).ToList() :
+ headerValues.Select(hv => CookieCutter.ParseResponseHeader(hv, url)).ToList() :
new List();
}