Add project files.
This commit is contained in:
30
.dockerignore
Normal file
30
.dockerignore
Normal file
@@ -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/**
|
||||||
14
Config/SyncOptions.cs
Normal file
14
Config/SyncOptions.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ConnectionsAPI.Config
|
||||||
|
{
|
||||||
|
public class SyncOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The cron expression for the sync schedule
|
||||||
|
/// </summary>
|
||||||
|
public string? ScheduleCron { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// If the sync should run immediately or wait until the next cron occurrence
|
||||||
|
/// </summary>
|
||||||
|
public bool? RunImmediately { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
ConnectionsAPI - Backup.csproj
Normal file
18
ConnectionsAPI - Backup.csproj
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>b560bdda-fbdf-4fb8-86ec-e8d6c743e978</UserSecretsId>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
<DockerfileContext>.</DockerfileContext>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FastEndpoints" Version="5.24.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
24
ConnectionsAPI.csproj
Normal file
24
ConnectionsAPI.csproj
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>b560bdda-fbdf-4fb8-86ec-e8d6c743e978</UserSecretsId>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
<DockerfileContext>.</DockerfileContext>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Cronos" Version="0.8.4" />
|
||||||
|
<PackageReference Include="FastEndpoints" Version="5.24.0" />
|
||||||
|
<PackageReference Include="LazyCache.AspNetCore" Version="2.4.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
25
ConnectionsAPI.sln
Normal file
25
ConnectionsAPI.sln
Normal file
@@ -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
|
||||||
7
Constants.cs
Normal file
7
Constants.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ConnectionsAPI
|
||||||
|
{
|
||||||
|
public class Constants
|
||||||
|
{
|
||||||
|
public static readonly DateTime ConnectionsStartDate = new(2023, 06, 12, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Database/ConnectionsContext.cs
Normal file
18
Database/ConnectionsContext.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using ConnectionsAPI.Database.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ConnectionsAPI.Database
|
||||||
|
{
|
||||||
|
public class ConnectionsContext(DbContextOptions<ConnectionsContext> dbContextOptions) : DbContext(dbContextOptions)
|
||||||
|
{
|
||||||
|
public DbSet<Puzzle> Puzzles { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Puzzle>()
|
||||||
|
.HasIndex(x => x.PrintDate).IsUnique();
|
||||||
|
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Database/Entities/Puzzle.cs
Normal file
40
Database/Entities/Puzzle.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace ConnectionsAPI.Database.Entities
|
||||||
|
{
|
||||||
|
public class Puzzle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key of the entity
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the entity was created (is the sync date)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the puzzle was "printed" online
|
||||||
|
/// </summary>
|
||||||
|
public string PrintDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the editor for the puzzle
|
||||||
|
/// </summary>
|
||||||
|
public string EditorName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The actual count of the puzzle
|
||||||
|
/// </summary>
|
||||||
|
public int Index { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The MD5 hash for the source content used to sync this puzzle
|
||||||
|
/// </summary>
|
||||||
|
public string ContentMD5 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The categories associated with this puzzle
|
||||||
|
/// </summary>
|
||||||
|
public virtual ICollection<PuzzleCategory> Categories { get; set; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Database/Entities/PuzzleCard.cs
Normal file
30
Database/Entities/PuzzleCard.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ConnectionsAPI.Database.Entities
|
||||||
|
{
|
||||||
|
public class PuzzleCard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key of the entity
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The contents of this card (the word)
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The initial position of this card on the grid
|
||||||
|
/// </summary>
|
||||||
|
public int Position { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the associated Connections category
|
||||||
|
/// </summary>
|
||||||
|
public int PuzzleCategoryId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The associated category instance
|
||||||
|
/// </summary>
|
||||||
|
public virtual PuzzleCategory? PuzzleCategory { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Database/Entities/PuzzleCategory.cs
Normal file
35
Database/Entities/PuzzleCategory.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ConnectionsAPI.Database.Entities
|
||||||
|
{
|
||||||
|
public class PuzzleCategory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key of the entity
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the category in this Connections puzzle
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color of the category in this Connections puzzle; Also used for sorting
|
||||||
|
/// </summary>
|
||||||
|
public PuzzleCategoryColor Color { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the associated Connections puzzle
|
||||||
|
/// </summary>
|
||||||
|
public int PuzzleId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The associated puzzle instance
|
||||||
|
/// </summary>
|
||||||
|
public virtual Puzzle? Puzzle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cards associated with this category
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<PuzzleCard> PuzzleCards { get; set; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Database/Entities/PuzzleCategoryColor.cs
Normal file
10
Database/Entities/PuzzleCategoryColor.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ConnectionsAPI.Database.Entities
|
||||||
|
{
|
||||||
|
public enum PuzzleCategoryColor
|
||||||
|
{
|
||||||
|
Yellow = 1,
|
||||||
|
Green = 2,
|
||||||
|
Blue = 3,
|
||||||
|
Purple = 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
135
Database/Migrations/20240416121538_Initial.Designer.cs
generated
Normal file
135
Database/Migrations/20240416121538_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ContentMD5")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EditorName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PrintDate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PrintDate")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Puzzles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCard", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Position")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PuzzleCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PuzzleCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("PuzzleCard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Color")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
Database/Migrations/20240416121538_Initial.cs
Normal file
103
Database/Migrations/20240416121538_Initial.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ConnectionsAPI.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Initial : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Puzzles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
CreatedDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
PrintDate = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
EditorName = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Index = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ContentMD5 = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Puzzles", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PuzzleCategory",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Color = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
PuzzleId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Content = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Position = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
PuzzleCategoryId = table.Column<int>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PuzzleCard");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PuzzleCategory");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Puzzles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
Database/Migrations/ConnectionsContextModelSnapshot.cs
Normal file
132
Database/Migrations/ConnectionsContextModelSnapshot.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ContentMD5")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EditorName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PrintDate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PrintDate")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Puzzles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCard", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Position")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PuzzleCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PuzzleCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("PuzzleCard");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ConnectionsAPI.Database.Entities.PuzzleCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Color")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||||
41
Events/PuzzleSyncEvent.cs
Normal file
41
Events/PuzzleSyncEvent.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
using ConnectionsAPI.Database;
|
||||||
|
using ConnectionsAPI.Utility;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ConnectionsAPI.Events
|
||||||
|
{
|
||||||
|
public class PuzzleSyncEvent : IEvent { }
|
||||||
|
|
||||||
|
public class PuzzleSyncHandler(ILogger<PuzzleSyncHandler> logger, IServiceScopeFactory scopeFactory) : IEventHandler<PuzzleSyncEvent>
|
||||||
|
{
|
||||||
|
private readonly ILogger<PuzzleSyncHandler> _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<ConnectionsContext>();
|
||||||
|
HttpClient http = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>().CreateClient();
|
||||||
|
ILogger<SyncUtility> syncLogger = scope.ServiceProvider.GetRequiredService<ILogger<SyncUtility>>();
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Features/Puzzle/Get/GetPuzzleEndpoint.cs
Normal file
83
Features/Puzzle/Get/GetPuzzleEndpoint.cs
Normal file
@@ -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<GetPuzzleEndpointRequest>
|
||||||
|
{
|
||||||
|
[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<GetPuzzleEndpoint> logger, IAppCache cache) : Endpoint<GetPuzzleEndpointRequest, PuzzleDTO>
|
||||||
|
{
|
||||||
|
private readonly ConnectionsContext _db = db;
|
||||||
|
private readonly ILogger<GetPuzzleEndpoint> _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<PuzzleDTO?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Features/Puzzle/List/ListPuzzlesEndpoint.cs
Normal file
43
Features/Puzzle/List/ListPuzzlesEndpoint.cs
Normal file
@@ -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<ICollection<PuzzleDTO>>
|
||||||
|
{
|
||||||
|
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<ICollection<PuzzleDTO>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
GlobalUsings.cs
Normal file
1
GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using FastEndpoints;
|
||||||
49
Models/PuzzleDTO.cs
Normal file
49
Models/PuzzleDTO.cs
Normal file
@@ -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<PuzzleCategoryDTO> 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<PuzzleCardDTO> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
77
Program.cs
Normal file
77
Program.cs
Normal file
@@ -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<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor |
|
||||||
|
Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddFastEndpoints();
|
||||||
|
|
||||||
|
// set up options
|
||||||
|
builder.Services.Configure<SyncOptions>(builder.Configuration.GetSection("Sync"));
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<ConnectionsContext>(opt =>
|
||||||
|
{
|
||||||
|
opt.UseSqlite(builder.Configuration.GetConnectionString("ConnectionsContext"), sqlOpts =>
|
||||||
|
{
|
||||||
|
sqlOpts.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
|
builder.Services.AddLazyCache();
|
||||||
|
|
||||||
|
builder.Services.AddHostedService<SyncScheduler>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||||
|
|
||||||
|
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<ConnectionsContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
logger.LogInformation("Migration finished");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Properties/launchSettings.json
Normal file
34
Properties/launchSettings.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
SyncScheduler.cs
Normal file
73
SyncScheduler.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using ConnectionsAPI.Events;
|
||||||
|
using Cronos;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace ConnectionsAPI
|
||||||
|
{
|
||||||
|
public class SyncScheduler(ILogger<SyncScheduler> logger, IConfiguration configuration) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SyncScheduler> _logger = logger;
|
||||||
|
private readonly IConfiguration _configuration = configuration;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// get and parse the cron expression
|
||||||
|
string configCronStr = _configuration.GetValue<string>("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<bool>("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Utility/EnvironmentUtility.cs
Normal file
8
Utility/EnvironmentUtility.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ConnectionsAPI.Utility
|
||||||
|
{
|
||||||
|
public static class EnvironmentUtility
|
||||||
|
{
|
||||||
|
public static bool IsContainer =>
|
||||||
|
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Utility/HashUtility.cs
Normal file
14
Utility/HashUtility.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
Utility/SyncUtility.cs
Normal file
229
Utility/SyncUtility.cs
Normal file
@@ -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<SyncUtility> 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<NYTConnectionsPuzzleCategory> Categories { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class NYTConnectionsPuzzleCategory
|
||||||
|
{
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("cards")]
|
||||||
|
public IReadOnlyList<NYTConnectionsPuzzleCard> 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<SyncUtility> _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<string, string> 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<NYTConnectionsPuzzle>(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<string?> 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<IReadOnlyList<string>> 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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
appsettings.Development.json
Normal file
14
appsettings.Development.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"ConnectionsContext": "Data Source=c:\\tmp\\connections-api\\dev.db;"
|
||||||
|
},
|
||||||
|
"Sync": {
|
||||||
|
"RunImmediately": false
|
||||||
|
}
|
||||||
|
}
|
||||||
9
appsettings.json
Normal file
9
appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user