From 901b26153e2faf69a68a60d15e78f9bf3b9eaedf Mon Sep 17 00:00:00 2001 From: Mate Farkas Date: Tue, 16 Apr 2024 23:39:52 +0200 Subject: [PATCH] Add project files. --- .dockerignore | 30 +++ Config/SyncOptions.cs | 14 ++ ConnectionsAPI - Backup.csproj | 18 ++ ConnectionsAPI.csproj | 24 ++ ConnectionsAPI.sln | 25 ++ Constants.cs | 7 + Database/ConnectionsContext.cs | 18 ++ Database/Entities/Puzzle.cs | 40 +++ Database/Entities/PuzzleCard.cs | 30 +++ Database/Entities/PuzzleCategory.cs | 35 +++ Database/Entities/PuzzleCategoryColor.cs | 10 + .../20240416121538_Initial.Designer.cs | 135 +++++++++++ Database/Migrations/20240416121538_Initial.cs | 103 ++++++++ .../ConnectionsContextModelSnapshot.cs | 132 ++++++++++ Dockerfile | 25 ++ Events/PuzzleSyncEvent.cs | 41 ++++ Features/Puzzle/Get/GetPuzzleEndpoint.cs | 83 +++++++ Features/Puzzle/List/ListPuzzlesEndpoint.cs | 43 ++++ GlobalUsings.cs | 1 + Models/PuzzleDTO.cs | 49 ++++ Program.cs | 77 ++++++ Properties/launchSettings.json | 34 +++ SyncScheduler.cs | 73 ++++++ Utility/EnvironmentUtility.cs | 8 + Utility/HashUtility.cs | 14 ++ Utility/SyncUtility.cs | 229 ++++++++++++++++++ appsettings.Development.json | 14 ++ appsettings.json | 9 + 28 files changed, 1321 insertions(+) create mode 100644 .dockerignore create mode 100644 Config/SyncOptions.cs create mode 100644 ConnectionsAPI - Backup.csproj create mode 100644 ConnectionsAPI.csproj create mode 100644 ConnectionsAPI.sln create mode 100644 Constants.cs create mode 100644 Database/ConnectionsContext.cs create mode 100644 Database/Entities/Puzzle.cs create mode 100644 Database/Entities/PuzzleCard.cs create mode 100644 Database/Entities/PuzzleCategory.cs create mode 100644 Database/Entities/PuzzleCategoryColor.cs create mode 100644 Database/Migrations/20240416121538_Initial.Designer.cs create mode 100644 Database/Migrations/20240416121538_Initial.cs create mode 100644 Database/Migrations/ConnectionsContextModelSnapshot.cs create mode 100644 Dockerfile create mode 100644 Events/PuzzleSyncEvent.cs create mode 100644 Features/Puzzle/Get/GetPuzzleEndpoint.cs create mode 100644 Features/Puzzle/List/ListPuzzlesEndpoint.cs create mode 100644 GlobalUsings.cs create mode 100644 Models/PuzzleDTO.cs create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 SyncScheduler.cs create mode 100644 Utility/EnvironmentUtility.cs create mode 100644 Utility/HashUtility.cs create mode 100644 Utility/SyncUtility.cs create mode 100644 appsettings.Development.json create mode 100644 appsettings.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/Config/SyncOptions.cs b/Config/SyncOptions.cs new file mode 100644 index 0000000..90eb6fc --- /dev/null +++ b/Config/SyncOptions.cs @@ -0,0 +1,14 @@ +namespace ConnectionsAPI.Config +{ + public class SyncOptions + { + /// + /// The cron expression for the sync schedule + /// + public string? ScheduleCron { get; set; } + /// + /// If the sync should run immediately or wait until the next cron occurrence + /// + public bool? RunImmediately { get; set; } + } +} diff --git a/ConnectionsAPI - Backup.csproj b/ConnectionsAPI - Backup.csproj new file mode 100644 index 0000000..084f07f --- /dev/null +++ b/ConnectionsAPI - Backup.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + b560bdda-fbdf-4fb8-86ec-e8d6c743e978 + Linux + . + + + + + + + + + diff --git a/ConnectionsAPI.csproj b/ConnectionsAPI.csproj new file mode 100644 index 0000000..1f02ff4 --- /dev/null +++ b/ConnectionsAPI.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + b560bdda-fbdf-4fb8-86ec-e8d6c743e978 + Linux + . + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/ConnectionsAPI.sln b/ConnectionsAPI.sln new file mode 100644 index 0000000..c61c766 --- /dev/null +++ b/ConnectionsAPI.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34728.123 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConnectionsAPI", "ConnectionsAPI.csproj", "{8C8F77FE-BC46-4209-B28A-3A46661C12E5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8C8F77FE-BC46-4209-B28A-3A46661C12E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C8F77FE-BC46-4209-B28A-3A46661C12E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C8F77FE-BC46-4209-B28A-3A46661C12E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C8F77FE-BC46-4209-B28A-3A46661C12E5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {72BFE030-A4D1-4BBD-B1D8-9D58507C65C9} + EndGlobalSection +EndGlobal diff --git a/Constants.cs b/Constants.cs new file mode 100644 index 0000000..792176a --- /dev/null +++ b/Constants.cs @@ -0,0 +1,7 @@ +namespace ConnectionsAPI +{ + public class Constants + { + public static readonly DateTime ConnectionsStartDate = new(2023, 06, 12, 0, 0, 0, DateTimeKind.Utc); + } +} diff --git a/Database/ConnectionsContext.cs b/Database/ConnectionsContext.cs new file mode 100644 index 0000000..d0e5597 --- /dev/null +++ b/Database/ConnectionsContext.cs @@ -0,0 +1,18 @@ +using ConnectionsAPI.Database.Entities; +using Microsoft.EntityFrameworkCore; + +namespace ConnectionsAPI.Database +{ + public class ConnectionsContext(DbContextOptions dbContextOptions) : DbContext(dbContextOptions) + { + public DbSet Puzzles { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(x => x.PrintDate).IsUnique(); + + base.OnModelCreating(modelBuilder); + } + } +} diff --git a/Database/Entities/Puzzle.cs b/Database/Entities/Puzzle.cs new file mode 100644 index 0000000..5459440 --- /dev/null +++ b/Database/Entities/Puzzle.cs @@ -0,0 +1,40 @@ +namespace ConnectionsAPI.Database.Entities +{ + public class Puzzle + { + /// + /// Primary key of the entity + /// + public int Id { get; set; } + + /// + /// When the entity was created (is the sync date) + /// + public DateTime CreatedDate { get; set; } + + /// + /// When the puzzle was "printed" online + /// + public string PrintDate { get; set; } = string.Empty; + + /// + /// The name of the editor for the puzzle + /// + public string EditorName { get; set; } = string.Empty; + + /// + /// The actual count of the puzzle + /// + public int Index { get; set; } + + /// + /// The MD5 hash for the source content used to sync this puzzle + /// + public string ContentMD5 { get; set; } = string.Empty; + + /// + /// The categories associated with this puzzle + /// + public virtual ICollection Categories { get; set; } = []; + } +} diff --git a/Database/Entities/PuzzleCard.cs b/Database/Entities/PuzzleCard.cs new file mode 100644 index 0000000..3362f45 --- /dev/null +++ b/Database/Entities/PuzzleCard.cs @@ -0,0 +1,30 @@ +namespace ConnectionsAPI.Database.Entities +{ + public class PuzzleCard + { + /// + /// Primary key of the entity + /// + public int Id { get; set; } + + /// + /// The contents of this card (the word) + /// + public string Content { get; set; } = string.Empty; + + /// + /// The initial position of this card on the grid + /// + public int Position { get; set; } + + /// + /// The ID of the associated Connections category + /// + public int PuzzleCategoryId { get; set; } + + /// + /// The associated category instance + /// + public virtual PuzzleCategory? PuzzleCategory { get; set; } + } +} \ No newline at end of file diff --git a/Database/Entities/PuzzleCategory.cs b/Database/Entities/PuzzleCategory.cs new file mode 100644 index 0000000..caf924b --- /dev/null +++ b/Database/Entities/PuzzleCategory.cs @@ -0,0 +1,35 @@ +namespace ConnectionsAPI.Database.Entities +{ + public class PuzzleCategory + { + /// + /// Primary key of the entity + /// + public int Id { get; set; } + + /// + /// The name of the category in this Connections puzzle + /// + public string Name { get; set; } = string.Empty; + + /// + /// The color of the category in this Connections puzzle; Also used for sorting + /// + public PuzzleCategoryColor Color { get; set; } + + /// + /// The ID of the associated Connections puzzle + /// + public int PuzzleId { get; set; } + + /// + /// The associated puzzle instance + /// + public virtual Puzzle? Puzzle { get; set; } + + /// + /// The cards associated with this category + /// + public ICollection PuzzleCards { get; set; } = []; + } +} diff --git a/Database/Entities/PuzzleCategoryColor.cs b/Database/Entities/PuzzleCategoryColor.cs new file mode 100644 index 0000000..e052586 --- /dev/null +++ b/Database/Entities/PuzzleCategoryColor.cs @@ -0,0 +1,10 @@ +namespace ConnectionsAPI.Database.Entities +{ + public enum PuzzleCategoryColor + { + Yellow = 1, + Green = 2, + Blue = 3, + Purple = 4, + } +} diff --git a/Database/Migrations/20240416121538_Initial.Designer.cs b/Database/Migrations/20240416121538_Initial.Designer.cs new file mode 100644 index 0000000..dbe4726 --- /dev/null +++ b/Database/Migrations/20240416121538_Initial.Designer.cs @@ -0,0 +1,135 @@ +// +using System; +using ConnectionsAPI.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ConnectionsAPI.Database.Migrations +{ + [DbContext(typeof(ConnectionsContext))] + [Migration("20240416121538_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.Puzzle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentMD5") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("EditorName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("PrintDate") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PrintDate") + .IsUnique(); + + b.ToTable("Puzzles"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PuzzleCategoryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PuzzleCategoryId"); + + b.ToTable("PuzzleCard"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PuzzleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PuzzleId"); + + b.ToTable("PuzzleCategory"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCard", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.PuzzleCategory", "PuzzleCategory") + .WithMany("PuzzleCards") + .HasForeignKey("PuzzleCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PuzzleCategory"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.Puzzle", "Puzzle") + .WithMany("Categories") + .HasForeignKey("PuzzleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Puzzle"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.Puzzle", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b => + { + b.Navigation("PuzzleCards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Database/Migrations/20240416121538_Initial.cs b/Database/Migrations/20240416121538_Initial.cs new file mode 100644 index 0000000..52dfd79 --- /dev/null +++ b/Database/Migrations/20240416121538_Initial.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ConnectionsAPI.Database.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Puzzles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CreatedDate = table.Column(type: "TEXT", nullable: false), + PrintDate = table.Column(type: "TEXT", nullable: false), + EditorName = table.Column(type: "TEXT", nullable: false), + Index = table.Column(type: "INTEGER", nullable: false), + ContentMD5 = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Puzzles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PuzzleCategory", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Color = table.Column(type: "INTEGER", nullable: false), + PuzzleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PuzzleCategory", x => x.Id); + table.ForeignKey( + name: "FK_PuzzleCategory_Puzzles_PuzzleId", + column: x => x.PuzzleId, + principalTable: "Puzzles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PuzzleCard", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Content = table.Column(type: "TEXT", nullable: false), + Position = table.Column(type: "INTEGER", nullable: false), + PuzzleCategoryId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PuzzleCard", x => x.Id); + table.ForeignKey( + name: "FK_PuzzleCard_PuzzleCategory_PuzzleCategoryId", + column: x => x.PuzzleCategoryId, + principalTable: "PuzzleCategory", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PuzzleCard_PuzzleCategoryId", + table: "PuzzleCard", + column: "PuzzleCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_PuzzleCategory_PuzzleId", + table: "PuzzleCategory", + column: "PuzzleId"); + + migrationBuilder.CreateIndex( + name: "IX_Puzzles_PrintDate", + table: "Puzzles", + column: "PrintDate", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PuzzleCard"); + + migrationBuilder.DropTable( + name: "PuzzleCategory"); + + migrationBuilder.DropTable( + name: "Puzzles"); + } + } +} diff --git a/Database/Migrations/ConnectionsContextModelSnapshot.cs b/Database/Migrations/ConnectionsContextModelSnapshot.cs new file mode 100644 index 0000000..357f0d0 --- /dev/null +++ b/Database/Migrations/ConnectionsContextModelSnapshot.cs @@ -0,0 +1,132 @@ +// +using System; +using ConnectionsAPI.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ConnectionsAPI.Database.Migrations +{ + [DbContext(typeof(ConnectionsContext))] + partial class ConnectionsContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.Puzzle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentMD5") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("EditorName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("PrintDate") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PrintDate") + .IsUnique(); + + b.ToTable("Puzzles"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PuzzleCategoryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PuzzleCategoryId"); + + b.ToTable("PuzzleCard"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PuzzleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PuzzleId"); + + b.ToTable("PuzzleCategory"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCard", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.PuzzleCategory", "PuzzleCategory") + .WithMany("PuzzleCards") + .HasForeignKey("PuzzleCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PuzzleCategory"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.Puzzle", "Puzzle") + .WithMany("Categories") + .HasForeignKey("PuzzleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Puzzle"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.Puzzle", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b => + { + b.Navigation("PuzzleCards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d996f0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER app +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["ConnectionsAPI.csproj", "."] +RUN dotnet restore "./ConnectionsAPI.csproj" +COPY . . +WORKDIR "/src/." +RUN dotnet build "./ConnectionsAPI.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./ConnectionsAPI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "ConnectionsAPI.dll"] \ No newline at end of file diff --git a/Events/PuzzleSyncEvent.cs b/Events/PuzzleSyncEvent.cs new file mode 100644 index 0000000..11caaa6 --- /dev/null +++ b/Events/PuzzleSyncEvent.cs @@ -0,0 +1,41 @@ + +using ConnectionsAPI.Database; +using ConnectionsAPI.Utility; +using System.Diagnostics; + +namespace ConnectionsAPI.Events +{ + public class PuzzleSyncEvent : IEvent { } + + public class PuzzleSyncHandler(ILogger logger, IServiceScopeFactory scopeFactory) : IEventHandler + { + private readonly ILogger _logger = logger; + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + + public async Task HandleAsync(PuzzleSyncEvent eventModel, CancellationToken ct) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + _logger.LogInformation("Received Puzzle Sync Event"); + try + { + // construct scope + using var scope = _scopeFactory.CreateScope(); + // get dependencies + ConnectionsContext db = scope.ServiceProvider.GetRequiredService(); + HttpClient http = scope.ServiceProvider.GetRequiredService().CreateClient(); + ILogger syncLogger = scope.ServiceProvider.GetRequiredService>(); + // do the work + await new SyncUtility(db, syncLogger, http).SyncPuzzlesAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while executing puzzle sync event"); + } + finally + { + stopwatch.Stop(); + } + _logger.LogInformation("Puzzle Sync Event finished in {ts}", stopwatch.Elapsed); + } + } +} diff --git a/Features/Puzzle/Get/GetPuzzleEndpoint.cs b/Features/Puzzle/Get/GetPuzzleEndpoint.cs new file mode 100644 index 0000000..47ff124 --- /dev/null +++ b/Features/Puzzle/Get/GetPuzzleEndpoint.cs @@ -0,0 +1,83 @@ +using ConnectionsAPI.Database; +using ConnectionsAPI.Models; +using FluentValidation; +using LazyCache; +using Microsoft.EntityFrameworkCore; +using System.Text.RegularExpressions; + +namespace ConnectionsAPI.Features.Puzzle.Get +{ + public record GetPuzzleEndpointRequest(string PuzzleDate); + + public partial class GetPuzzleEndpointRequestValidator : Validator + { + [GeneratedRegex("^\\d{4}-\\d{2}-\\d{2}$", RegexOptions.IgnoreCase)] + private static partial Regex PrintDateGeneratedRegex(); + + public GetPuzzleEndpointRequestValidator() + { + RuleFor(x => x.PuzzleDate) + .NotEmpty().WithMessage("Puzzle date is required") + .Must(x => PrintDateGeneratedRegex().IsMatch(x)).WithMessage("Puzzle date must be in the format yyyy-MM-dd"); + } + } + + public class GetPuzzleEndpoint(ConnectionsContext db, ILogger logger, IAppCache cache) : Endpoint + { + private readonly ConnectionsContext _db = db; + private readonly ILogger _logger = logger; + private readonly IAppCache _cache = cache; + + public override void Configure() + { + Get("/{PuzzleDate}.json"); + AllowAnonymous(); + DontThrowIfValidationFails(); + } + + public override async Task HandleAsync(GetPuzzleEndpointRequest req, CancellationToken ct) + { + // default to 404 if validation fails + if (ValidationFailed) + { + _logger.LogError("Validation error. {path} {pathBase}", HttpContext.Request.Path, HttpContext.Request.PathBase); + await SendNotFoundAsync(ct); + return; + } + + // get response from cache + var response = await _cache.GetOrAddAsync($"Puzzle:{req.PuzzleDate}", + () => { return GetResponseForCache(req.PuzzleDate); }, + DateTimeOffset.UtcNow.AddMinutes(5)); + + // if not found, done here + if (response == null) + { + await SendNotFoundAsync(ct); + return; + } + + // done + await SendAsync(response, cancellation: ct); + } + + private async Task GetResponseForCache(string printDate) + { + // query for the puzzle + var puzzle = await _db.Puzzles + .Include(x => x.Categories) + .ThenInclude(x => x.PuzzleCards) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.PrintDate == printDate); + + // if not found, we're done here + if (puzzle == null) + { + return null; + } + + // if found, map + return PuzzleDTO.FromEntity(puzzle); + } + } +} diff --git a/Features/Puzzle/List/ListPuzzlesEndpoint.cs b/Features/Puzzle/List/ListPuzzlesEndpoint.cs new file mode 100644 index 0000000..f1857e4 --- /dev/null +++ b/Features/Puzzle/List/ListPuzzlesEndpoint.cs @@ -0,0 +1,43 @@ +using ConnectionsAPI.Database; +using ConnectionsAPI.Models; +using LazyCache; +using Microsoft.EntityFrameworkCore; + +namespace ConnectionsAPI.Features.Puzzle.List +{ + public class ListPuzzlesEndpoint(ConnectionsContext db, IAppCache cache) : EndpointWithoutRequest> + { + private readonly ConnectionsContext _db = db; + private readonly IAppCache _cache = cache; + + public override void Configure() + { + Get("/all.json"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + // get response from cache + var response = await _cache.GetOrAddAsync("Puzzle:All", + GetResponseForCache, + DateTimeOffset.UtcNow.AddMinutes(5)); + // done + await SendAsync(response, cancellation: ct); + } + + private async Task> GetResponseForCache() + { + // query all, ordered by print date + var puzzles = await _db.Puzzles + .Include(x => x.Categories) + .ThenInclude(x => x.PuzzleCards) + .AsNoTracking() + .OrderBy(x => x.PrintDate) + .ToListAsync(); + + // map to dto + return puzzles.Select(PuzzleDTO.FromEntity).ToList(); + } + } +} diff --git a/GlobalUsings.cs b/GlobalUsings.cs new file mode 100644 index 0000000..5e33eef --- /dev/null +++ b/GlobalUsings.cs @@ -0,0 +1 @@ +global using FastEndpoints; \ No newline at end of file diff --git a/Models/PuzzleDTO.cs b/Models/PuzzleDTO.cs new file mode 100644 index 0000000..d3221d5 --- /dev/null +++ b/Models/PuzzleDTO.cs @@ -0,0 +1,49 @@ +namespace ConnectionsAPI.Models +{ + public class PuzzleDTO + { + public static PuzzleDTO FromEntity(Database.Entities.Puzzle dbPuzzle) => + new() + { + PuzzleNumber = dbPuzzle.Index, + PrintDate = dbPuzzle.PrintDate, + Editor = dbPuzzle.EditorName, + Categories = dbPuzzle.Categories.OrderBy(x => (int)x.Color).Select(PuzzleCategoryDTO.FromEntity).ToList() + }; + + public int PuzzleNumber { get; set; } + public string PrintDate { get; set; } = string.Empty; + public string Editor { get; set;} = string.Empty; + public ICollection Categories { get; set; } = []; + } + + public class PuzzleCategoryDTO + { + public static PuzzleCategoryDTO FromEntity(Database.Entities.PuzzleCategory dbCategory) => + new() + { + Title = dbCategory.Name, + Cards = dbCategory.PuzzleCards.OrderBy(x => x.Content).Select(PuzzleCardDTO.FromEntity).ToList(), + Color = dbCategory.Color.ToString().ToLower(), + OrderingKey = (int)dbCategory.Color + }; + + public string Title { get; set; } = string.Empty; + public string Color { get; set; } = string.Empty; + public int OrderingKey { get; set; } + public ICollection Cards { get; set; } = []; + } + + public class PuzzleCardDTO + { + public static PuzzleCardDTO FromEntity(Database.Entities.PuzzleCard dbCard) => + new() + { + Content = dbCard.Content, + Position = dbCard.Position, + }; + + public string Content { get; set; } = string.Empty; + public int Position { get; set; } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..bc29797 --- /dev/null +++ b/Program.cs @@ -0,0 +1,77 @@ +using ConnectionsAPI.Config; +using ConnectionsAPI.Database; +using ConnectionsAPI.Utility; +using Microsoft.EntityFrameworkCore; + +namespace ConnectionsAPI +{ + public class Program + { + public static async Task Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.Configure(options => + { + options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | + Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto; + }); + + builder.Services + .AddFastEndpoints(); + + // set up options + builder.Services.Configure(builder.Configuration.GetSection("Sync")); + + builder.Services.AddDbContext(opt => + { + opt.UseSqlite(builder.Configuration.GetConnectionString("ConnectionsContext"), sqlOpts => + { + sqlOpts.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery); + }); + }); + + builder.Services.AddHttpClient(); + + builder.Services.AddLazyCache(); + + builder.Services.AddHostedService(); + + var app = builder.Build(); + + var logger = app.Services.GetRequiredService>(); + + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | + Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto + }); + + // not production container environments should work behind a reverse proxy + if (!app.Environment.IsDevelopment() && EnvironmentUtility.IsContainer) + { + logger.LogInformation("Non-development environment container detected, reverse proxy mode enabled"); + + app.UsePathBase("/connections-api"); + } + + //app.UseHttpsRedirection(); + + app.UseFastEndpoints(); + + // if not dev env, migrate database + if (!app.Environment.IsDevelopment()) + { + using (var scope = app.Services.CreateScope()) + { + logger.LogInformation("Starting migration"); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + logger.LogInformation("Migration finished"); + } + } + + app.Run(); + } + } +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..ab354fb --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,34 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7004" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/weatherforecast", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:24921", + "sslPort": 44306 + } + } +} \ No newline at end of file diff --git a/SyncScheduler.cs b/SyncScheduler.cs new file mode 100644 index 0000000..38b1b33 --- /dev/null +++ b/SyncScheduler.cs @@ -0,0 +1,73 @@ +using ConnectionsAPI.Events; +using Cronos; +using System.Diagnostics.CodeAnalysis; + +namespace ConnectionsAPI +{ + public class SyncScheduler(ILogger logger, IConfiguration configuration) : BackgroundService + { + private readonly ILogger _logger = logger; + private readonly IConfiguration _configuration = configuration; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // get and parse the cron expression + string configCronStr = _configuration.GetValue("Sync:ScheduleCron") ?? string.Empty; + if (!TryParseCronExpression(configCronStr, out var cron)) + { + cron = CronExpression.Hourly; + _logger.LogWarning("Passed CRON expression was invalid ({configCron}); Defaulting to {newCron}", configCronStr, cron.ToString()); + } + _logger.LogInformation("Starting Sync Scheduler with CRON expression {cron}", cron.ToString()); + + // get and parse if immediate execution is wanted + bool runImmediately = _configuration.GetValue("Sync:RunImmediately", false); + if (runImmediately) + { + _logger.LogInformation("Immediate execution enabled; Sending sync command."); + await SendSyncEvent(stoppingToken, wait: true); + } + + // run the loop for executions + while (!stoppingToken.IsCancellationRequested) + { + // wait for next scheduled run + await WaitForNextSchedule(cron, stoppingToken); + // run the sync + _logger.LogInformation("Sending sync command at: {currentTime}", DateTimeOffset.UtcNow); + await SendSyncEvent(stoppingToken, wait: false); + } + } + + // this method exists because the Cronos TryParse for some reason throws an exception on nulls and empty strings + // completely defeating the purpose of the TryParse pattern + private static bool TryParseCronExpression(string expression, [NotNullWhen(true)] out CronExpression? cron) + { + try + { + cron = CronExpression.Parse(expression); + return true; + } + catch + { + cron = null; + return false; + } + } + + private static Task SendSyncEvent(CancellationToken stoppingToken, bool wait = false) => + new PuzzleSyncEvent { }.PublishAsync(wait ? Mode.WaitForAll : Mode.WaitForNone, stoppingToken); + + private async Task WaitForNextSchedule(CronExpression cron, CancellationToken ct) + { + var currentUtcTime = DateTimeOffset.UtcNow.UtcDateTime; + var nextOccurrenceTime = cron.GetNextOccurrence(currentUtcTime); + + var delay = nextOccurrenceTime.GetValueOrDefault() - currentUtcTime; + + _logger.LogInformation("Run delayed for {delay}. Next occurrence: {nextOccurrence}; Current time: {currentTime}", delay, nextOccurrenceTime, currentUtcTime); + + await Task.Delay(delay, ct); + } + } +} diff --git a/Utility/EnvironmentUtility.cs b/Utility/EnvironmentUtility.cs new file mode 100644 index 0000000..0be742a --- /dev/null +++ b/Utility/EnvironmentUtility.cs @@ -0,0 +1,8 @@ +namespace ConnectionsAPI.Utility +{ + public static class EnvironmentUtility + { + public static bool IsContainer => + Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + } +} diff --git a/Utility/HashUtility.cs b/Utility/HashUtility.cs new file mode 100644 index 0000000..a8cd0c4 --- /dev/null +++ b/Utility/HashUtility.cs @@ -0,0 +1,14 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ConnectionsAPI.Utility +{ + public static class HashUtility + { + public static string CalculateMD5(string input) + { + byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hash).Replace("-", string.Empty).ToLower(); + } + } +} diff --git a/Utility/SyncUtility.cs b/Utility/SyncUtility.cs new file mode 100644 index 0000000..c4b2c60 --- /dev/null +++ b/Utility/SyncUtility.cs @@ -0,0 +1,229 @@ +using ConnectionsAPI.Database; +using ConnectionsAPI.Database.Entities; +using Microsoft.EntityFrameworkCore; +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ConnectionsAPI.Utility +{ + public class SyncUtility(ConnectionsContext db, ILogger logger, HttpClient http) + { + #region Response types + class NYTConnectionsPuzzle + { + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + [JsonPropertyName("print_date")] + public string PrintDate { get; set; } = string.Empty; + [JsonPropertyName("editor")] + public string Editor { get; set; } = string.Empty; + [JsonPropertyName("categories")] + public IReadOnlyList Categories { get; set; } = []; + } + + class NYTConnectionsPuzzleCategory + { + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + [JsonPropertyName("cards")] + public IReadOnlyList Cards { get; set; } = []; + } + + public class NYTConnectionsPuzzleCard + { + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + [JsonPropertyName("position")] + public int Position { get; set; } + } + #endregion + + private static readonly string SHORT_DATE = "yyyy-MM-dd"; + + private readonly ConnectionsContext _db = db; + private readonly ILogger _logger = logger; + private readonly HttpClient _http = http; + + public async Task SyncPuzzlesAsync(CancellationToken ct) + { + _logger.LogInformation("Calculating puzzle sync dates"); + + // calculate the date ranges for the sync + var syncDates = await GetSyncDatesAsync(ct); + + _logger.LogInformation("Syncing puzzles between {start} - {end}", syncDates[0], syncDates[^1]); + + // run the HTTP requests in batches + ConcurrentDictionary responses = new(); + foreach (var batch in syncDates.Chunk(5)) + { + await Task.WhenAll( + batch.Select(x => GetConnectionsResponseAsync(x, ct).ContinueWith(t => + { + string? result = t.Result; + if (!string.IsNullOrWhiteSpace(result)) + { + responses.TryAdd(x, result); + } + else + { + _logger.LogWarning("Puzzle {date} non-success response, skipping", x); + } + })) + ); + } + + // process the response data + foreach (var response in responses.Select(kvp => new { PrintDate = kvp.Key, JsonContent = kvp.Value }) + .OrderBy(x => x.PrintDate)) + { + _logger.LogInformation("Processing puzzle data for {printDate}", response.PrintDate); + await UpsertPuzzleDataAsync(response.PrintDate, response.JsonContent); + } + + await _db.SaveChangesAsync(ct); + } + + private async Task UpsertPuzzleDataAsync(string printDate, string puzzleJson) + { + // check if JSON is valid + NYTConnectionsPuzzle? nytPuzzle = JsonSerializer.Deserialize(puzzleJson); + if (nytPuzzle == null || nytPuzzle.Status != "OK") + { + _logger.LogError("JSON content for {printDate} failed to deserialize or status not OK", printDate); + return; + } + + // calculate JSON content hash for change detection + string jsonMD5 = HashUtility.CalculateMD5(puzzleJson); + + // get a tracking reference to the puzzle matching by print date, either by querying or creating a new entity + var puzzle = await _db.Puzzles + .Include(x => x.Categories) + .ThenInclude(x => x.PuzzleCards) + .FirstOrDefaultAsync(x => x.PrintDate == printDate); + if (puzzle == null) + { + _logger.LogTrace("No puzzle found for {printDate}, puzzle will be created", printDate); + puzzle = new Database.Entities.Puzzle + { + Categories = [], + CreatedDate = DateTime.UtcNow + }; + _db.Puzzles.Add(puzzle); + } + + // if the content hash matches, no update needed + if (puzzle.ContentMD5 == jsonMD5) + { + _logger.LogTrace("JSON content hash for {printDate} matches, no need for update", printDate); + return; + } + + puzzle.ContentMD5 = jsonMD5; + puzzle.PrintDate = printDate; + puzzle.EditorName = nytPuzzle.Editor; + puzzle.Index = CalculateConnectionsDayIndex(printDate); + puzzle.Categories ??= []; + + // mark items for deletion and also remove them from here to be readded + _db.RemoveRange(puzzle.Categories); + puzzle.Categories.Clear(); + + // construct the entities + int idx = 1; + foreach (var nytCategory in nytPuzzle.Categories) + { + PuzzleCategory category = new() + { + Color = (PuzzleCategoryColor)idx++, + Name = nytCategory.Title, + Puzzle = puzzle, + PuzzleCards = [] + }; + + foreach (var nytCard in nytCategory.Cards) + { + PuzzleCard card = new() + { + Content = nytCard.Content, + Position = nytCard.Position, + PuzzleCategory = category, + }; + category.PuzzleCards.Add(card); + } + + puzzle.Categories.Add(category); + } + // done + } + + private static int CalculateConnectionsDayIndex(string printDate) + { + DateTime connectionsDate = DateTime.ParseExact(printDate, SHORT_DATE, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).Date; + return Convert.ToInt32(Math.Max((connectionsDate - Constants.ConnectionsStartDate).TotalDays, 0)) + 1; + } + + private async Task GetConnectionsResponseAsync(string printDate, CancellationToken ct) + { + string url = $"https://www.nytimes.com/svc/connections/v2/{printDate}.json"; + using var resp = await _http.GetAsync(url, ct); + if (resp == null || !resp.IsSuccessStatusCode) + { + return null; + } + string responseContent = await resp.Content.ReadAsStringAsync(ct); + return responseContent; + } + + private async Task> GetSyncDatesAsync(CancellationToken ct) + { + // query the last puzzle we have in the database + string? lastSyncedPuzzleDate = await _db.Puzzles.AsNoTracking() + .OrderByDescending(x => x.PrintDate) + .Select(x => x.PrintDate) + .FirstOrDefaultAsync(cancellationToken: ct); + + // calculate the starting date of the sync + string startDate; + + // if no puzzle was synced before, we use the start day of connections as a start (we want to sync every puzzle ever) + if (string.IsNullOrWhiteSpace(lastSyncedPuzzleDate)) + { + startDate = Constants.ConnectionsStartDate.ToString(SHORT_DATE); + } + else + { + string todayPrintDate = DateTimeOffset.UtcNow.UtcDateTime.ToString(SHORT_DATE); + // if we have a puzzle, we check the latest print date we have + // if the print date is earlier than today's date, we use that day as a base + // if the print date is after today, we use today as a base + if (lastSyncedPuzzleDate.CompareTo(todayPrintDate) < 0) + { + startDate = lastSyncedPuzzleDate; + } + else + { + startDate = todayPrintDate; + } + } + + // construct a list of dates + List dates = [startDate]; + + // we iterate on every day between the start date and UTC tomorrow (this should handle +12 timezones as well) + DateTime syncBeginDate = DateTime.ParseExact(startDate, SHORT_DATE, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).Date; + DateTime syncEndDate = DateTimeOffset.UtcNow.UtcDateTime.Date.AddDays(1); + + foreach (var date in Enumerable.Repeat(0, Convert.ToInt32((syncEndDate - syncBeginDate).TotalDays)).Select((_, idx) => syncBeginDate.AddDays(idx + 1))) + { + dates.Add(date.ToString(SHORT_DATE)); + } + + // done + return dates; + } + } +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..3169818 --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "ConnectionsContext": "Data Source=c:\\tmp\\connections-api\\dev.db;" + }, + "Sync": { + "RunImmediately": false + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}