Skip to content

Commit

Permalink
#13: Use JSON object mapping instead of DOM access
Browse files Browse the repository at this point in the history
  • Loading branch information
Aldaviva committed Jan 15, 2024
1 parent 54c7e81 commit 2708e30
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 28 deletions.
5 changes: 3 additions & 2 deletions GamesDoneQuickCalendarFactory/CalendarGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Ical.Net;
using GamesDoneQuickCalendarFactory.Data;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;

Expand All @@ -17,7 +18,7 @@ public sealed class CalendarGenerator(IEventDownloader eventDownloader, ILogger<

public async Task<Calendar> generateCalendar() {
logger.LogDebug("Downloading schedule from Games Done Quick website");
GdqEvent gdqEvent = await eventDownloader.downloadSchedule();
Event gdqEvent = await eventDownloader.downloadSchedule();

Calendar calendar = new() { Method = CalendarMethods.Publish };
calendar.Events.AddRange(gdqEvent.runs
Expand Down
3 changes: 3 additions & 0 deletions GamesDoneQuickCalendarFactory/Data/Event.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace GamesDoneQuickCalendarFactory.Data;

public record Event(string title, IEnumerable<GameRun> runs);
15 changes: 15 additions & 0 deletions GamesDoneQuickCalendarFactory/Data/GDQ/GdqEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;

namespace GamesDoneQuickCalendarFactory.Data.GDQ;

public record GdqEvent(
string type,
int id,
string @short,
string name,
string hashtag,
DateTimeOffset datetime,
string timezone,
[property: JsonPropertyName("use_one_step_screening")]
bool useOneStepScreening
);
75 changes: 75 additions & 0 deletions GamesDoneQuickCalendarFactory/Data/GDQ/GdqRuns.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Text.Json.Serialization;

// ReSharper disable ClassNeverInstantiated.Global - these are instantiated by deserializers

namespace GamesDoneQuickCalendarFactory.Data.GDQ;

public record GdqRuns(
int count,
object? next,
object? previous,
IReadOnlyList<Run> results
);

/// <param name="displayName">always the same as <paramref name="name"/></param>
/// <param name="description">always <c>""</c></param>
/// <param name="runTime">before a run ends, this is the estimated duration, but after a run ends, this changes to the actual duration</param>
public record Run(
string type,
int id,
string name,
[property: JsonPropertyName("display_name")] string displayName,
string description,
string category,
string console,
IReadOnlyList<Runner> runners,
IReadOnlyList<Person> hosts,
IReadOnlyList<Person> commentators,
[property: JsonPropertyName("starttime")] DateTimeOffset startTime,
[property: JsonPropertyName("endtime")] DateTimeOffset endTime,
int order,
[property: JsonPropertyName("run_time")] string runTime,
[property: JsonPropertyName("setup_time")] TimeSpan setupTime,
[property: JsonPropertyName("anchor_time")] DateTimeOffset? anchorTime,
[property: JsonPropertyName("video_links")] IReadOnlyList<Video> videos
);

public record Person(
string type,
int id,
string name,
string pronouns
);

/// <param name="twitter">Handle/username on Twitter</param>
/// <param name="youtube">Handle on YouTube</param>
/// <param name="streamingPlatform">The service that <paramref name="stream"/> is hosted on</param>
public record Runner(
string type,
int id,
string name,
Uri stream,
string twitter,
string youtube,
[property: JsonPropertyName("platform")] StreamingPlatform streamingPlatform,
string pronouns
): Person(type, id, name, pronouns);

public enum StreamingPlatform {

TWITCH

}

public record Video(
int id,
[property: JsonPropertyName("link_type")] VideoType type,
Uri url
);

public enum VideoType {

TWITCH,
YOUTUBE

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace GamesDoneQuickCalendarFactory;
namespace GamesDoneQuickCalendarFactory.Data;

public record GameRun(
DateTimeOffset start,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Buffers.Text;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace GamesDoneQuickCalendarFactory.Data.Marshal;

/// <summary>
/// Copy of <see cref="System.Text.Json.Serialization.Converters.TimeSpanConverter"/> that can handle <c>"0"</c> inputs
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Copied third-party code")]
public class ZeroTolerantTimeSpanConverter: JsonConverter<TimeSpan> {

public static readonly ZeroTolerantTimeSpanConverter INSTANCE = new();

private const int MINIMUM_TIME_SPAN_FORMAT_LENGTH = 1; // 0, changed from 8 by Ben
private const int MAXIMUM_TIME_SPAN_FORMAT_LENGTH = 26; // -dddddddd.hh:mm:ss.fffffff
private const int MAXIMUM_ESCAPED_TIME_SPAN_FORMAT_LENGTH = 6 * MAXIMUM_TIME_SPAN_FORMAT_LENGTH;

public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
if (reader.TokenType != JsonTokenType.String) {
throw new InvalidOperationException($"Cannot get the value of a token type '{reader.TokenType}' as a string.");
}

return readCore(ref reader);
}

private static TimeSpan readCore(ref Utf8JsonReader reader) {
Debug.Assert(reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName);

int valueLength = reader.HasValueSequence ? checked((int) reader.ValueSequence.Length) : reader.ValueSpan.Length;
if (valueLength is < MINIMUM_TIME_SPAN_FORMAT_LENGTH or > MAXIMUM_ESCAPED_TIME_SPAN_FORMAT_LENGTH) {
throw new FormatException($"The JSON value is not in a supported {nameof(TimeSpan)} format.");
}

scoped ReadOnlySpan<byte> source;
if (!reader.HasValueSequence && !reader.ValueIsEscaped) {
source = reader.ValueSpan;
} else {
Span<byte> stackSpan = stackalloc byte[MAXIMUM_ESCAPED_TIME_SPAN_FORMAT_LENGTH];
int bytesWritten = reader.CopyString(stackSpan);
source = stackSpan[..bytesWritten];
}

char firstChar = (char) source[0];
if (firstChar is < '0' or > '9' && firstChar != '-') {
throw new FormatException($"The JSON value is not in a supported {nameof(TimeSpan)} format.");
}

bool result = Utf8Parser.TryParse(source, out TimeSpan tmpValue, out int bytesConsumed, 'c');

if (!result || source.Length != bytesConsumed) {
throw new FormatException($"The JSON value is not in a supported {nameof(TimeSpan)} format.");
}

return tmpValue;
}

public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) {
Span<byte> output = stackalloc byte[MAXIMUM_TIME_SPAN_FORMAT_LENGTH];

bool result = Utf8Formatter.TryFormat(value, output, out int bytesWritten, 'c');
Debug.Assert(result);

writer.WriteStringValue(output[..bytesWritten]);
}

}
44 changes: 28 additions & 16 deletions GamesDoneQuickCalendarFactory/EventDownloader.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
using jaytwo.FluentUri;
using System.Text.Json.Nodes;
using GamesDoneQuickCalendarFactory.Data;
using GamesDoneQuickCalendarFactory.Data.GDQ;
using GamesDoneQuickCalendarFactory.Data.Marshal;
using jaytwo.FluentUri;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace GamesDoneQuickCalendarFactory;

public interface IEventDownloader {

Task<GdqEvent> downloadSchedule();
Task<Event> downloadSchedule();

}

Expand All @@ -14,27 +18,35 @@ public class EventDownloader(HttpClient httpClient): IEventDownloader {
private static readonly Uri SCHEDULE_URL = new("https://gamesdonequick.com/schedule");
private static readonly Uri EVENTS_API_LOCATION = new("https://gamesdonequick.com/tracker/api/v2/events");

public async Task<GdqEvent> downloadSchedule() {
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() {
Converters = {
ZeroTolerantTimeSpanConverter.INSTANCE,
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper)
}
};

public async Task<Event> downloadSchedule() {
using HttpResponseMessage eventIdResponse = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, SCHEDULE_URL));

int eventId = Convert.ToInt32(eventIdResponse.RequestMessage!.RequestUri!.GetPathSegment(1));
Uri eventLocation = EVENTS_API_LOCATION.WithPath(eventId.ToString());

JsonNode eventResponse = (await httpClient.GetFromJsonAsync<JsonNode>(eventLocation))!;
string eventTitle = eventResponse["name"]!.GetValue<string>();
GdqEvent eventResponse = (await httpClient.GetFromJsonAsync<GdqEvent>(eventLocation, JSON_SERIALIZER_OPTIONS))!;
GdqRuns runResponse = (await httpClient.GetFromJsonAsync<GdqRuns>(eventLocation.WithPath("runs"), JSON_SERIALIZER_OPTIONS))!;

JsonNode runResponse = (await httpClient.GetFromJsonAsync<JsonNode>(eventLocation.WithPath("runs")))!;
IEnumerable<GameRun> runs = runResponse.results.Select(run => new GameRun(
start: run.startTime,
duration: run.endTime - run.startTime,
name: run.name,
description: $"{run.category} \u2014 {run.console}",
runners: run.runners.Select(getName),
commentators: run.commentators.Select(getName),
hosts: run.hosts.Select(getName),
setupDuration: run.setupTime));

IEnumerable<GameRun> runs = runResponse["results"]!.AsArray().Select(result => new GameRun(
start: DateTimeOffset.Parse(result!["starttime"]!.GetValue<string>()),
duration: DateTimeOffset.Parse(result["endtime"]!.GetValue<string>()) - DateTimeOffset.Parse(result["starttime"]!.GetValue<string>()),
name: result["name"]!.GetValue<string>(),
description: result["category"]!.GetValue<string>() + " — " + result["console"]!.GetValue<string>(),
runners: result["runners"]!.AsArray().Select(person => person!["name"]!.GetValue<string>()),
commentators: result["commentators"]!.AsArray().Select(person => person!["name"]!.GetValue<string>()),
hosts: result["hosts"]!.AsArray().Select(person => person!["name"]!.GetValue<string>()), setupDuration: TimeSpan.Parse(result["setup_time"]!.GetValue<string>())));
return new Event(eventResponse.name, runs);

return new GdqEvent(eventTitle, runs);
static string getName(Person person) => person.name;
}

}
3 changes: 0 additions & 3 deletions GamesDoneQuickCalendarFactory/GdqEvent.cs

This file was deleted.

9 changes: 5 additions & 4 deletions Tests/CalendarGeneratorTest.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using GamesDoneQuickCalendarFactory.Data;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Microsoft.Extensions.Logging.Abstractions;
Expand All @@ -16,7 +17,7 @@ public CalendarGeneratorTest() {

[Fact]
public async Task generateCalendar() {
GdqEvent gdqEvent = new("Awesome Games Done Quick 2024", new[] {
Event @event = new("Awesome Games Done Quick 2024", new[] {
new GameRun(
DateTimeOffset.Parse("2024-01-14T11:30:00-05:00"),
TimeSpan.FromMinutes(42),
Expand Down Expand Up @@ -68,7 +69,7 @@ public async Task generateCalendar() {
TimeSpan.Zero)
});

A.CallTo(() => eventDownloader.downloadSchedule()).Returns(gdqEvent);
A.CallTo(() => eventDownloader.downloadSchedule()).Returns(@event);

Calendar actual = await calendarGenerator.generateCalendar();

Expand Down Expand Up @@ -144,7 +145,7 @@ public async Task generateCalendar() {

[Fact]
public async Task ignoreSleepRuns() {
GdqEvent gdqEvent = new("test", new[] {
Event @event = new("test", new[] {
// Sleep event
new GameRun(
DateTimeOffset.Now,
Expand Down Expand Up @@ -176,7 +177,7 @@ public async Task ignoreSleepRuns() {
new[] { "Studio Workers" }, null)
});

A.CallTo(() => eventDownloader.downloadSchedule()).Returns(gdqEvent);
A.CallTo(() => eventDownloader.downloadSchedule()).Returns(@event);

Calendar actual = await calendarGenerator.generateCalendar();

Expand Down
6 changes: 4 additions & 2 deletions Tests/EventDownloaderTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace Tests;
using GamesDoneQuickCalendarFactory.Data;

namespace Tests;

public class EventDownloaderTest {

Expand All @@ -23,7 +25,7 @@ public async Task downloadSchedule() {
A.CallTo(() => httpMessageHandler.SendAsync(An<HttpRequestMessage>.That.Matches(HttpMethod.Get, "https://gamesdonequick.com/tracker/api/v2/events/46/runs")))
.Returns(new HttpResponseMessage { Content = new StreamContent(runsStream) });

GdqEvent actual = await eventDownloader.downloadSchedule();
Event actual = await eventDownloader.downloadSchedule();

actual.title.Should().Be("Awesome Games Done Quick 2024");

Expand Down

0 comments on commit 2708e30

Please sign in to comment.