Skip to content

Commit

Permalink
#758 CookieJar persistence
Browse files Browse the repository at this point in the history
  • Loading branch information
Todd committed Sep 19, 2023
1 parent 52e6f78 commit 5a9f2d6
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 13 deletions.
50 changes: 47 additions & 3 deletions Test/Flurl.Test/Http/CookieTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down
36 changes: 29 additions & 7 deletions src/Flurl.Http/CookieCutter.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Flurl.Util;

namespace Flurl.Http
Expand All @@ -25,23 +26,24 @@ public static class CookieCutter
/// <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>
/// <param name="url">The URL that sent the response.</param>
/// <param name="dateReceived">Date/time that original Set-Cookie header was received. Defaults to current date/time. Important for Max-Age to be enforced correctly.</param>
/// <returns></returns>
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))
Expand All @@ -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<SameSite>(pair.Value, true, out var val) ? val : (SameSite?)null;
cookie.SameSite = Enum.TryParse<SameSite>(pair.Value, true, out var val) ? val : null;
}
return cookie;
}
Expand All @@ -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.
/// </summary>
/// <returns>A header value if cookie values are present, otherwise null.</returns>
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}"));
}

/// <summary>
/// Creates a Set-Cookie response header value from a FlurlCookie.
/// </summary>
/// <returns>A header value if cookie is non-null, otherwise null.</returns>
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();
}

/// <summary>
/// True if this cookie passes well-accepted rules for the Set-Cookie header. If false, provides a descriptive reason.
/// </summary>
Expand Down
4 changes: 2 additions & 2 deletions src/Flurl.Http/CookieExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static class CookieExtensions
public static IFlurlRequest WithCookie(this IFlurlRequest request, string name, object value) {
var cookies = new NameValueList<string>(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));
}

/// <summary>
Expand All @@ -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));
}

/// <summary>
Expand Down
37 changes: 37 additions & 0 deletions src/Flurl.Http/CookieJar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Flurl.Util;

Expand Down Expand Up @@ -77,6 +78,42 @@ public CookieJar Clear() {
return this;
}

/// <summary>
/// Writes this CookieJar to a TextWriter. Useful for persisting to a file.
/// </summary>
public void WriteTo(TextWriter writer) {
foreach (var cookie in _dict.Values)
cookie.WriteTo(writer);
}

/// <summary>
/// Instantiates a CookieJar that was previously persisted using WriteTo.
/// </summary>
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;
}

/// <summary>
/// Returns a string representing this CookieJar.
/// </summary>
public override string ToString() {
var writer = new StringWriter();
WriteTo(writer);
return writer.ToString();
}

/// <summary>
/// Instantiates a CookieJar that was previously persisted using ToString.
/// </summary>
public static CookieJar LoadFromString(string s) => LoadFrom(new StringReader(s));

/// <inheritdoc/>
public IEnumerator<FlurlCookie> GetEnumerator() => _dict.Values.GetEnumerator();

Expand Down
42 changes: 42 additions & 0 deletions src/Flurl.Http/FlurlCookie.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;

namespace Flurl.Http
Expand Down Expand Up @@ -145,6 +147,46 @@ public string GetKey() {
return $"{domain}{path}:{Name}";
}

/// <summary>
/// Writes this cookie to a TextWriter. Useful for persisting to a file.
/// </summary>
public void WriteTo(TextWriter writer) {
writer.WriteLine(DateReceived.ToString("s"));
writer.WriteLine(OriginUrl);
writer.WriteLine(CookieCutter.BuildResponseHeader(this));
}

/// <summary>
/// Instantiates a FlurlCookie that was previously persisted using WriteTo.
/// </summary>
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);
}

/// <summary>
/// Returns a string representing this FlurlCookie.
/// </summary>
public override string ToString() {
var writer = new StringWriter();
WriteTo(writer);
return writer.ToString();
}

/// <summary>
/// Instantiates a FlurlCookie that was previously persisted using ToString.
/// </summary>
public static FlurlCookie LoadFromString(string s) => LoadFrom(new StringReader(s));


/// <summary>
/// Makes this cookie immutable. Call when added to a jar.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Flurl.Http/FlurlResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ private IReadOnlyNameValueList<string> LoadHeaders() {
private IReadOnlyList<FlurlCookie> 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<FlurlCookie>();
}

Expand Down

0 comments on commit 5a9f2d6

Please sign in to comment.