diff --git a/Application/Application.csproj b/Application/Application.csproj index 0c61b74..d0cbd3e 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -21,7 +21,7 @@ - + diff --git a/Application/Authenticator.cs b/Application/Authenticator.cs index b9b2c8d..b799009 100644 --- a/Application/Authenticator.cs +++ b/Application/Authenticator.cs @@ -1,11 +1,10 @@ -using System; using System.Collections.Generic; using System.IO; -using System.Net; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; +using BoardGameGeek.Dungeon.Converters; using BoardGameGeek.Dungeon.Services; +using Flurl.Http; using Pocket; using static Pocket.Logger; @@ -21,43 +20,22 @@ public Authenticator(IBggService bggService) public async Task AuthenticateUser(string userName, string password) { var fileName = $"BGG-{userName}-Auth.json"; // auth cache + var options = new JsonSerializerOptions { Converters = { new CookieConverter() }, WriteIndented = true }; if (password != null) { Log.Info("Authenticating user"); var cookies = await BggService.LoginUserAsync(userName, password); - var json = JsonSerializer.Serialize(cookies, new JsonSerializerOptions - { - Converters = { new CookieConverter() }, - WriteIndented = true - }); + var json = JsonSerializer.Serialize(cookies, options); await File.WriteAllTextAsync(fileName, json); } else { var json = await File.ReadAllTextAsync(fileName); - var cookies = JsonSerializer.Deserialize>(json); + var cookies = JsonSerializer.Deserialize>(json, options); BggService.LoginUser(cookies); } } - private class CookieConverter : JsonConverter - { - public override Cookie Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotImplementedException(); - - public override void Write(Utf8JsonWriter writer, Cookie value, JsonSerializerOptions options) - { - // select minimal properties for roundtrip - writer.WriteStartObject(); - writer.WriteString("Domain", value.Domain); - writer.WriteString("Expires", value.Expires); - writer.WriteString("Name", value.Name); - writer.WriteString("Path", value.Path); - writer.WriteString("TimeStamp", value.TimeStamp); - writer.WriteString("Value", value.Value); - writer.WriteEndObject(); - } - } - private IBggService BggService { get; } } } diff --git a/Application/CommandLine/Bootstrap.cs b/Application/CommandLine/Bootstrap.cs index c6f3bfb..25e37e3 100644 --- a/Application/CommandLine/Bootstrap.cs +++ b/Application/CommandLine/Bootstrap.cs @@ -13,8 +13,8 @@ public static class Bootstrap { static Bootstrap() { - var userNameArgument = new Argument{ Name = "username", Description = "Geek username." }; - var passwordArgument = new Argument{ Name = "password", Description = "Geek password." }; + var userNameArgument = new Argument { Name = "username", Description = "Geek username." }; + var passwordArgument = new Argument { Name = "password", Description = "Geek password." }; var passwordOption = new Option(new[] { "--password", "-p" }, "Geek password. Defaults to last specified."); diff --git a/Application/Converters/CookieConverter.cs b/Application/Converters/CookieConverter.cs new file mode 100644 index 0000000..bea80bc --- /dev/null +++ b/Application/Converters/CookieConverter.cs @@ -0,0 +1,56 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using BoardGameGeek.Dungeon.Extensions; +using Flurl.Http; + +namespace BoardGameGeek.Dungeon.Converters +{ + public sealed class CookieConverter : JsonConverter + { + public override FlurlCookie Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + reader.CheckStartObject(); + var originUrl = reader.ReadString("OriginUrl"); + var dateReceived = reader.ReadDateTimeOffset("DateReceived"); + var name = reader.ReadString("Name"); + var value = reader.ReadString("Value"); + var expires = reader.ReadNullableDateTimeOffset("Expires"); + var maxAge = reader.ReadNullableInt32("MaxAge"); + var domain = reader.ReadString("Domain"); + var path = reader.ReadString("Path"); + var secure = reader.ReadBoolean("Secure"); + var httpOnly = reader.ReadBoolean("HttpOnly"); + var sameSite = reader.ReadNullableEnum("SameSite"); + reader.ReadEndObject(); + + return new FlurlCookie(name, value, originUrl, dateReceived) + { + Expires = expires, + MaxAge = maxAge, + Domain = domain, + Path = path, + Secure = secure, + HttpOnly = httpOnly, + SameSite = sameSite + }; + } + + public override void Write(Utf8JsonWriter writer, FlurlCookie value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("OriginUrl", value.OriginUrl); + writer.WriteDateTimeOffset("DateReceived", value.DateReceived); + writer.WriteString("Name", value.Name); + writer.WriteString("Value", value.Value); + writer.WriteNullableDateTimeOffset("Expires", value.Expires); + writer.WriteNullableNumber("MaxAge", value.MaxAge); + writer.WriteString("Domain", value.Domain); + writer.WriteString("Path", value.Path); + writer.WriteBoolean("Secure", value.Secure); + writer.WriteBoolean("HttpOnly", value.HttpOnly); + writer.WriteNullableEnum("SameSite", value.SameSite); + writer.WriteEndObject(); + } + } +} diff --git a/Application/Extensions/Utf8JsonReaderExtensions.cs b/Application/Extensions/Utf8JsonReaderExtensions.cs new file mode 100644 index 0000000..b23c9f6 --- /dev/null +++ b/Application/Extensions/Utf8JsonReaderExtensions.cs @@ -0,0 +1,92 @@ +using System; +using System.Globalization; +using System.Text.Json; + +namespace BoardGameGeek.Dungeon.Extensions +{ + public static class Utf8JsonReaderExtensions + { + public static void CheckStartObject(this ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } + } + + public static void CheckEndObject(this ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException(); + } + } + + public static T GetEnum(this ref Utf8JsonReader reader) where T : struct, Enum => Enum.Parse(reader.GetString()!); + + public static DateTime GetDateTime(this ref Utf8JsonReader reader) => DateTime.Parse(reader.GetString()!); + + public static DateTimeOffset GetDateTimeOffset(this ref Utf8JsonReader reader) => DateTimeOffset.Parse(reader.GetString()!); + + public static TimeSpan GetTimeSpan(this ref Utf8JsonReader reader) => TimeSpan.Parse(reader.GetString()!, CultureInfo.InvariantCulture); + + public static bool? GetNullableBoolean(this ref Utf8JsonReader reader) => reader.TokenType != JsonTokenType.Null ? reader.GetBoolean() : (bool?)null; + + public static int? GetNullableInt32(this ref Utf8JsonReader reader) => reader.TokenType != JsonTokenType.Null ? reader.GetInt32() : (int?)null; + + public static T? GetNullableEnum(this ref Utf8JsonReader reader) where T : struct, Enum => reader.TokenType != JsonTokenType.Null ? reader.GetEnum() : (T?)null; + + public static DateTime? GetNullableDateTime(this ref Utf8JsonReader reader) => reader.TokenType != JsonTokenType.Null ? reader.GetDateTime() : (DateTime?)null; + + public static DateTimeOffset? GetNullableDateTimeOffset(this ref Utf8JsonReader reader) => reader.TokenType != JsonTokenType.Null ? reader.GetDateTimeOffset() : (DateTimeOffset?)null; + + public static TimeSpan? GetNullableTimeSpan(this ref Utf8JsonReader reader) => reader.TokenType != JsonTokenType.Null ? reader.GetTimeSpan() : (TimeSpan?)null; + + public static void ReadStartObject(this ref Utf8JsonReader reader) + { + reader.Read(); + reader.CheckStartObject(); + } + + public static void ReadEndObject(this ref Utf8JsonReader reader) + { + reader.Read(); + reader.CheckEndObject(); + } + + public static bool ReadBoolean(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetBoolean(); + + public static string ReadString(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetString(); + + public static T ReadEnum(this ref Utf8JsonReader reader, string propertyName) where T : struct, Enum => reader.ReadProperty(propertyName).GetEnum(); + + public static DateTime ReadDateTime(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetDateTime(); + + public static DateTimeOffset ReadDateTimeOffset(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetDateTimeOffset(); + + public static TimeSpan ReadTimeSpan(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetTimeSpan(); + + public static bool? ReadNullableBoolean(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetNullableBoolean(); + + public static int? ReadNullableInt32(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetNullableInt32(); + + public static T? ReadNullableEnum(this ref Utf8JsonReader reader, string propertyName) where T : struct, Enum => reader.ReadProperty(propertyName).GetNullableEnum(); + + public static DateTime? ReadNullableDateTime(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetNullableDateTime(); + + public static DateTimeOffset? ReadNullableDateTimeOffset(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetNullableDateTimeOffset(); + + public static TimeSpan? ReadNullableTimeSpan(this ref Utf8JsonReader reader, string propertyName) => reader.ReadProperty(propertyName).GetNullableTimeSpan(); + + private static ref Utf8JsonReader ReadProperty(this ref Utf8JsonReader reader, string propertyName) + { + reader.Read(); + if (reader.TokenType != JsonTokenType.PropertyName || reader.GetString() != propertyName) + { + throw new JsonException(); + } + reader.Read(); + return ref reader; + } + } +} diff --git a/Application/Extensions/Utf8JsonWriterExtensions.cs b/Application/Extensions/Utf8JsonWriterExtensions.cs new file mode 100644 index 0000000..49625a8 --- /dev/null +++ b/Application/Extensions/Utf8JsonWriterExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace BoardGameGeek.Dungeon.Extensions +{ + public static class Utf8JsonWriterExtensions + { + public static void WriteEnum(this Utf8JsonWriter writer, string propertyName, T value) where T : struct, Enum => writer.WriteString(propertyName, value.ToString()); + + public static void WriteDateTime(this Utf8JsonWriter writer, string propertyName, DateTime value) => writer.WriteString(propertyName, value); + + public static void WriteDateTimeOffset(this Utf8JsonWriter writer, string propertyName, DateTimeOffset value) => writer.WriteString(propertyName, value); + + public static void WriteTimeSpan(this Utf8JsonWriter writer, string propertyName, TimeSpan value) => writer.WriteString(propertyName, value.ToString(null, CultureInfo.InvariantCulture)); + + public static void WriteNullableBoolean(this Utf8JsonWriter writer, string propertyName, bool? value) => writer.WriteNull(propertyName, value)?.WriteBoolean(propertyName, value!.Value); + + public static void WriteNullableNumber(this Utf8JsonWriter writer, string propertyName, int? value) => writer.WriteNull(propertyName, value)?.WriteNumber(propertyName, value!.Value); + + public static void WriteNullableEnum(this Utf8JsonWriter writer, string propertyName, T? value) where T : struct, Enum => writer.WriteNull(propertyName, value)?.WriteEnum(propertyName, value!.Value); + + public static void WriteNullableDateTime(this Utf8JsonWriter writer, string propertyName, DateTime? value) => writer.WriteNull(propertyName, value)?.WriteDateTime(propertyName, value!.Value); + + public static void WriteNullableDateTimeOffset(this Utf8JsonWriter writer, string propertyName, DateTimeOffset? value) => writer.WriteNull(propertyName, value)?.WriteDateTimeOffset(propertyName, value!.Value); + + public static void WriteNullableTimeSpan(this Utf8JsonWriter writer, string propertyName, TimeSpan? value) => writer.WriteNull(propertyName, value)?.WriteTimeSpan(propertyName, value!.Value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Utf8JsonWriter WriteNull(this Utf8JsonWriter writer, string propertyName, T? value) where T : struct + { + if (!value.HasValue) + { + writer.WriteNull(propertyName); + return null; + } + return writer; + } + } +} diff --git a/Application/Program.cs b/Application/Program.cs index bd9afdd..5015eae 100644 --- a/Application/Program.cs +++ b/Application/Program.cs @@ -1,11 +1,12 @@ using System; using System.CommandLine.Parsing; using System.Threading.Tasks; +using BoardGameGeek.Dungeon.CommandLine; using Pocket; namespace BoardGameGeek.Dungeon { - public class Program + public static class Program { private static Task Main(string[] args) { @@ -15,7 +16,7 @@ private static Task Main(string[] args) Console.WriteLine($"{entry.TimestampUtc.ToLocalTime():HH:mm:ss} {message}"); }); - return CommandLine.Bootstrap.Parser.InvokeAsync(args); + return Bootstrap.Parser.InvokeAsync(args); } } } diff --git a/Application/Renderer.cs b/Application/Renderer.cs index f5f0e77..09ba8a0 100644 --- a/Application/Renderer.cs +++ b/Application/Renderer.cs @@ -77,20 +77,11 @@ private static string Players(IEnumerable players) return players != null ? string.Join(",", players.OrderByDescending(player => player.Score).Select(player => player.Name)) : string.Empty; } - private static string Highlight(string text, bool isHighlight = true) - { - return isHighlight ? $"[bgcolor=gold]{text}[/bgcolor]" : text; - } + private static string Highlight(string text, bool isHighlight = true) => isHighlight ? $"[bgcolor=gold]{text}[/bgcolor]" : text; - private static string Pluralize(int count) - { - return count != 1 ? "s" : string.Empty; - } + private static string Pluralize(int count) => count != 1 ? "s" : string.Empty; - private static string Star(int count) - { - return count >= 100 ? ":star:" : count >= 10 ? ":halfstar:" : ":nostar:"; - } + private static string Star(int count) => count >= 100 ? ":star:" : count >= 10 ? ":halfstar:" : ":nostar:"; private static string Suffix(Game game) { diff --git a/Application/Services/BggService.cs b/Application/Services/BggService.cs index 0041cf4..9b8f809 100644 --- a/Application/Services/BggService.cs +++ b/Application/Services/BggService.cs @@ -20,8 +20,8 @@ public interface IBggService IAsyncEnumerable GetThingsAsync(IEnumerable ids); IAsyncEnumerable GetUserCollectionAsync(string userName); IAsyncEnumerable GetUserPlaysAsync(string userName, int? year = null, int? id = null); - void LoginUser(IDictionary cookies); - Task> LoginUserAsync(string userName, string password); + void LoginUser(IEnumerable cookies); + Task> LoginUserAsync(string userName, string password); Task LogUserPlayAsync(Play play); } @@ -39,19 +39,19 @@ public BggService() { FlurlClient = new FlurlClient("https://boardgamegeek.com") { - Settings = { BeforeCall = call => { Logger.Log.Trace($"{call.Request.Method} {call.Request.RequestUri}"); } } + Settings = { BeforeCall = call => { Logger.Log.Trace($"{call.Request.Verb} {call.Request.Url}"); } } }; - RetryPolicy = Policy.Handle(ex => ex.Call.Response.StatusCode == HttpStatusCode.TooManyRequests) - .OrResult(response => response.StatusCode == HttpStatusCode.Accepted) + RetryPolicy = Policy.Handle(ex => ex.Call.HttpResponseMessage.StatusCode == HttpStatusCode.TooManyRequests) + .OrResult(response => response.ResponseMessage.StatusCode == HttpStatusCode.Accepted) .WaitAndRetryAsync(EnumerateDelay(), (response, _) => { if (response.Exception is FlurlHttpException ex) //TODO once throttled, introduce delay for all subsequent calls { - Logger.Log.Warning($"{ex.Call.HttpStatus:D} {ex.Call.HttpStatus}"); + Logger.Log.Warning($"{ex.Call.HttpResponseMessage.StatusCode:D} {ex.Call.HttpResponseMessage.StatusCode}"); } else { - Logger.Log.Warning($"{response.Result.StatusCode:D} {response.Result.StatusCode}"); + Logger.Log.Warning($"{response.Result.ResponseMessage.StatusCode:D} {response.Result.ResponseMessage.StatusCode}"); } }); } @@ -66,7 +66,7 @@ async ValueTask GetThingCollectionAsync(IList ids) id = string.Join(",", ids) }); var response = await RetryPolicy.ExecuteAsync(() => request.GetAsync()); - return await response.Content.ReadAsAsync(XmlFormatterCollection); + return await response.ResponseMessage.Content.ReadAsAsync(XmlFormatterCollection); } var thingCollections = ids.Distinct() @@ -111,7 +111,7 @@ async Task GetUserCollectionAsync(string userName) minplays = 1 }); var response = await RetryPolicy.ExecuteAsync(() => request.GetAsync()); - return await response.Content.ReadAsAsync(XmlFormatterCollection); + return await response.ResponseMessage.Content.ReadAsAsync(XmlFormatterCollection); } var userCollection = await GetUserCollectionAsync(userName); @@ -144,7 +144,7 @@ async Task GetUserPlaysAsync(string userName, int? year, int? id, int page }); var response = await RetryPolicy.ExecuteAsync(() => request.GetAsync()); - return await response.Content.ReadAsAsync(XmlFormatterCollection); + return await response.ResponseMessage.Content.ReadAsAsync(XmlFormatterCollection); } var userPlays = await GetUserPlaysAsync(userName, year, id); @@ -190,30 +190,33 @@ async Task GetUserPlaysAsync(string userName, int? year, int? id, int } } - public void LoginUser(IDictionary cookies) + public void LoginUser(IEnumerable cookies) { + Cookies = new CookieJar(); foreach (var cookie in cookies) { - FlurlClient.WithCookie(cookie.Value); + Cookies.AddOrReplace(cookie); } } - public async Task> LoginUserAsync(string userName, string password) + public async Task> LoginUserAsync(string userName, string password) { - var request = FlurlClient.Request("login").EnableCookies(); + var request = FlurlClient.Request("login"); + var cookies = new CookieJar(); var body = new { username = userName, password }; - await RetryPolicy.ExecuteAsync(() => request.PostUrlEncodedAsync(body)); - return FlurlClient.Cookies.Where(cookie => cookie.Key.StartsWith("bgg", StringComparison.OrdinalIgnoreCase)) - .ToDictionary(cookie => cookie.Key, cookie => cookie.Value); + await RetryPolicy.ExecuteAsync(() => request.WithCookies(out cookies).PostUrlEncodedAsync(body)); + Cookies = cookies.Remove(cookie => !cookie.Name.StartsWith("bgg", StringComparison.OrdinalIgnoreCase)); + return Cookies; } public async Task LogUserPlayAsync(Play play) { - var request = FlurlClient.Request("geekplay.php"); + var request = FlurlClient.Request("geekplay.php") + .WithCookies(Cookies); var body = new { version = 2, @@ -242,7 +245,8 @@ private static IEnumerable EnumerateDelay() } private IFlurlClient FlurlClient { get; } - private AsyncRetryPolicy RetryPolicy { get; } + private CookieJar Cookies { get; set; } + private AsyncRetryPolicy RetryPolicy { get; } private static readonly XmlMediaTypeFormatter XmlFormatter = new XmlMediaTypeFormatter { UseXmlSerializer = true }; private static readonly MediaTypeFormatterCollection XmlFormatterCollection = new MediaTypeFormatterCollection(new MediaTypeFormatter[] { XmlFormatter }); diff --git a/Application/packages.lock.json b/Application/packages.lock.json index f415a65..bc3d8e6 100644 --- a/Application/packages.lock.json +++ b/Application/packages.lock.json @@ -10,11 +10,11 @@ }, "Flurl.Http": { "type": "Direct", - "requested": "[2.4.2, )", - "resolved": "2.4.2", - "contentHash": "VfSJ0DKzh6kf2IOZGG1/JpHajhtqzGp1t+M1QSyFGse7c4Gg1DpH80LTveYea2hV1BEtvPPEp6A4iZ78D8bxGQ==", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "20y0rStrI5WH7YvB1bUrc9OYyiP3XRK4ikk8gCTL4sn0yPIlCNdEtHCnJtPY5GlcUvWfIoCbJjgoBnmRs1r11w==", "dependencies": { - "Flurl": "2.8.2", + "Flurl": "3.0.0", "Newtonsoft.Json": "12.0.2", "System.Text.Encoding.CodePages": "4.5.1" } diff --git a/Tests/packages.lock.json b/Tests/packages.lock.json index 8e6e88d..0ebb55a 100644 --- a/Tests/packages.lock.json +++ b/Tests/packages.lock.json @@ -87,10 +87,10 @@ }, "Flurl.Http": { "type": "Transitive", - "resolved": "2.4.2", - "contentHash": "VfSJ0DKzh6kf2IOZGG1/JpHajhtqzGp1t+M1QSyFGse7c4Gg1DpH80LTveYea2hV1BEtvPPEp6A4iZ78D8bxGQ==", + "resolved": "3.0.0", + "contentHash": "20y0rStrI5WH7YvB1bUrc9OYyiP3XRK4ikk8gCTL4sn0yPIlCNdEtHCnJtPY5GlcUvWfIoCbJjgoBnmRs1r11w==", "dependencies": { - "Flurl": "2.8.2", + "Flurl": "3.0.0", "Newtonsoft.Json": "12.0.2", "System.Text.Encoding.CodePages": "4.5.1" } @@ -1324,7 +1324,7 @@ "type": "Project", "dependencies": { "Flurl": "3.0.0", - "Flurl.Http": "2.4.2", + "Flurl.Http": "3.0.0", "Microsoft.AspNet.WebApi.Client": "5.2.7", "PocketLogger.Subscribe": "0.7.0", "Polly": "7.2.1",