From 5a9f2d6e5fa2d68049761a9759681fd9897b7885 Mon Sep 17 00:00:00 2001 From: Todd Date: Tue, 19 Sep 2023 15:46:53 -0500 Subject: [PATCH] #758 CookieJar persistence --- Test/Flurl.Test/Http/CookieTests.cs | 50 +++++++++++++++++++++++++++-- src/Flurl.Http/CookieCutter.cs | 36 +++++++++++++++++---- src/Flurl.Http/CookieExtensions.cs | 4 +-- src/Flurl.Http/CookieJar.cs | 37 +++++++++++++++++++++ src/Flurl.Http/FlurlCookie.cs | 42 ++++++++++++++++++++++++ src/Flurl.Http/FlurlResponse.cs | 2 +- 6 files changed, 158 insertions(+), 13 deletions(-) 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(); }