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": "*"
+}