Initial commit

This commit is contained in:
oaklake89 committed 2026-07-26 10:14:27 +00:00
commit 3abc7ec6d1
48 files changed
+9873

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/devcontainers/base:ubuntu24.04
USER root
ENV DOTNET_ROOT=/usr/share/dotnet \
PATH=/usr/share/dotnet:$PATH
ARG DOTNET_SDK_TOOLING_CHANNEL=10.0.3xx
RUN set -eux; \
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh; \
chmod +x /tmp/dotnet-install.sh; \
/tmp/dotnet-install.sh --channel "$DOTNET_SDK_TOOLING_CHANNEL" --quality GA --install-dir /usr/share/dotnet; \
rm -f /tmp/dotnet-install.sh
USER vscode
WORKDIR /workspaces
+29
View File
@@ -0,0 +1,29 @@
{
"name": "Backend",
"dockerComposeFile": ["../../docker-compose.yml"],
"service": "backend",
"workspaceFolder": "/workspaces/backend",
"updateRemoteUserUID": false,
"customizations": {
"vscode": {
"extensions": ["esbenp.prettier-vscode", "mikeburgh.xml-format", "ms-dotnettools.csdevkit@3.10.14"],
"settings": {
"dotnetAcquisitionExtension.sharedExistingDotnetPath": "/usr/share/dotnet/dotnet",
"remote.autoForwardPorts": false,
"[csharp]": {
"editor.defaultFormatter": "ms-dotnettools.csharp"
},
"[xml]": {
"editor.defaultFormatter": "mikeburgh.xml-format"
}
}
}
},
"forwardPorts": [5000, 5001],
"portsAttributes": {
"5001": {
"protocol": "https"
}
},
"remoteUser": "vscode"
}
+3
View File
@@ -0,0 +1,3 @@
root = true
[*]
end_of_line = lf
+1
View File
@@ -0,0 +1 @@
* text=auto
+51
View File
@@ -0,0 +1,51 @@
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# dotenv environment variables file
.env
# Certificates
[.]certs/
# Others
~$*
*~
CodeCoverage/
# MSBuild Binary and Structured Log
*.binlog
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
+39
View File
@@ -0,0 +1,39 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostNordLotteriet.WebAPI", "src\PostNordLotteriet.WebAPI\PostNordLotteriet.WebAPI.csproj", "{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Debug|x64.ActiveCfg = Debug|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Debug|x64.Build.0 = Debug|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Debug|x86.ActiveCfg = Debug|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Debug|x86.Build.0 = Debug|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Release|Any CPU.Build.0 = Release|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Release|x64.ActiveCfg = Release|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Release|x64.Build.0 = Release|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Release|x86.ActiveCfg = Release|Any CPU
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{AE85FFEC-CD86-4C18-8880-F56D01C1D44C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
@@ -0,0 +1,48 @@
using System;
using System.Globalization;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using PostNordLotteriet.WebAPI.Services;
namespace PostNordLotteriet.WebAPI.Controllers
{
[ApiVersion("1.0")]
[Route("rest/[controller]/v{v:apiVersion}")]
[ApiController]
[EnableRateLimiting("fixed")]
public class ShipmentController : ControllerBase
{
private readonly IWorkDayService _workDayService;
public ShipmentController(IWorkDayService workDayService)
{
_workDayService = workDayService;
}
[MapToApiVersion("1.0")]
[HttpGet("trackandtrace")]
public IActionResult Get()
{
DateTime currentDate = DateTime.Now;
if (_workDayService.IsNonWorkDay(currentDate))
{
DateTime nextWorkDay = _workDayService.GetNextWorkDay(currentDate);
Response.Headers.RetryAfter = nextWorkDay
.ToUniversalTime()
.ToString("R", CultureInfo.InvariantCulture);
return StatusCode(
StatusCodes.Status503ServiceUnavailable,
new { message = "Ingen postutdelning på arbetsfria dagar." });
}
return Random.Shared.NextDouble() < 0.4
? Ok(new { message = "Grattis! Försändelsen finns snart i en brevlåda nära dig." })
: BadRequest(new { message = "Tyvärr, ingen post idag." });
}
}
}
@@ -0,0 +1,27 @@
using Asp.Versioning;
using Microsoft.Extensions.DependencyInjection;
namespace PostNordLotteriet.WebAPI.Extensions
{
public static class ApiVersioningExtensions
{
public static IServiceCollection AddUrlSegmentApiVersioning(this IServiceCollection services)
{
services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ReportApiVersions = true;
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'V";
options.SubstituteApiVersionInUrl = true;
});
return services;
}
}
}
@@ -0,0 +1,93 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using PostNordLotteriet.WebAPI.Services;
namespace PostNordLotteriet.WebAPI.Extensions
{
public static class RateLimiterExtensions
{
// Adds a rate limiter with a custom error message in JSON format.
public static IServiceCollection AddPostNordLotterietRateLimiter(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.AddPolicy(policyName: "fixed", httpContext =>
{
// Disable rate limiter if today is a non-work day.
if (httpContext.RequestServices.GetRequiredService<IWorkDayService>().IsNonWorkDay(DateTime.Now))
{
return RateLimitPartition.GetNoLimiter("NoLimiter");
}
else
{
return RateLimitPartition.GetFixedWindowLimiter(partitionKey: httpContext.Connection.RemoteIpAddress?.ToString()
?? "Unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromDays(1),
PermitLimit = 1
});
}
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
HttpContext httpContext = context.HttpContext;
IWorkDayService workDayService =
httpContext.RequestServices.GetRequiredService<IWorkDayService>();
DateTimeOffset now = DateTimeOffset.Now;
DateTime nextWorkDay = workDayService.GetNextWorkDay(now.DateTime);
// The next workday starts at midnight in the server's local time zone.
DateTimeOffset nextWorkDayStart = new(
nextWorkDay,
now.Offset);
DateTimeOffset nextAttempt = nextWorkDayStart;
// Get the rate limiter's actual remaining lockout duration.
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out TimeSpan retryAfter))
{
DateTimeOffset limiterReset = now.Add(retryAfter);
if (limiterReset > nextAttempt)
{
nextAttempt = limiterReset;
}
}
string formattedNextAttempt = nextAttempt.ToString(
"dddd d MMMM 'klockan' HH:mm",
CultureInfo.GetCultureInfo("sv-SE"));
httpContext.Response.ContentType = "application/json";
httpContext.Response.Headers.RetryAfter = nextAttempt
.ToUniversalTime()
.ToString("R", CultureInfo.InvariantCulture);
await httpContext.Response.WriteAsync(
JsonSerializer.Serialize(new
{
message = $"Posten delas ut en gång per arbetsdag. Gör ett nytt försök {formattedNextAttempt}."
}),
cancellationToken);
};
});
return services;
}
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Folder Include="Controllers/" />
<Folder Include="Extensions/" />
<Folder Include="Services/" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="10.0.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace PostNordLotteriet.WebAPI
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://0.0.0.0:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://0.0.0.0:5001;http://0.0.0.0:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,10 @@
using System;
namespace PostNordLotteriet.WebAPI.Services
{
public interface IWorkDayService
{
bool IsNonWorkDay(DateTime date);
DateTime GetNextWorkDay(DateTime date);
}
}
@@ -0,0 +1,130 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace PostNordLotteriet.WebAPI.Services
{
public class WorkDayService : IWorkDayService
{
private static readonly ConcurrentDictionary<int, HashSet<DateTime>> _nonWorkDayCache = new ConcurrentDictionary<int, HashSet<DateTime>>();
private static readonly object _cacheLock = new object();
private static int _cacheYear = DateTime.Today.Year;
// Method to check if a given date is a non-working day.
public bool IsNonWorkDay(DateTime date)
{
if (date.DayOfWeek == DayOfWeek.Saturday ||
date.DayOfWeek == DayOfWeek.Sunday)
{
return true;
}
ClearCacheIfOutdated();
// Check if the date matches any fixed or moveable non-working days.
return _nonWorkDayCache.GetOrAdd(
date.Year,
year => GetFixedNonWorkDays(year)
.Concat(GetMoveableNonWorkDays(year))
.Select(d => d.Date)
.ToHashSet())
.Contains(date.Date);
}
// Computes and returns the next working day after the given date.
public DateTime GetNextWorkDay(DateTime date)
{
DateTime nextDate = date.Date.AddDays(1);
while (IsNonWorkDay(nextDate))
{
nextDate = nextDate.AddDays(1);
}
return nextDate;
}
// Returns fixed non-working days for the given year.
private static IEnumerable<DateTime> GetFixedNonWorkDays(int year)
{
return new DateTime[]
{
new DateTime(year, 1, 1),
new DateTime(year, 1, 6),
new DateTime(year, 5, 1),
new DateTime(year, 6, 6)
}
.Concat(
new int[] { 24, 25, 26, 31 }
.Select(day => new DateTime(year, 12, day)));
}
// Returns moveable non-working days for the given year.
private static DateTime[] GetMoveableNonWorkDays(int year)
{
DateTime easterSunday = GetEasterDate(year);
return new DateTime[]
{
easterSunday.AddDays(-2), // Good Friday
easterSunday.AddDays(1), // Easter Monday
easterSunday.AddDays(39), // Ascension Day
GetMidsummerEve(year) // Midsummer Eve
};
}
private static DateTime GetMidsummerEve(int year)
{
return Enumerable.Range(19, 7)
.Select(day => new DateTime(year, 6, day))
.First(date => date.DayOfWeek == DayOfWeek.Friday);
}
// Computes and returns the date of Easter Sunday for the given year.
private static DateTime GetEasterDate(int year)
{
int a = year % 19;
int b = year / 100;
int c = year % 100;
int d = b / 4;
int e = b % 4;
int f = (b + 8) / 25;
int g = (b - f + 1) / 3;
int h = (19 * a + b - d - g + 15) % 30;
int i = c / 4;
int k = c % 4;
int l = (32 + 2 * e + 2 * i - h - k) % 7;
int m = (a + 11 * h + 22 * l) / 451;
int value = h + l - 7 * m + 114;
int month = value / 31;
int day = value % 31 + 1;
return new DateTime(year, month, day);
}
private static void ClearCacheIfOutdated()
{
int currentYear = DateTime.Today.Year;
if (Volatile.Read(ref _cacheYear) == currentYear)
{
return;
}
lock (_cacheLock)
{
if (_cacheYear == currentYear)
{
return;
}
_nonWorkDayCache.Clear();
Volatile.Write(ref _cacheYear, currentYear);
}
}
}
}
@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PostNordLotteriet.WebAPI.Extensions;
using PostNordLotteriet.WebAPI.Services;
namespace PostNordLotteriet.WebAPI
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IWorkDayService, WorkDayService>();
services.AddControllers();
services.AddUrlSegmentApiVersioning();
// Configures routing options to enforce lowercase formatting.
services.Configure<RouteOptions>(options =>
{
options.LowercaseUrls = true;
options.LowercaseQueryStrings = true;
});
services.AddPostNordLotterietRateLimiter();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseRateLimiter();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}