Compare commits
No commits in common. "main" and "take-a-number-implementation" have entirely different histories.
main
...
take-a-num
|
@ -1,20 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Blazor.Bootstrap" Version="3.3.1" />
|
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\AccessQueueService\AccessQueueService.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -1,23 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<base href="/" />
|
|
||||||
<link rel="stylesheet" href="app.css" />
|
|
||||||
<link rel="stylesheet" href="AccessQueuePlayground.styles.css" />
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
|
|
||||||
<link href="_content/Blazor.Bootstrap/blazor.bootstrap.css" rel="stylesheet" />
|
|
||||||
<HeadOutlet @rendermode="InteractiveServer" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<Routes @rendermode="InteractiveServer" />
|
|
||||||
<script src="_framework/blazor.web.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
|
||||||
<script src="_content/Blazor.Bootstrap/blazor.bootstrap.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,36 +0,0 @@
|
||||||
@inherits LayoutComponentBase
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="/">AccessQueue Playground</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/">Home</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/about">About</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/config">Config</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="https://git.hobbs.zone/henry/AccessQueueService" target="_blank" rel="noopener">Source</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container-body">
|
|
||||||
@Body
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
|
||||||
An unhandled error has occurred.
|
|
||||||
<a href="" class="reload">Reload</a>
|
|
||||||
<a class="dismiss">🗙</a>
|
|
||||||
</div>
|
|
|
@ -1,25 +0,0 @@
|
||||||
#blazor-error-ui {
|
|
||||||
background: lightyellow;
|
|
||||||
bottom: 0;
|
|
||||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
|
||||||
display: none;
|
|
||||||
left: 0;
|
|
||||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blazor-error-ui .dismiss {
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
right: 0.75rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-body {
|
|
||||||
width: 90%;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
@page "/about"
|
|
||||||
|
|
||||||
<PageTitle>About - AccessQueue Playground</PageTitle>
|
|
||||||
|
|
||||||
<h2>About AccessQueue Playground</h2>
|
|
||||||
<p>
|
|
||||||
<b>AccessQueue Playground</b> is a demo Blazor application for testing and visualizing the <b>AccessQueueService</b> system. It allows you to simulate users requesting access, manage a queue, and experiment with configuration options such as expiration, activity, and capacity limits.
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Project: <b>AccessQueueService</b></li>
|
|
||||||
<li>Frontend: <b>Blazor (BlazorBootstrap)</b></li>
|
|
||||||
<li>Backend: <b>.NET 8</b></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>How AccessQueueService Works</h3>
|
|
||||||
<ol>
|
|
||||||
<li><b>Requesting Access:</b> Users are granted access if capacity is available, otherwise they are queued. Expiration and activity timeouts are enforced.</li>
|
|
||||||
<li><b>Queueing:</b> Users in the queue must remain active to keep their spot. The queue is managed FIFO (first-in, first-out).</li>
|
|
||||||
<li><b>Dequeuing:</b> When capacity is available, users are dequeued and granted access if still active.</li>
|
|
||||||
<li><b>Maintaining Access:</b> Users must re-request access to remain active. Expiration can be rolling or fixed.</li>
|
|
||||||
<li><b>Revoking Access:</b> Users can revoke their access, freeing up capacity for others.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>Configuration Options</h3>
|
|
||||||
<ul>
|
|
||||||
<li><b>CapacityLimit:</b> Max concurrent users with access.</li>
|
|
||||||
<li><b>ActivitySeconds:</b> How long a user can be inactive before losing their spot.</li>
|
|
||||||
<li><b>ExpirationSeconds:</b> How long before an access ticket expires.</li>
|
|
||||||
<li><b>RollingExpiration:</b> If true, expiration resets on activity.</li>
|
|
||||||
<li><b>RefreshRateMilliseconds:</b> How often the playground requests access for active users and updates the UI.</li>
|
|
||||||
</ul>
|
|
||||||
<p>For now these options can only be set via appsettings.json. The Playground UI does not yet support changing these values.</p>
|
|
||||||
|
|
||||||
<h3>About the Playground UI</h3>
|
|
||||||
<p>
|
|
||||||
The Playground UI lets you:
|
|
||||||
<ul>
|
|
||||||
<li>Add users to the queue and grant access.</li>
|
|
||||||
<li>Revoke access for individual users or all users.</li>
|
|
||||||
<li>Reset all data for testing.</li>
|
|
||||||
<li>See real-time updates of users with access, in queue, and inactive.</li>
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Source Code & Documentation</h3>
|
|
||||||
<p>
|
|
||||||
See the <a href="https://git.hobbs.zone/henry/AccessQueueService" target="_blank" rel="noopener">project repository</a> for full documentation, source code, and usage instructions.
|
|
||||||
</p>
|
|
|
@ -1,96 +0,0 @@
|
||||||
@page "/config"
|
|
||||||
@inject AccessQueuePlayground.Services.IAccessQueueManager QueueManager
|
|
||||||
@using BlazorBootstrap
|
|
||||||
|
|
||||||
<h3>Access Queue Configuration</h3>
|
|
||||||
|
|
||||||
<EditForm Model="config" OnValidSubmit="HandleValidSubmit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<ValidationSummary />
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="capacityLimit">Capacity Limit</label>
|
|
||||||
<TextInput Id="capacityLimit" @bind-Value="config.CapacityLimit" Type="TextInputType.Number" />
|
|
||||||
@if (!isCapacityLimitValid)
|
|
||||||
{
|
|
||||||
<div class="text-danger">Please enter a positive integer.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="activitySeconds">Activity Seconds</label>
|
|
||||||
<TextInput Id="activitySeconds" @bind-Value="config.ActivitySeconds" Type="TextInputType.Number" />
|
|
||||||
@if (!isActivitySecondsValid)
|
|
||||||
{
|
|
||||||
<div class="text-danger">Please enter a positive integer.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="expirationSeconds">Expiration Seconds</label>
|
|
||||||
<TextInput Id="expirationSeconds" @bind-Value="config.ExpirationSeconds" Type="TextInputType.Number" />
|
|
||||||
@if (!isExpirationSecondsValid)
|
|
||||||
{
|
|
||||||
<div class="text-danger">Please enter a positive integer.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<Switch Id="rollingExpiration" @bind-Value="config.RollingExpiration" Label="Rolling Expiration" />
|
|
||||||
</div>
|
|
||||||
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary">Save</Button>
|
|
||||||
@if (successMessage != null)
|
|
||||||
{
|
|
||||||
<Alert Color="AlertColor.Success" Class="mt-3">@successMessage</Alert>
|
|
||||||
}
|
|
||||||
</EditForm>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private ConfigModel config = new();
|
|
||||||
private bool isCapacityLimitValid = true;
|
|
||||||
private bool isActivitySecondsValid = true;
|
|
||||||
private bool isExpirationSecondsValid = true;
|
|
||||||
private string? successMessage;
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
var current = QueueManager.GetConfig();
|
|
||||||
config = new ConfigModel
|
|
||||||
{
|
|
||||||
ActivitySeconds = (current.ActivitySeconds ?? 0).ToString(),
|
|
||||||
CapacityLimit = (current.CapacityLimit ?? 0).ToString(),
|
|
||||||
ExpirationSeconds = (current.ExpirationSeconds ?? 0).ToString(),
|
|
||||||
RollingExpiration = current.RollingExpiration ?? false
|
|
||||||
};
|
|
||||||
ValidateInputs();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsFormValid => isCapacityLimitValid && isActivitySecondsValid && isExpirationSecondsValid;
|
|
||||||
|
|
||||||
private void ValidateInputs()
|
|
||||||
{
|
|
||||||
isCapacityLimitValid = int.TryParse(config.CapacityLimit, out var cap) && cap > 0;
|
|
||||||
isActivitySecondsValid = int.TryParse(config.ActivitySeconds, out var act) && act > 0;
|
|
||||||
isExpirationSecondsValid = int.TryParse(config.ExpirationSeconds, out var exp) && exp > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleValidSubmit()
|
|
||||||
{
|
|
||||||
successMessage = null;
|
|
||||||
ValidateInputs();
|
|
||||||
if (!IsFormValid)
|
|
||||||
return;
|
|
||||||
await Task.Run(() => QueueManager.UpdateConfig(new ()
|
|
||||||
{
|
|
||||||
ActivitySeconds = int.Parse(config.ActivitySeconds),
|
|
||||||
CapacityLimit = int.Parse(config.CapacityLimit),
|
|
||||||
ExpirationSeconds = int.Parse(config.ExpirationSeconds),
|
|
||||||
RollingExpiration = config.RollingExpiration
|
|
||||||
}));
|
|
||||||
successMessage = "Configuration updated successfully.";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ConfigModel
|
|
||||||
{
|
|
||||||
public string CapacityLimit { get; set; } = "";
|
|
||||||
public string ActivitySeconds { get; set; } = "";
|
|
||||||
public string ExpirationSeconds { get; set; } = "";
|
|
||||||
public bool RollingExpiration { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
@page "/Error"
|
|
||||||
@using System.Diagnostics
|
|
||||||
|
|
||||||
<PageTitle>Error</PageTitle>
|
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
|
||||||
|
|
||||||
@if (ShowRequestId)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
|
||||||
<p>
|
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
|
||||||
It can result in displaying sensitive information from exceptions to end users.
|
|
||||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
|
||||||
and restarting the app.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@code{
|
|
||||||
[CascadingParameter]
|
|
||||||
private HttpContext? HttpContext { get; set; }
|
|
||||||
|
|
||||||
private string? RequestId { get; set; }
|
|
||||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
|
||||||
|
|
||||||
protected override void OnInitialized() =>
|
|
||||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
|
||||||
}
|
|
|
@ -1,129 +0,0 @@
|
||||||
@page "/"
|
|
||||||
@using AccessQueuePlayground.Models
|
|
||||||
@using AccessQueuePlayground.Services
|
|
||||||
@using AccessQueueService.Models;
|
|
||||||
@using BlazorBootstrap
|
|
||||||
|
|
||||||
@inject IAccessQueueManager Manager
|
|
||||||
|
|
||||||
<PageTitle>AccessQueue Playground</PageTitle>
|
|
||||||
@if (Config != null)
|
|
||||||
{
|
|
||||||
<h4>Config</h4>
|
|
||||||
<p>
|
|
||||||
<b>Expiration Seconds:</b> @Config.ExpirationSeconds,
|
|
||||||
<b>Activity Seconds:</b> @Config.ActivitySeconds,
|
|
||||||
<b>Capacity Limit:</b> @Config.CapacityLimit,
|
|
||||||
<b>Rolling Expiration:</b> @Config.RollingExpiration
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<p>
|
|
||||||
<Button Color="ButtonColor.Success" @onclick="() => AddUser(true)">Add Active User</Button>
|
|
||||||
<Button Color="ButtonColor.Success" Outline @onclick="() => AddUser(false)">Add Inctive User</Button>
|
|
||||||
<Button Color="ButtonColor.Danger" @onclick="RevokeAllAccess">Revoke All</Button>
|
|
||||||
<Button Color="ButtonColor.Warning" @onclick="Reset">Reset Data</Button>
|
|
||||||
</p>
|
|
||||||
@if (Status != null)
|
|
||||||
{
|
|
||||||
<h4>Users with access</h4>
|
|
||||||
<Grid TItem="User" Data="Status.AccessUsers" Class="table table-bordered mt-3" AllowSorting>
|
|
||||||
<GridColumns>
|
|
||||||
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
|
|
||||||
@context.Id
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn TItem="User" HeaderText="Expiration" PropertyName="LatestResponse?.ExpiresOn" SortKeySelector="item => item.LatestResponse.ExpiresOn">
|
|
||||||
@context.LatestResponse?.ExpiresOn
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn TItem="User" HeaderText="Active">
|
|
||||||
<ChildContent>
|
|
||||||
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(value) => SetUserActive(context.Id, value)" />
|
|
||||||
</ChildContent>
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn TItem="User" HeaderText="Revoke">
|
|
||||||
<ChildContent>
|
|
||||||
<Button Size="ButtonSize.ExtraSmall" Color="ButtonColor.Danger" @onclick="() => RevokeAccess(context.Id)">Revoke Access</Button>
|
|
||||||
</ChildContent>
|
|
||||||
</GridColumn>
|
|
||||||
</GridColumns>
|
|
||||||
</Grid>
|
|
||||||
<h4>Users in queue</h4>
|
|
||||||
<Grid TItem="User" Data="Status.QueuedUsers" Class="table table-bordered mt-3">
|
|
||||||
<GridColumns>
|
|
||||||
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id">
|
|
||||||
@context.Id
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn TItem="User" HeaderText="Queue Postition" PropertyName="LatestResponse?.RequestsAhead">
|
|
||||||
@(context.LatestResponse?.RequestsAhead ?? 0 + 1)
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn TItem="User" HeaderText="Active">
|
|
||||||
<ChildContent>
|
|
||||||
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(value) => SetUserActive(context.Id, value)" />
|
|
||||||
</ChildContent>
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn TItem="User" HeaderText="Revoke">
|
|
||||||
<ChildContent>
|
|
||||||
<Button Size="ButtonSize.ExtraSmall" Color="ButtonColor.Danger" @onclick="() => RevokeAccess(context.Id)">Revoke Access</Button>
|
|
||||||
</ChildContent>
|
|
||||||
</GridColumn>
|
|
||||||
</GridColumns>
|
|
||||||
</Grid>
|
|
||||||
<h4>Inactive users</h4>
|
|
||||||
<Grid TItem="User" Data="Status.InactiveUsers" Class="table table-bordered mt-3" AllowSorting>
|
|
||||||
<GridColumns>
|
|
||||||
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
|
|
||||||
@context.Id
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn TItem="User" HeaderText="Active">
|
|
||||||
<ChildContent>
|
|
||||||
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(value) => SetUserActive(context.Id, value)" />
|
|
||||||
</ChildContent>
|
|
||||||
</GridColumn>
|
|
||||||
</GridColumns>
|
|
||||||
</Grid>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
public AccessQueueManagerStatus? Status;
|
|
||||||
public AccessQueueConfig? Config;
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
Manager.StatusUpdated += OnStatusUpdated;
|
|
||||||
Status = Manager.GetStatus();
|
|
||||||
Config = Manager.GetConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnStatusUpdated()
|
|
||||||
{
|
|
||||||
InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
Status = Manager.GetStatus();
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddUser(bool isActive)
|
|
||||||
{
|
|
||||||
Manager.AddUser(isActive);
|
|
||||||
Status = Manager.GetStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetUserActive(Guid userId, bool isActive)
|
|
||||||
{
|
|
||||||
Manager.SetUserActive(userId, isActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RevokeAccess(Guid userId)
|
|
||||||
{
|
|
||||||
Manager.RevokeAccess(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RevokeAllAccess()
|
|
||||||
{
|
|
||||||
Manager.RevokeAllAccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Reset()
|
|
||||||
{
|
|
||||||
Manager.Reset();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
<Router AppAssembly="typeof(Program).Assembly">
|
|
||||||
<Found Context="routeData">
|
|
||||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
|
||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
|
||||||
</Found>
|
|
||||||
</Router>
|
|
|
@ -1,10 +0,0 @@
|
||||||
@using System.Net.Http
|
|
||||||
@using System.Net.Http.Json
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
|
||||||
@using Microsoft.JSInterop
|
|
||||||
@using AccessQueuePlayground
|
|
||||||
@using AccessQueuePlayground.Components
|
|
|
@ -1,11 +0,0 @@
|
||||||
using AccessQueueService.Models;
|
|
||||||
|
|
||||||
namespace AccessQueuePlayground.Models
|
|
||||||
{
|
|
||||||
public class AccessQueueManagerStatus
|
|
||||||
{
|
|
||||||
public List<User> AccessUsers { get; set; } = [];
|
|
||||||
public List<User> QueuedUsers { get; set; } = [];
|
|
||||||
public List<User> InactiveUsers { get; set; } = [];
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
using AccessQueueService.Models;
|
|
||||||
|
|
||||||
namespace AccessQueuePlayground.Models
|
|
||||||
{
|
|
||||||
public class User
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public bool Active { get; set; }
|
|
||||||
public AccessResponse? LatestResponse { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
using AccessQueuePlayground.Components;
|
|
||||||
using AccessQueuePlayground.Services;
|
|
||||||
using AccessQueueService.Data;
|
|
||||||
using AccessQueueService.Services;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Add Serilog configuration for console logging only
|
|
||||||
builder.Host.UseSerilog((context, services, configuration) =>
|
|
||||||
{
|
|
||||||
configuration
|
|
||||||
.WriteTo.Console()
|
|
||||||
.ReadFrom.Configuration(context.Configuration)
|
|
||||||
.ReadFrom.Services(services)
|
|
||||||
.Enrich.FromLogContext();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add services to the container.
|
|
||||||
builder.Services.AddRazorComponents()
|
|
||||||
.AddInteractiveServerComponents();
|
|
||||||
if (string.IsNullOrEmpty(builder.Configuration["AccessQueuePlayground:ServiceUrl"]))
|
|
||||||
{
|
|
||||||
builder.Services.AddSingleton<IAccessService, AccessService>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
builder.Services.AddSingleton<IAccessService, AccessQueueApiService>();
|
|
||||||
}
|
|
||||||
builder.Services.AddSingleton<IAccessQueueRepo, TakeANumberAccessQueueRepo>();
|
|
||||||
builder.Services.AddSingleton<IAccessQueueManager, AccessQueueManager>();
|
|
||||||
builder.Services.AddHostedService<AccessQueueBackgroundService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
|
||||||
if (!app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
||||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
|
||||||
app.UseHsts();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseStaticFiles();
|
|
||||||
app.UseAntiforgery();
|
|
||||||
|
|
||||||
app.MapRazorComponents<App>()
|
|
||||||
.AddInteractiveServerRenderMode();
|
|
||||||
|
|
||||||
app.Run();
|
|
|
@ -1,38 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
|
||||||
"iisSettings": {
|
|
||||||
"windowsAuthentication": false,
|
|
||||||
"anonymousAuthentication": true,
|
|
||||||
"iisExpress": {
|
|
||||||
"applicationUrl": "http://localhost:25310",
|
|
||||||
"sslPort": 44353
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "http://localhost:5108",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "https://localhost:7211;http://localhost:5108",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"IIS Express": {
|
|
||||||
"commandName": "IISExpress",
|
|
||||||
"launchBrowser": true,
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
using System.Net.Http;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using AccessQueueService.Models;
|
|
||||||
using AccessQueueService.Services;
|
|
||||||
|
|
||||||
namespace AccessQueuePlayground.Services
|
|
||||||
{
|
|
||||||
public class AccessQueueApiService : IAccessService
|
|
||||||
{
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly string _serviceUrl;
|
|
||||||
|
|
||||||
public AccessQueueStatus Status => throw new NotImplementedException();
|
|
||||||
|
|
||||||
public AccessQueueApiService(HttpClient httpClient, IConfiguration config)
|
|
||||||
{
|
|
||||||
_httpClient = httpClient;
|
|
||||||
_serviceUrl = config["AccessQueuePlayground:ServiceUrl"]?.TrimEnd('/') ?? "https://localhost:7291";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AccessResponse> RequestAccess(string userId)
|
|
||||||
{
|
|
||||||
return await _httpClient.GetFromJsonAsync<AccessResponse>($"{_serviceUrl}/access/{userId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> RevokeAccess(string userId)
|
|
||||||
{
|
|
||||||
var response = await _httpClient.DeleteAsync($"{_serviceUrl}/access/{userId}");
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<bool>();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<int> DeleteExpiredTickets()
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public AccessQueueConfig GetConfiguration()
|
|
||||||
{
|
|
||||||
return _httpClient.GetFromJsonAsync<AccessQueueConfig>($"{_serviceUrl}/config").Result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateConfiguration(AccessQueueConfig config)
|
|
||||||
{
|
|
||||||
_ = _httpClient.PostAsJsonAsync($"{_serviceUrl}/config", config).Result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void PatchConfiguration(AccessQueueConfig partialConfig)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
|
|
||||||
namespace AccessQueuePlayground.Services
|
|
||||||
{
|
|
||||||
public class AccessQueueBackgroundService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly IAccessQueueManager _accessQueueManager;
|
|
||||||
private readonly IConfiguration _config;
|
|
||||||
|
|
||||||
public AccessQueueBackgroundService(IAccessQueueManager accessQueueManager, IConfiguration config)
|
|
||||||
{
|
|
||||||
_accessQueueManager = accessQueueManager;
|
|
||||||
_config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
int refreshRate = _config.GetValue<int>("AccessQueuePlayground:RefreshRateMilliseconds");
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
await _accessQueueManager.RecalculateStatus();
|
|
||||||
await Task.Delay(refreshRate, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using AccessQueuePlayground.Models;
|
|
||||||
using AccessQueueService.Models;
|
|
||||||
using AccessQueueService.Services;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace AccessQueuePlayground.Services
|
|
||||||
{
|
|
||||||
public class AccessQueueManager : IAccessQueueManager
|
|
||||||
{
|
|
||||||
private readonly IAccessService _accessService;
|
|
||||||
private readonly IConfiguration _config;
|
|
||||||
private ConcurrentDictionary<Guid, User> _users;
|
|
||||||
private AccessQueueManagerStatus _status;
|
|
||||||
public event Action? StatusUpdated;
|
|
||||||
|
|
||||||
private void NotifyStatusUpdated()
|
|
||||||
{
|
|
||||||
StatusUpdated?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
public AccessQueueManager(IAccessService accessService, IConfiguration config)
|
|
||||||
{
|
|
||||||
_accessService = accessService;
|
|
||||||
_users = new ConcurrentDictionary<Guid, User>();
|
|
||||||
_status = new AccessQueueManagerStatus();
|
|
||||||
_config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AccessQueueManagerStatus GetStatus() => _status;
|
|
||||||
|
|
||||||
public AccessQueueConfig GetConfig() => _accessService.GetConfiguration();
|
|
||||||
|
|
||||||
public void UpdateConfig(AccessQueueConfig config)
|
|
||||||
{
|
|
||||||
_accessService.UpdateConfiguration(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid AddUser(bool isActive)
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
_users[id] = new User
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Active = isActive,
|
|
||||||
};
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetUserActive(Guid userId, bool isActive)
|
|
||||||
{
|
|
||||||
if (_users.TryGetValue(userId, out var user))
|
|
||||||
{
|
|
||||||
user.Active = isActive;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RecalculateStatus()
|
|
||||||
{
|
|
||||||
var userList = _users.Values.ToList();
|
|
||||||
var newStatus = new AccessQueueManagerStatus();
|
|
||||||
foreach (var user in userList)
|
|
||||||
{
|
|
||||||
if (user.Active)
|
|
||||||
{
|
|
||||||
user.LatestResponse = await _accessService.RequestAccess(user.Id.ToString());
|
|
||||||
if (user.LatestResponse?.HasAccess ?? false)
|
|
||||||
{
|
|
||||||
newStatus.AccessUsers.Add(user);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
newStatus.QueuedUsers.Add(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if(user.LatestResponse?.ExpiresOn != null && user.LatestResponse.ExpiresOn > DateTime.UtcNow)
|
|
||||||
{
|
|
||||||
newStatus.AccessUsers.Add(user);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
newStatus.InactiveUsers.Add(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newStatus.QueuedUsers.Sort((user1, user2) => user1.LatestResponse!.RequestsAhead - user2.LatestResponse!.RequestsAhead);
|
|
||||||
_status = newStatus;
|
|
||||||
NotifyStatusUpdated();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RevokeAccess(Guid userId)
|
|
||||||
{
|
|
||||||
var user = _users[userId];
|
|
||||||
user.Active = false;
|
|
||||||
user.LatestResponse = null;
|
|
||||||
_accessService.RevokeAccess(userId.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RevokeAllAccess()
|
|
||||||
{
|
|
||||||
foreach (var user in _users.Values)
|
|
||||||
{
|
|
||||||
RevokeAccess(user.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Reset()
|
|
||||||
{
|
|
||||||
RevokeAllAccess();
|
|
||||||
_users = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
using AccessQueuePlayground.Models;
|
|
||||||
using AccessQueueService.Models;
|
|
||||||
|
|
||||||
namespace AccessQueuePlayground.Services
|
|
||||||
{
|
|
||||||
public interface IAccessQueueManager
|
|
||||||
{
|
|
||||||
public event Action? StatusUpdated;
|
|
||||||
public AccessQueueConfig GetConfig();
|
|
||||||
public void UpdateConfig(AccessQueueConfig config);
|
|
||||||
public Task RecalculateStatus();
|
|
||||||
public AccessQueueManagerStatus GetStatus();
|
|
||||||
public Guid AddUser(bool isActive);
|
|
||||||
public void SetUserActive(Guid userId, bool isActive);
|
|
||||||
public void RevokeAccess(Guid userId);
|
|
||||||
public void RevokeAllAccess();
|
|
||||||
public void Reset();
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AccessQueue": {
|
|
||||||
"CapacityLimit": 3,
|
|
||||||
"ActivitySeconds": 2,
|
|
||||||
"ExpirationSeconds": 10,
|
|
||||||
"RollingExpiration": true
|
|
||||||
},
|
|
||||||
"AccessQueuePlayground": {
|
|
||||||
"RefreshRateMilliseconds": 200,
|
|
||||||
"ServiceUrl": "https://localhost:7291/"
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
h1:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valid.modified:not([type=checkbox]) {
|
|
||||||
outline: 1px solid #26b050;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalid {
|
|
||||||
outline: 1px solid #e50000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-message {
|
|
||||||
color: #e50000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blazor-error-boundary {
|
|
||||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
|
||||||
padding: 1rem 1rem 1rem 3.7rem;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blazor-error-boundary::after {
|
|
||||||
content: "An error has occurred."
|
|
||||||
}
|
|
||||||
|
|
||||||
.darker-border-checkbox.form-check-input {
|
|
||||||
border-color: #929292;
|
|
||||||
}
|
|
|
@ -7,8 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueService", "Acces
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueServiceTests", "AccessQueueServiceTests\AccessQueueServiceTests.csproj", "{1DF48A19-A2B3-4B0C-B726-E65B8E023760}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueServiceTests", "AccessQueueServiceTests\AccessQueueServiceTests.csproj", "{1DF48A19-A2B3-4B0C-B726-E65B8E023760}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueuePlayground", "AccessQueuePlayground\AccessQueuePlayground.csproj", "{65D5E841-7B02-4A55-89C6-12082FA1BCAF}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
@ -23,10 +21,6 @@ Global
|
||||||
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.Build.0 = Release|Any CPU
|
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
|
@ -7,15 +7,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<InternalsVisibleTo Include="AccessQueueServiceTests" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
@AccessQueueService_HostAddress = http://localhost:5199
|
||||||
|
|
||||||
|
GET {{AccessQueueService_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
|
@ -17,14 +17,14 @@ namespace AccessQueueService.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("{id}")]
|
[Route("{id}")]
|
||||||
public async Task<AccessResponse> Get(string id)
|
public async Task<AccessResponse> Get(Guid id)
|
||||||
{
|
{
|
||||||
return await _accessService.RequestAccess(id);
|
return await _accessService.RequestAccess(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
[Route("{id}")]
|
[Route("{id}")]
|
||||||
public async Task<bool> Delete(string id)
|
public async Task<bool> Delete(Guid id)
|
||||||
{
|
{
|
||||||
return await _accessService.RevokeAccess(id);
|
return await _accessService.RevokeAccess(id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
using AccessQueueService.Models;
|
|
||||||
using AccessQueueService.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace AccessQueueService.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("config")]
|
|
||||||
public class ConfigurationController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IAccessService _accessService;
|
|
||||||
|
|
||||||
public ConfigurationController(IAccessService accessService)
|
|
||||||
{
|
|
||||||
_accessService = accessService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
public ActionResult<AccessQueueConfig> GetConfiguration()
|
|
||||||
{
|
|
||||||
return Ok(_accessService.GetConfiguration());
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
public IActionResult UpdateConfiguration([FromBody] AccessQueueConfig config)
|
|
||||||
{
|
|
||||||
_accessService.PatchConfiguration(config);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
using AccessQueueService.Models;
|
|
||||||
using AccessQueueService.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace AccessQueueService.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("status")]
|
|
||||||
public class StatusController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IAccessService _accessService;
|
|
||||||
|
|
||||||
public StatusController(IAccessService accessService)
|
|
||||||
{
|
|
||||||
_accessService = accessService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
public ActionResult<AccessQueueStatus> GetStatus()
|
|
||||||
{
|
|
||||||
return Ok(_accessService.Status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
using AccessQueueService.Models;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace AccessQueueService.Data
|
||||||
|
{
|
||||||
|
public class DictionaryAccessQueueRepo : IAccessQueueRepo
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Guid, AccessTicket> _accessTickets = new();
|
||||||
|
private readonly Queue<AccessTicket> _accessQueue = new();
|
||||||
|
|
||||||
|
public int GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow);
|
||||||
|
public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets
|
||||||
|
.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive >activeCutoff);
|
||||||
|
public int GetQueueCount() => _accessQueue.Count;
|
||||||
|
public int GetRequestsAhead(Guid userId)
|
||||||
|
{
|
||||||
|
var index = 0;
|
||||||
|
foreach (var ticket in _accessQueue)
|
||||||
|
{
|
||||||
|
if (ticket.UserId == userId)
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Enqueue(AccessTicket ticket)
|
||||||
|
{
|
||||||
|
_accessQueue.Enqueue(ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int DeleteExpiredTickets()
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow;
|
||||||
|
var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < cutoff);
|
||||||
|
int count = 0;
|
||||||
|
foreach (var ticket in expiredTickets)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
_accessTickets.Remove(ticket.Key);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveUser(Guid userId)
|
||||||
|
{
|
||||||
|
_accessTickets.Remove(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var activeCutoff = now.AddSeconds(-activeSeconds);
|
||||||
|
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff);
|
||||||
|
var openSpots = capacityLimit - numberOfActiveUsers;
|
||||||
|
int filledSpots = 0;
|
||||||
|
while (filledSpots < openSpots)
|
||||||
|
{
|
||||||
|
if (_accessQueue.TryDequeue(out var nextUser))
|
||||||
|
{
|
||||||
|
if (nextUser.LastActive < activeCutoff)
|
||||||
|
{
|
||||||
|
// User is inactive, throw away their ticket
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_accessTickets[nextUser.UserId] = new AccessTicket
|
||||||
|
{
|
||||||
|
UserId = nextUser.UserId,
|
||||||
|
ExpiresOn = now.AddSeconds(expirationSeconds),
|
||||||
|
LastActive = now
|
||||||
|
};
|
||||||
|
filledSpots++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filledSpots == openSpots;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccessTicket? GetTicket(Guid userId)
|
||||||
|
{
|
||||||
|
return _accessTickets.TryGetValue(userId, out var ticket) ? ticket : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpsertTicket(AccessTicket ticket)
|
||||||
|
{
|
||||||
|
_accessTickets[ticket.UserId] = ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IAccessQueueRepo.RemoveUser(Guid userId)
|
||||||
|
{
|
||||||
|
return _accessTickets.Remove(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,22 @@
|
||||||
using System.Runtime.Serialization;
|
using AccessQueueService.Models;
|
||||||
using AccessQueueService.Models;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace AccessQueueService.Data
|
namespace AccessQueueService.Data
|
||||||
{
|
{
|
||||||
public interface IAccessQueueRepo
|
public interface IAccessQueueRepo
|
||||||
{
|
{
|
||||||
public string ToState();
|
|
||||||
public int GetUnexpiredTicketsCount();
|
public int GetUnexpiredTicketsCount();
|
||||||
public int GetActiveTicketsCount(DateTime activeCutoff);
|
public int GetActiveTicketsCount(DateTime activeCutoff);
|
||||||
public int GetQueueCount();
|
public int GetQueueCount();
|
||||||
public AccessTicket? GetTicket(string userId);
|
public AccessTicket? GetTicket(Guid userId);
|
||||||
public void UpsertTicket(AccessTicket ticket);
|
public void UpsertTicket(AccessTicket ticket);
|
||||||
public int GetRequestsAhead(string userId);
|
public int GetRequestsAhead(Guid userId);
|
||||||
public void Enqueue(AccessTicket ticket);
|
public void Enqueue(AccessTicket ticket);
|
||||||
public int DeleteExpiredTickets();
|
public int DeleteExpiredTickets();
|
||||||
public bool RemoveUser(string userId);
|
public bool RemoveUser(Guid userId);
|
||||||
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit);
|
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,45 +1,33 @@
|
||||||
using System.Collections.Concurrent;
|
using AccessQueueService.Models;
|
||||||
using System.Runtime.Serialization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using AccessQueueService.Models;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace AccessQueueService.Data
|
namespace AccessQueueService.Data
|
||||||
{
|
{
|
||||||
public class TakeANumberAccessQueueRepo : IAccessQueueRepo
|
public class TakeANumberAccessQueueRepo : IAccessQueueRepo
|
||||||
{
|
{
|
||||||
private ConcurrentDictionary<string, AccessTicket> _accessTickets = new();
|
private readonly Dictionary<Guid, AccessTicket> _accessTickets = [];
|
||||||
private ConcurrentDictionary<string, ulong> _queueNumbers = new();
|
private readonly Dictionary<Guid, ulong> _queueNumbers = [];
|
||||||
private ConcurrentDictionary<ulong, AccessTicket> _accessQueue = new();
|
private readonly Dictionary<ulong, AccessTicket> _accessQueue = [];
|
||||||
|
|
||||||
internal ulong _nowServing = 0;
|
private ulong _nowServing = 0;
|
||||||
internal ulong _nextUnusedTicket = 0;
|
private ulong _nextUnusedTicket = 0;
|
||||||
|
|
||||||
public int GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow);
|
public int GetUnexpiredTicketsCount() => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow);
|
||||||
public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets
|
public int GetActiveTicketsCount(DateTime activeCutoff) => _accessTickets
|
||||||
.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive > activeCutoff);
|
.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive > activeCutoff);
|
||||||
public int GetQueueCount() => (int)(_nextUnusedTicket - _nowServing);
|
public int GetQueueCount() => (int)(_nextUnusedTicket - _nowServing);
|
||||||
public int GetRequestsAhead(string userId)
|
public int GetRequestsAhead(Guid userId)
|
||||||
{
|
{
|
||||||
if(_queueNumbers.TryGetValue(userId, out var queueNumber))
|
if(_queueNumbers.TryGetValue(userId, out var queueNumber))
|
||||||
{
|
{
|
||||||
if (_accessQueue.TryGetValue(queueNumber, out var ticket))
|
|
||||||
{
|
|
||||||
ticket.LastActive = DateTime.UtcNow;
|
|
||||||
return queueNumber >= _nowServing ? (int)(queueNumber - _nowServing) : -1;
|
return queueNumber >= _nowServing ? (int)(queueNumber - _nowServing) : -1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return -1;
|
return -1;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Enqueue(AccessTicket ticket)
|
public void Enqueue(AccessTicket ticket)
|
||||||
{
|
{
|
||||||
if (_nextUnusedTicket >= long.MaxValue)
|
|
||||||
{
|
|
||||||
// Prevent overflow
|
|
||||||
Optimize();
|
|
||||||
}
|
|
||||||
_queueNumbers[ticket.UserId] = _nextUnusedTicket;
|
_queueNumbers[ticket.UserId] = _nextUnusedTicket;
|
||||||
_accessQueue[_nextUnusedTicket] = ticket;
|
_accessQueue[_nextUnusedTicket] = ticket;
|
||||||
_nextUnusedTicket++;
|
_nextUnusedTicket++;
|
||||||
|
@ -48,36 +36,36 @@ namespace AccessQueueService.Data
|
||||||
public int DeleteExpiredTickets()
|
public int DeleteExpiredTickets()
|
||||||
{
|
{
|
||||||
var cutoff = DateTime.UtcNow;
|
var cutoff = DateTime.UtcNow;
|
||||||
var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < cutoff).ToList();
|
var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < cutoff);
|
||||||
int count = 0;
|
int count = 0;
|
||||||
foreach (var ticket in expiredTickets)
|
foreach (var ticket in expiredTickets)
|
||||||
{
|
{
|
||||||
count++;
|
count++;
|
||||||
_accessTickets.TryRemove(ticket.Key, out _);
|
_accessTickets.Remove(ticket.Key);
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RemoveUser(Guid userId)
|
||||||
|
{
|
||||||
|
_accessTickets.Remove(userId);
|
||||||
|
}
|
||||||
|
|
||||||
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit)
|
public bool DidDequeueUntilFull(int activeSeconds, int expirationSeconds, int capacityLimit)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var activeCutoff = now.AddSeconds(-activeSeconds);
|
var activeCutoff = now.AddSeconds(-activeSeconds);
|
||||||
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff);
|
var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > now && t.Value.LastActive > activeCutoff);
|
||||||
var openSpots = capacityLimit - numberOfActiveUsers;
|
var openSpots = capacityLimit - numberOfActiveUsers;
|
||||||
if (openSpots <= 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
int filledSpots = 0;
|
int filledSpots = 0;
|
||||||
while (filledSpots < openSpots && _nowServing < _nextUnusedTicket)
|
while (filledSpots < openSpots && _nowServing < _nextUnusedTicket)
|
||||||
{
|
{
|
||||||
if (_accessQueue.TryRemove(_nowServing, out var nextUser))
|
if (_accessQueue.TryGetValue(_nowServing, out var nextUser))
|
||||||
{
|
{
|
||||||
_queueNumbers.TryRemove(nextUser.UserId, out _);
|
_nowServing++;
|
||||||
if (nextUser.LastActive < activeCutoff)
|
if (nextUser.LastActive < activeCutoff)
|
||||||
{
|
{
|
||||||
// User is inactive, throw away their ticket
|
// User is inactive, throw away their ticket
|
||||||
_nowServing++;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_accessTickets[nextUser.UserId] = new AccessTicket
|
_accessTickets[nextUser.UserId] = new AccessTicket
|
||||||
|
@ -88,12 +76,15 @@ namespace AccessQueueService.Data
|
||||||
};
|
};
|
||||||
filledSpots++;
|
filledSpots++;
|
||||||
}
|
}
|
||||||
_nowServing++;
|
else
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return filledSpots == openSpots;
|
return filledSpots == openSpots;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccessTicket? GetTicket(string userId)
|
public AccessTicket? GetTicket(Guid userId)
|
||||||
{
|
{
|
||||||
return _accessTickets.TryGetValue(userId, out var ticket) ? ticket : null;
|
return _accessTickets.TryGetValue(userId, out var ticket) ? ticket : null;
|
||||||
}
|
}
|
||||||
|
@ -103,77 +94,9 @@ namespace AccessQueueService.Data
|
||||||
_accessTickets[ticket.UserId] = ticket;
|
_accessTickets[ticket.UserId] = ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool RemoveUser(string userId)
|
bool IAccessQueueRepo.RemoveUser(Guid userId)
|
||||||
{
|
{
|
||||||
if (_queueNumbers.TryRemove(userId, out var queueNumber))
|
return _accessTickets.Remove(userId);
|
||||||
{
|
|
||||||
_accessQueue.TryRemove(queueNumber, out _);
|
|
||||||
}
|
|
||||||
return _accessTickets.TryRemove(userId, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void Optimize()
|
|
||||||
{
|
|
||||||
var newQueue = new ConcurrentDictionary<ulong, AccessTicket>();
|
|
||||||
var newQueueNumbers = new ConcurrentDictionary<string, ulong>();
|
|
||||||
ulong newIndex = 0;
|
|
||||||
for (ulong i = _nowServing; i < _nextUnusedTicket; i++)
|
|
||||||
{
|
|
||||||
if (_accessQueue.TryGetValue(i, out var user))
|
|
||||||
{
|
|
||||||
newQueue[newIndex] = user;
|
|
||||||
newQueueNumbers[user.UserId] = newIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_accessQueue = newQueue;
|
|
||||||
_queueNumbers = newQueueNumbers;
|
|
||||||
_nowServing = 0;
|
|
||||||
_nextUnusedTicket = newIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ToState()
|
|
||||||
{
|
|
||||||
var state = new TakeANumberAccessQueueRepoState
|
|
||||||
{
|
|
||||||
AccessTickets = new Dictionary<string, AccessTicket>(_accessTickets),
|
|
||||||
AccessQueue = new Dictionary<ulong, AccessTicket>(_accessQueue),
|
|
||||||
};
|
|
||||||
return JsonSerializer.Serialize(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TakeANumberAccessQueueRepo FromState(string stateJson)
|
|
||||||
{
|
|
||||||
var state = JsonSerializer.Deserialize<TakeANumberAccessQueueRepoState?>(stateJson);
|
|
||||||
if (state?.AccessTickets == null || state?.AccessQueue == null)
|
|
||||||
{
|
|
||||||
return new();
|
|
||||||
}
|
|
||||||
|
|
||||||
var _accessTickets = new ConcurrentDictionary<string, AccessTicket>(state.AccessTickets);
|
|
||||||
var _accessQueue = new ConcurrentDictionary<ulong, AccessTicket>(state.AccessQueue);
|
|
||||||
var _nextUnusedTicket = 0ul;
|
|
||||||
var _nowServing = ulong.MaxValue;
|
|
||||||
var _queueNumbers = new ConcurrentDictionary<string, ulong>();
|
|
||||||
foreach (var queueItem in state.AccessQueue)
|
|
||||||
{
|
|
||||||
_queueNumbers[queueItem.Value.UserId] = queueItem.Key;
|
|
||||||
_nextUnusedTicket = Math.Max(_nextUnusedTicket, queueItem.Key + 1);
|
|
||||||
_nowServing = Math.Min(_nowServing, queueItem.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_nowServing == ulong.MaxValue)
|
|
||||||
{
|
|
||||||
_nowServing = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new()
|
|
||||||
{
|
|
||||||
_accessQueue = _accessQueue,
|
|
||||||
_accessTickets = _accessTickets,
|
|
||||||
_nextUnusedTicket = _nextUnusedTicket,
|
|
||||||
_nowServing = _nowServing,
|
|
||||||
_queueNumbers = _queueNumbers
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
namespace AccessQueueService.Models
|
|
||||||
{
|
|
||||||
public class AccessQueueConfig
|
|
||||||
{
|
|
||||||
public int? CapacityLimit { get; set; }
|
|
||||||
public int? ActivitySeconds { get; set; }
|
|
||||||
public int? ExpirationSeconds { get; set; }
|
|
||||||
public bool? RollingExpiration { get; set; }
|
|
||||||
|
|
||||||
public AccessQueueConfig Clone()
|
|
||||||
{
|
|
||||||
return (AccessQueueConfig)this.MemberwiseClone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
using AccessQueueService.Data;
|
|
||||||
|
|
||||||
namespace AccessQueueService.Models
|
|
||||||
{
|
|
||||||
public class AccessQueueStatus
|
|
||||||
{
|
|
||||||
public int UnexpiredTicketsCount { get; internal set; }
|
|
||||||
public int ActiveTicketsCount { get; internal set; }
|
|
||||||
public int QueueCount { get; internal set; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,7 +2,7 @@
|
||||||
{
|
{
|
||||||
public class AccessTicket
|
public class AccessTicket
|
||||||
{
|
{
|
||||||
public string UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public DateTime ExpiresOn { get; set; }
|
public DateTime ExpiresOn { get; set; }
|
||||||
public DateTime LastActive { get; set; }
|
public DateTime LastActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
namespace AccessQueueService.Models
|
|
||||||
{
|
|
||||||
public class TakeANumberAccessQueueRepoState
|
|
||||||
{
|
|
||||||
public Dictionary<string, AccessTicket> AccessTickets { get; set; } = [];
|
|
||||||
public Dictionary<ulong, AccessTicket> AccessQueue { get; set; } = [];
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,44 +1,21 @@
|
||||||
using AccessQueueService.Data;
|
using AccessQueueService.Data;
|
||||||
using AccessQueueService.Services;
|
using AccessQueueService.Services;
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add Serilog configuration from appsettings and serilog.json
|
// Add services to the container.
|
||||||
builder.Host.UseSerilog((context, services, configuration) =>
|
|
||||||
{
|
|
||||||
configuration
|
|
||||||
.ReadFrom.Configuration(context.Configuration)
|
|
||||||
.ReadFrom.Services(services)
|
|
||||||
.Enrich.FromLogContext();
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
builder.Services.AddSingleton<IAccessService, AccessService>();
|
builder.Services.AddSingleton<IAccessService, AccessService>();
|
||||||
builder.Services.AddSingleton<IAccessQueueRepo>(sp =>
|
builder.Services.AddSingleton<IAccessQueueRepo, DictionaryAccessQueueRepo>();
|
||||||
{
|
|
||||||
string? filePath = builder.Configuration.GetValue<string>("AccessQueue:BackupFilePath");
|
|
||||||
if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(filePath);
|
|
||||||
return TakeANumberAccessQueueRepo.FromState(json);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Failed to load state from {filePath}. Error message: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new TakeANumberAccessQueueRepo();
|
|
||||||
});
|
|
||||||
builder.Services.AddHostedService<AccessCleanupBackgroundService>();
|
|
||||||
builder.Services.AddHostedService<AccessQueueSerializerService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
namespace AccessQueueService.Services
|
|
||||||
{
|
|
||||||
public class AccessCleanupBackgroundService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly IAccessService _accessService;
|
|
||||||
private readonly IConfiguration _config;
|
|
||||||
private readonly ILogger<AccessCleanupBackgroundService> _logger;
|
|
||||||
|
|
||||||
public AccessCleanupBackgroundService(IAccessService accessService, IConfiguration config, ILogger<AccessCleanupBackgroundService> logger)
|
|
||||||
{
|
|
||||||
_accessService = accessService;
|
|
||||||
_config = config;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
var cleanupIntervalMillis = _config.GetValue<int>("AccessQueue:CleanupIntervalSeconds") * 1000;
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var removed = await _accessService.DeleteExpiredTickets();
|
|
||||||
if (removed > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Background cleanup removed {Count} expired tickets.", removed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Exception occurred during background cleanup.");
|
|
||||||
}
|
|
||||||
await Task.Delay(cleanupIntervalMillis, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
using System.Text.Json;
|
|
||||||
using AccessQueueService.Data;
|
|
||||||
|
|
||||||
namespace AccessQueueService.Services
|
|
||||||
{
|
|
||||||
public class AccessQueueSerializerService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly IAccessQueueRepo _accessRepo;
|
|
||||||
private readonly IConfiguration _config;
|
|
||||||
private readonly ILogger<AccessQueueSerializerService> _logger;
|
|
||||||
|
|
||||||
public AccessQueueSerializerService(IAccessQueueRepo accessRepo, IConfiguration config, ILogger<AccessQueueSerializerService> logger)
|
|
||||||
{
|
|
||||||
_accessRepo = accessRepo;
|
|
||||||
_config = config;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
var backupIntervalSeconds = _config.GetValue<int>("AccessQueue:BackupIntervalSeconds") * 1000;
|
|
||||||
var backupPath = _config.GetValue<string>("AccessQueue:BackupFilePath");
|
|
||||||
if (backupIntervalSeconds == 0 || string.IsNullOrEmpty(backupPath))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Writing backup to {backupPath}");
|
|
||||||
var stateJson = _accessRepo.ToState();
|
|
||||||
File.WriteAllText(backupPath, stateJson);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Exception occurred while trying to write state in background backup service.");
|
|
||||||
}
|
|
||||||
await Task.Delay(backupIntervalSeconds, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,5 @@
|
||||||
using System.Threading.Tasks;
|
using AccessQueueService.Data;
|
||||||
using AccessQueueService.Data;
|
|
||||||
using AccessQueueService.Models;
|
using AccessQueueService.Models;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace AccessQueueService.Services
|
namespace AccessQueueService.Services
|
||||||
{
|
{
|
||||||
|
@ -9,62 +7,39 @@ namespace AccessQueueService.Services
|
||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IAccessQueueRepo _accessQueueRepo;
|
private readonly IAccessQueueRepo _accessQueueRepo;
|
||||||
private readonly ILogger<AccessService> _logger;
|
|
||||||
|
|
||||||
|
//private readonly Dictionary<Guid, AccessTicket> _accessTickets = new();
|
||||||
|
//private readonly Queue<AccessTicket> _accessQueue = new();
|
||||||
private readonly SemaphoreSlim _queueLock = new(1, 1);
|
private readonly SemaphoreSlim _queueLock = new(1, 1);
|
||||||
private AccessQueueConfig _config;
|
private readonly int EXP_SECONDS;
|
||||||
public AccessService(IConfiguration configuration, IAccessQueueRepo accessQueueRepo, ILogger<AccessService> logger)
|
private readonly int ACT_SECONDS;
|
||||||
|
private readonly int CAPACITY_LIMIT;
|
||||||
|
private readonly bool ROLLING_EXPIRATION;
|
||||||
|
public AccessService(IConfiguration configuration, IAccessQueueRepo accessQueueRepo)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_accessQueueRepo = accessQueueRepo;
|
_accessQueueRepo = accessQueueRepo;
|
||||||
_logger = logger;
|
EXP_SECONDS = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds");
|
||||||
_config = new AccessQueueConfig
|
ACT_SECONDS = _configuration.GetValue<int>("AccessQueue:ActivitySeconds");
|
||||||
{
|
CAPACITY_LIMIT = _configuration.GetValue<int>("AccessQueue:CapacityLimit");
|
||||||
ExpirationSeconds = _configuration.GetValue<int>("AccessQueue:ExpirationSeconds"),
|
ROLLING_EXPIRATION = _configuration.GetValue<bool>("AccessQueue:RollingExpiration");
|
||||||
ActivitySeconds = _configuration.GetValue<int>("AccessQueue:ActivitySeconds"),
|
|
||||||
CapacityLimit = _configuration.GetValue<int>("AccessQueue:CapacityLimit"),
|
|
||||||
RollingExpiration = _configuration.GetValue<bool>("AccessQueue:RollingExpiration")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
public AccessQueueConfig GetConfiguration() => _config.Clone();
|
|
||||||
public void UpdateConfiguration(AccessQueueConfig config)
|
|
||||||
{
|
|
||||||
_config = config.Clone();
|
|
||||||
}
|
|
||||||
public void PatchConfiguration(AccessQueueConfig partialConfig)
|
|
||||||
{
|
|
||||||
if (partialConfig.CapacityLimit.HasValue) _config.CapacityLimit = partialConfig.CapacityLimit.Value;
|
|
||||||
if (partialConfig.ActivitySeconds.HasValue) _config.ActivitySeconds = partialConfig.ActivitySeconds.Value;
|
|
||||||
if (partialConfig.ExpirationSeconds.HasValue) _config.ExpirationSeconds = partialConfig.ExpirationSeconds.Value;
|
|
||||||
if (partialConfig.RollingExpiration.HasValue) _config.RollingExpiration = partialConfig.RollingExpiration.Value;
|
|
||||||
}
|
}
|
||||||
public int UnexpiredTicketsCount => _accessQueueRepo.GetUnexpiredTicketsCount();
|
public int UnexpiredTicketsCount => _accessQueueRepo.GetUnexpiredTicketsCount();
|
||||||
public int ActiveTicketsCount => _accessQueueRepo.GetActiveTicketsCount(DateTime.UtcNow.AddSeconds(-_config.ActivitySeconds.Value));
|
public int ActiveTicketsCount => _accessQueueRepo.GetActiveTicketsCount(DateTime.UtcNow.AddSeconds(-_configuration.GetValue<int>("AccessQueue:ActivitySeconds")));
|
||||||
public int QueueCount => _accessQueueRepo.GetQueueCount();
|
public int QueueCount => _accessQueueRepo.GetQueueCount();
|
||||||
public AccessQueueStatus Status => new()
|
public async Task<AccessResponse> RequestAccess(Guid userId)
|
||||||
{
|
|
||||||
ActiveTicketsCount = this.ActiveTicketsCount,
|
|
||||||
QueueCount = this.QueueCount,
|
|
||||||
UnexpiredTicketsCount = this.UnexpiredTicketsCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
public async Task<AccessResponse> RequestAccess(string userId)
|
|
||||||
{
|
{
|
||||||
await _queueLock.WaitAsync();
|
await _queueLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(
|
var hasCapacity = !_accessQueueRepo.DidDequeueUntilFull(ACT_SECONDS, EXP_SECONDS, CAPACITY_LIMIT);
|
||||||
_config.ActivitySeconds.Value,
|
|
||||||
_config.ExpirationSeconds.Value,
|
|
||||||
_config.CapacityLimit.Value);
|
|
||||||
var existingTicket = _accessQueueRepo.GetTicket(userId);
|
var existingTicket = _accessQueueRepo.GetTicket(userId);
|
||||||
if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow)
|
if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow)
|
||||||
{
|
{
|
||||||
// Already has access
|
|
||||||
var expiresOn = existingTicket.ExpiresOn;
|
var expiresOn = existingTicket.ExpiresOn;
|
||||||
if (_config.RollingExpiration.Value)
|
if (ROLLING_EXPIRATION)
|
||||||
{
|
{
|
||||||
expiresOn = DateTime.UtcNow.AddSeconds(_config.ExpirationSeconds.Value);
|
expiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS);
|
||||||
}
|
}
|
||||||
_accessQueueRepo.UpsertTicket(new AccessTicket
|
_accessQueueRepo.UpsertTicket(new AccessTicket
|
||||||
{
|
{
|
||||||
|
@ -72,7 +47,6 @@ namespace AccessQueueService.Services
|
||||||
ExpiresOn = expiresOn,
|
ExpiresOn = expiresOn,
|
||||||
LastActive = DateTime.UtcNow
|
LastActive = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
_logger.LogInformation("User {UserId} already has access. Expires on {ExpiresOn}.", userId, expiresOn);
|
|
||||||
return new AccessResponse
|
return new AccessResponse
|
||||||
{
|
{
|
||||||
ExpiresOn = expiresOn
|
ExpiresOn = expiresOn
|
||||||
|
@ -80,15 +54,13 @@ namespace AccessQueueService.Services
|
||||||
}
|
}
|
||||||
if (hasCapacity)
|
if (hasCapacity)
|
||||||
{
|
{
|
||||||
// Doesn't have access, but there's space available
|
|
||||||
var accessTicket = new AccessTicket
|
var accessTicket = new AccessTicket
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
ExpiresOn = DateTime.UtcNow.AddSeconds(_config.ExpirationSeconds.Value),
|
ExpiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS),
|
||||||
LastActive = DateTime.UtcNow
|
LastActive = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_accessQueueRepo.UpsertTicket(accessTicket);
|
_accessQueueRepo.UpsertTicket(accessTicket);
|
||||||
_logger.LogInformation("User {UserId} granted access. Expires on {ExpiresOn}.", userId, accessTicket.ExpiresOn);
|
|
||||||
return new AccessResponse
|
return new AccessResponse
|
||||||
{
|
{
|
||||||
ExpiresOn = accessTicket.ExpiresOn,
|
ExpiresOn = accessTicket.ExpiresOn,
|
||||||
|
@ -96,30 +68,35 @@ namespace AccessQueueService.Services
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No access and no space, add to queue
|
|
||||||
var requestsAhead = _accessQueueRepo.GetRequestsAhead(userId);
|
var requestsAhead = _accessQueueRepo.GetRequestsAhead(userId);
|
||||||
if (requestsAhead == -1)
|
if (requestsAhead == -1)
|
||||||
{
|
{
|
||||||
requestsAhead = _accessQueueRepo.GetQueueCount();
|
|
||||||
_accessQueueRepo.Enqueue(new AccessTicket
|
_accessQueueRepo.Enqueue(new AccessTicket
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
LastActive = DateTime.UtcNow,
|
LastActive = DateTime.UtcNow,
|
||||||
ExpiresOn = DateTime.MaxValue,
|
ExpiresOn = DateTime.MaxValue,
|
||||||
});
|
});
|
||||||
_logger.LogInformation("User {UserId} added to queue. Requests ahead: {RequestsAhead}.", userId, requestsAhead);
|
|
||||||
}
|
}
|
||||||
return new AccessResponse
|
return new AccessResponse
|
||||||
{
|
{
|
||||||
ExpiresOn = null,
|
ExpiresOn = null,
|
||||||
RequestsAhead = requestsAhead
|
RequestsAhead = _accessQueueRepo.GetQueueCount() - 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
finally
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Exception occurred while processing access request for user {UserId}.", userId);
|
_queueLock.Release();
|
||||||
throw;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeAccess(Guid userId)
|
||||||
|
{
|
||||||
|
await _queueLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _accessQueueRepo.RemoveUser(userId);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
@ -127,50 +104,6 @@ namespace AccessQueueService.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> RevokeAccess(string userId)
|
public int DeleteExpiredTickets() => _accessQueueRepo.DeleteExpiredTickets();
|
||||||
{
|
|
||||||
await _queueLock.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var removed = _accessQueueRepo.RemoveUser(userId);
|
|
||||||
if (removed)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("User {UserId} access revoked.", userId);
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Exception occurred while revoking access for user {UserId}.", userId);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_queueLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> DeleteExpiredTickets()
|
|
||||||
{
|
|
||||||
await _queueLock.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var removed = _accessQueueRepo.DeleteExpiredTickets();
|
|
||||||
if (removed > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Cleaned up {Count} expired tickets.", removed);
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Exception occurred during expired ticket cleanup.");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_queueLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,8 @@ namespace AccessQueueService.Services
|
||||||
{
|
{
|
||||||
public interface IAccessService
|
public interface IAccessService
|
||||||
{
|
{
|
||||||
public Task<AccessResponse> RequestAccess(string userId);
|
public Task<AccessResponse> RequestAccess(Guid userId);
|
||||||
public Task<bool> RevokeAccess(string userId);
|
public Task<bool> RevokeAccess(Guid userId);
|
||||||
public Task<int> DeleteExpiredTickets();
|
public int DeleteExpiredTickets();
|
||||||
public AccessQueueConfig GetConfiguration();
|
|
||||||
public void UpdateConfiguration(AccessQueueConfig config);
|
|
||||||
public void PatchConfiguration(AccessQueueConfig partialConfig);
|
|
||||||
public AccessQueueStatus Status { get; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,33 +4,12 @@
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Serilog": {
|
|
||||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
|
||||||
"MinimumLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Override": {
|
|
||||||
"Microsoft": "Warning",
|
|
||||||
"System": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"WriteTo": [
|
|
||||||
{ "Name": "Console" },
|
|
||||||
{ "Name": "File", "Args": { "path": "Logs/log-.txt", "rollingInterval": "Day", "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" } }
|
|
||||||
],
|
|
||||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
|
|
||||||
"Properties": {
|
|
||||||
"Application": "AccessQueueService"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"AccessQueue": {
|
"AccessQueue": {
|
||||||
"CapacityLimit": 100,
|
"CapacityLimit": 100, // Maximum number of active users
|
||||||
"ActivitySeconds": 900,
|
"ActivitySeconds": 900, // Time since last access before a user is considered inactive
|
||||||
"ExpirationSeconds": 43200,
|
"ExpirationSeconds": 43200, // 12 hours - Time before a user access is revoked
|
||||||
"RollingExpiration": true,
|
"RollingExpiration": true // Whether to extend expiration time on access
|
||||||
"CleanupIntervalSeconds": 60,
|
|
||||||
"BackupFilePath": "Logs\\backup.json",
|
|
||||||
"BackupIntervalSeconds": 5
|
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"Serilog": {
|
|
||||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
|
||||||
"MinimumLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Override": {
|
|
||||||
"Microsoft": "Warning",
|
|
||||||
"System": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"WriteTo": [
|
|
||||||
{ "Name": "Console" },
|
|
||||||
{ "Name": "File", "Args": { "path": "Logs/log-.txt", "rollingInterval": "Day", "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" } }
|
|
||||||
],
|
|
||||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
|
|
||||||
"Properties": {
|
|
||||||
"Application": "AccessQueueService"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,285 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using AccessQueueService.Data;
|
|
||||||
using AccessQueueService.Models;
|
|
||||||
using AccessQueueService.Services;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace AccessQueueServiceTests
|
|
||||||
{
|
|
||||||
public class AccessQueueRepoTests
|
|
||||||
{
|
|
||||||
private readonly TakeANumberAccessQueueRepo _repo;
|
|
||||||
|
|
||||||
public AccessQueueRepoTests()
|
|
||||||
{
|
|
||||||
_repo = new TakeANumberAccessQueueRepo();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetUnexpiredTicketsCount_ReturnsCorrectCount()
|
|
||||||
{
|
|
||||||
_repo.UpsertTicket(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow });
|
|
||||||
_repo.UpsertTicket(new AccessTicket { UserId = "b", ExpiresOn = DateTime.UtcNow.AddMinutes(-1), LastActive = DateTime.UtcNow });
|
|
||||||
Assert.Equal(1, _repo.GetUnexpiredTicketsCount());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetActiveTicketsCount_ReturnsCorrectCount()
|
|
||||||
{
|
|
||||||
|
|
||||||
var activeCutoff = DateTime.UtcNow.AddMinutes(-5);
|
|
||||||
_repo.UpsertTicket(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow });
|
|
||||||
_repo.UpsertTicket(new AccessTicket { UserId = "b", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow.AddMinutes(-10) });
|
|
||||||
Assert.Equal(1, _repo.GetActiveTicketsCount(activeCutoff));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetQueueCount_ReturnsCorrectCount()
|
|
||||||
{
|
|
||||||
|
|
||||||
Assert.Equal(0, _repo.GetQueueCount());
|
|
||||||
_repo.Enqueue(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow });
|
|
||||||
Assert.Equal(1, _repo.GetQueueCount());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetRequestsAhead_ReturnsMinusOneIfUserNotInQueue()
|
|
||||||
{
|
|
||||||
|
|
||||||
Assert.Equal(-1, _repo.GetRequestsAhead("notfound"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetRequestsAhead_ReturnsCorrectNumber()
|
|
||||||
{
|
|
||||||
|
|
||||||
var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(ticket);
|
|
||||||
Assert.Equal(0, _repo.GetRequestsAhead("a"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Enqueue_AddsTicketToQueue()
|
|
||||||
{
|
|
||||||
|
|
||||||
var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(ticket);
|
|
||||||
Assert.Equal(1, _repo.GetQueueCount());
|
|
||||||
Assert.Equal(0, _repo.GetRequestsAhead("a"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DeleteExpiredTickets_RemovesExpiredTickets()
|
|
||||||
{
|
|
||||||
|
|
||||||
_repo.UpsertTicket(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(-1), LastActive = DateTime.UtcNow });
|
|
||||||
_repo.UpsertTicket(new AccessTicket { UserId = "b", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow });
|
|
||||||
int removed = _repo.DeleteExpiredTickets();
|
|
||||||
Assert.Equal(1, removed);
|
|
||||||
Assert.NotNull(_repo.GetTicket("b"));
|
|
||||||
Assert.Null(_repo.GetTicket("a"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DidDequeueUntilFull_FillsOpenSpots()
|
|
||||||
{
|
|
||||||
|
|
||||||
var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(ticket);
|
|
||||||
bool result = _repo.DidDequeueUntilFull(60, 60, 1);
|
|
||||||
Assert.True(result);
|
|
||||||
Assert.NotNull(_repo.GetTicket("a"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DidDequeueUntilFull_ReturnsTrueIfNoOpenSpots()
|
|
||||||
{
|
|
||||||
|
|
||||||
_repo.UpsertTicket(new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow });
|
|
||||||
bool result = _repo.DidDequeueUntilFull(60, 60, 0);
|
|
||||||
Assert.True(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetTicket_ReturnsTicketIfExists()
|
|
||||||
{
|
|
||||||
|
|
||||||
var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
_repo.UpsertTicket(ticket);
|
|
||||||
var found = _repo.GetTicket("a");
|
|
||||||
Assert.NotNull(found);
|
|
||||||
Assert.Equal("a", found.UserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetTicket_ReturnsNullIfNotExists()
|
|
||||||
{
|
|
||||||
|
|
||||||
Assert.Null(_repo.GetTicket("notfound"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void UpsertTicket_AddsOrUpdatesTicket()
|
|
||||||
{
|
|
||||||
|
|
||||||
var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
_repo.UpsertTicket(ticket);
|
|
||||||
Assert.NotNull(_repo.GetTicket("a"));
|
|
||||||
var updated = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
_repo.UpsertTicket(updated);
|
|
||||||
Assert.Equal(updated.ExpiresOn, _repo.GetTicket("a")!.ExpiresOn);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RemoveUser_RemovesFromAllCollections()
|
|
||||||
{
|
|
||||||
|
|
||||||
var ticket = new AccessTicket { UserId = "a", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(ticket);
|
|
||||||
_repo.UpsertTicket(ticket);
|
|
||||||
bool removed = _repo.RemoveUser("a");
|
|
||||||
Assert.True(removed);
|
|
||||||
Assert.Null(_repo.GetTicket("a"));
|
|
||||||
Assert.Equal(-1, _repo.GetRequestsAhead("a"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DidDequeueUntilFull_SkipsInactiveUser()
|
|
||||||
{
|
|
||||||
var inactive = new AccessTicket { UserId = "inactive", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow.AddMinutes(-10) };
|
|
||||||
var active = new AccessTicket { UserId = "active", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(inactive);
|
|
||||||
_repo.Enqueue(active);
|
|
||||||
bool result = _repo.DidDequeueUntilFull(5 * 60, 60, 1);
|
|
||||||
Assert.True(result);
|
|
||||||
Assert.Null(_repo.GetTicket("inactive"));
|
|
||||||
Assert.NotNull(_repo.GetTicket("active"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Enqueue_QueuesUsersInOrder()
|
|
||||||
{
|
|
||||||
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow, LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(ticket1);
|
|
||||||
_repo.Enqueue(ticket2);
|
|
||||||
_repo.Enqueue(ticket3);
|
|
||||||
Assert.Equal(0, _repo.GetRequestsAhead("first"));
|
|
||||||
Assert.Equal(1, _repo.GetRequestsAhead("second"));
|
|
||||||
Assert.Equal(2, _repo.GetRequestsAhead("third"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DidDequeueUntilFull_DequeuesUsersInOrder()
|
|
||||||
{
|
|
||||||
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(ticket1);
|
|
||||||
_repo.Enqueue(ticket2);
|
|
||||||
_repo.Enqueue(ticket3);
|
|
||||||
|
|
||||||
bool result = _repo.DidDequeueUntilFull(60 * 60, 60, 1);
|
|
||||||
|
|
||||||
Assert.True(result);
|
|
||||||
Assert.NotNull(_repo.GetTicket("first"));
|
|
||||||
Assert.Null(_repo.GetTicket("second"));
|
|
||||||
Assert.Null(_repo.GetTicket("third"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Optimize_MaintainsQueueOrder()
|
|
||||||
{
|
|
||||||
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
_repo.Enqueue(ticket1);
|
|
||||||
_repo.Enqueue(ticket2);
|
|
||||||
_repo.Enqueue(ticket3);
|
|
||||||
|
|
||||||
_repo.DidDequeueUntilFull(60 * 60, 60, 1);
|
|
||||||
_repo.Optimize();
|
|
||||||
|
|
||||||
Assert.NotNull(_repo.GetTicket("first"));
|
|
||||||
Assert.Equal(0, _repo.GetRequestsAhead("second"));
|
|
||||||
Assert.Equal(1, _repo.GetRequestsAhead("third"));
|
|
||||||
Assert.Equal(0ul, _repo._nowServing);
|
|
||||||
|
|
||||||
_repo.DidDequeueUntilFull(60 * 60, 60, 2);
|
|
||||||
Assert.NotNull(_repo.GetTicket("second"));
|
|
||||||
Assert.Equal(0, _repo.GetRequestsAhead("third"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Enqueue_MaintainsQueueOrder_WhenMaxValueExceeded()
|
|
||||||
{
|
|
||||||
var ticket1 = new AccessTicket { UserId = "first", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticket2 = new AccessTicket { UserId = "second", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticket3 = new AccessTicket { UserId = "third", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
|
|
||||||
_repo._nowServing = long.MaxValue - 1;
|
|
||||||
_repo._nextUnusedTicket = long.MaxValue - 1;
|
|
||||||
|
|
||||||
_repo.Enqueue(ticket1);
|
|
||||||
_repo.Enqueue(ticket2);
|
|
||||||
_repo.Enqueue(ticket3);
|
|
||||||
|
|
||||||
Assert.Equal(0ul, _repo._nowServing);
|
|
||||||
Assert.Equal(3ul, _repo._nextUnusedTicket);
|
|
||||||
|
|
||||||
Assert.Equal(0, _repo.GetRequestsAhead("first"));
|
|
||||||
Assert.Equal(2, _repo.GetRequestsAhead("third"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToState_ReturnsAccurateJson()
|
|
||||||
{
|
|
||||||
var ticketWithAccess = new AccessTicket { UserId = "access", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticketWithoutAccess = new AccessTicket { UserId = "noAccess", LastActive = DateTime.UtcNow };
|
|
||||||
|
|
||||||
_repo.UpsertTicket(ticketWithAccess);
|
|
||||||
_repo.Enqueue(ticketWithoutAccess);
|
|
||||||
|
|
||||||
string stateJson = _repo.ToState();
|
|
||||||
var state = JsonSerializer.Deserialize<TakeANumberAccessQueueRepoState>(stateJson);
|
|
||||||
|
|
||||||
Assert.NotNull(state?.AccessQueue);
|
|
||||||
Assert.NotNull(state?.AccessTickets);
|
|
||||||
Assert.Single(state!.AccessTickets);
|
|
||||||
Assert.Single(state!.AccessQueue);
|
|
||||||
|
|
||||||
Assert.Equal(ticketWithAccess.UserId, state.AccessTickets.First().Key);
|
|
||||||
Assert.Equal(ticketWithAccess.ExpiresOn, state.AccessTickets.First().Value.ExpiresOn);
|
|
||||||
Assert.Equal(ticketWithAccess.LastActive, state.AccessTickets.First().Value.LastActive);
|
|
||||||
|
|
||||||
Assert.Equal(ticketWithoutAccess.UserId, state.AccessQueue.First().Value.UserId);
|
|
||||||
Assert.Equal(ticketWithoutAccess.LastActive, state.AccessQueue.First().Value.LastActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromState_DeserializesJsonCorrectly()
|
|
||||||
{
|
|
||||||
var ticketWithAccess = new AccessTicket { UserId = "access", ExpiresOn = DateTime.UtcNow.AddMinutes(1), LastActive = DateTime.UtcNow };
|
|
||||||
var ticketWithoutAccess = new AccessTicket { UserId = "noAccess", LastActive = DateTime.UtcNow };
|
|
||||||
|
|
||||||
_repo.UpsertTicket(ticketWithAccess);
|
|
||||||
_repo.Enqueue(ticketWithoutAccess);
|
|
||||||
|
|
||||||
string stateJson = _repo.ToState();
|
|
||||||
var deserializedRepo = TakeANumberAccessQueueRepo.FromState(stateJson);
|
|
||||||
|
|
||||||
Assert.Equal(1, deserializedRepo.GetUnexpiredTicketsCount());
|
|
||||||
Assert.Equal(1, deserializedRepo.GetQueueCount());
|
|
||||||
|
|
||||||
Assert.Equal(deserializedRepo.GetTicket("access")!.ExpiresOn, ticketWithAccess.ExpiresOn);
|
|
||||||
Assert.Null(deserializedRepo.GetTicket("noAccess"));
|
|
||||||
Assert.Equal(0, deserializedRepo.GetRequestsAhead("noAccess"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,11 +3,13 @@ namespace AccessQueueServiceTests
|
||||||
using global::AccessQueueService.Data;
|
using global::AccessQueueService.Data;
|
||||||
using global::AccessQueueService.Services;
|
using global::AccessQueueService.Services;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AccessQueueService.Tests
|
||||||
|
{
|
||||||
public class AccessServiceTests
|
public class AccessServiceTests
|
||||||
{
|
{
|
||||||
const int EXP_SECONDS = 5;
|
const int EXP_SECONDS = 5;
|
||||||
|
@ -15,10 +17,15 @@ namespace AccessQueueServiceTests
|
||||||
const int ACT_SECONDS = 1;
|
const int ACT_SECONDS = 1;
|
||||||
const int ACT_MILLIS = 1000 * ACT_SECONDS;
|
const int ACT_MILLIS = 1000 * ACT_SECONDS;
|
||||||
const int CAP_LIMIT = 5;
|
const int CAP_LIMIT = 5;
|
||||||
const int BULK_COUNT = 10000;
|
const int BULK_COUNT = 50000;
|
||||||
private readonly AccessService _accessService;
|
private AccessService _accessService;
|
||||||
|
public static IEnumerable<object[]> RepoImplementations()
|
||||||
|
{
|
||||||
|
yield return new object[] { new DictionaryAccessQueueRepo() };
|
||||||
|
yield return new object[] { new TakeANumberAccessQueueRepo() };
|
||||||
|
}
|
||||||
|
|
||||||
public AccessServiceTests()
|
private void CreateService(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var inMemorySettings = new Dictionary<string, string?>
|
var inMemorySettings = new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
|
@ -31,24 +38,17 @@ namespace AccessQueueServiceTests
|
||||||
var configuration = new ConfigurationBuilder()
|
var configuration = new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(inMemorySettings)
|
.AddInMemoryCollection(inMemorySettings)
|
||||||
.Build();
|
.Build();
|
||||||
var accessQueueRepo = new TakeANumberAccessQueueRepo();
|
|
||||||
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
|
|
||||||
var logger = loggerFactory.CreateLogger<AccessService>();
|
_accessService = new AccessService(configuration, repo);
|
||||||
_accessService = new AccessService(configuration, accessQueueRepo, logger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FillSlots(int usersToAdd = CAP_LIMIT)
|
[Theory]
|
||||||
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldGrantAccess_WhenCapacityIsAvailable(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < usersToAdd; i++)
|
CreateService(repo);
|
||||||
{
|
var userId = Guid.NewGuid();
|
||||||
_ = _accessService.RequestAccess(Guid.NewGuid().ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RequestAccess_ShouldGrantAccess_WhenCapacityIsAvailable()
|
|
||||||
{
|
|
||||||
var userId = "user";
|
|
||||||
|
|
||||||
var response = await _accessService.RequestAccess(userId);
|
var response = await _accessService.RequestAccess(userId);
|
||||||
|
|
||||||
|
@ -60,10 +60,12 @@ namespace AccessQueueServiceTests
|
||||||
Assert.Equal(0, _accessService.QueueCount);
|
Assert.Equal(0, _accessService.QueueCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldReturnAccessResponse_WhenUserAlreadyHasTicket()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldReturnAccessResponse_WhenUserAlreadyHasTicket(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
await _accessService.RequestAccess(userId);
|
await _accessService.RequestAccess(userId);
|
||||||
|
|
||||||
var response = await _accessService.RequestAccess(userId);
|
var response = await _accessService.RequestAccess(userId);
|
||||||
|
@ -76,11 +78,16 @@ namespace AccessQueueServiceTests
|
||||||
Assert.Equal(0, _accessService.QueueCount);
|
Assert.Equal(0, _accessService.QueueCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldQueueUser_WhenCapacityIsFull()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldQueueUser_WhenCapacityIsFull(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
FillSlots(CAP_LIMIT * 2);
|
CreateService(repo);
|
||||||
var userId = "user";
|
for (int i = 0; i < CAP_LIMIT * 2; i++) // Fill double capacity
|
||||||
|
{
|
||||||
|
await _accessService.RequestAccess(Guid.NewGuid());
|
||||||
|
}
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
var response = await _accessService.RequestAccess(userId);
|
var response = await _accessService.RequestAccess(userId);
|
||||||
|
|
||||||
|
@ -93,10 +100,12 @@ namespace AccessQueueServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RevokeAccess_ShouldReturnTrue_WhenUserHasAccess()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RevokeAccess_ShouldReturnTrue_WhenUserHasAccess(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
await _accessService.RequestAccess(userId);
|
await _accessService.RequestAccess(userId);
|
||||||
|
|
||||||
var result = await _accessService.RevokeAccess(userId);
|
var result = await _accessService.RevokeAccess(userId);
|
||||||
|
@ -104,23 +113,30 @@ namespace AccessQueueServiceTests
|
||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RevokeAccess_ShouldReturnFalse_WhenUserDoesNotHaveAccess()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RevokeAccess_ShouldReturnFalse_WhenUserDoesNotHaveAccess(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
var result = await _accessService.RevokeAccess(userId);
|
var result = await _accessService.RevokeAccess(userId);
|
||||||
|
|
||||||
Assert.False(result);
|
Assert.False(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldQueueUser_AfterAccessRevoked()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldQueueUser_AfterAccessRevoked(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
await _accessService.RequestAccess(userId);
|
await _accessService.RequestAccess(userId);
|
||||||
|
|
||||||
FillSlots();
|
for (int i = 0; i < CAP_LIMIT; i++) // Fill remaining slots
|
||||||
|
{
|
||||||
|
await _accessService.RequestAccess(Guid.NewGuid());
|
||||||
|
}
|
||||||
|
|
||||||
var response = await _accessService.RequestAccess(userId); // Request access before revoking
|
var response = await _accessService.RequestAccess(userId); // Request access before revoking
|
||||||
Assert.NotNull(response);
|
Assert.NotNull(response);
|
||||||
|
@ -132,30 +148,42 @@ namespace AccessQueueServiceTests
|
||||||
Assert.False(responseAfterRevoke.HasAccess);
|
Assert.False(responseAfterRevoke.HasAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldNotQueueUser_WhenMultipleRequestsForOtherUsersMade()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldNotQueueUser_WhenMultipleRequestsForOtherUsersMade(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
FillSlots();
|
CreateService(repo);
|
||||||
var response = await _accessService.RequestAccess(Guid.NewGuid().ToString()); // Request access before revoking
|
for (int i = 0; i < CAP_LIMIT; i++) // Fill slots without awaiting
|
||||||
|
{
|
||||||
|
_ = _accessService.RequestAccess(Guid.NewGuid());
|
||||||
|
}
|
||||||
|
var response = await _accessService.RequestAccess(Guid.NewGuid()); // Request access before revoking
|
||||||
Assert.NotNull(response);
|
Assert.NotNull(response);
|
||||||
Assert.False(response.HasAccess);
|
Assert.False(response.HasAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldUpdateExpirationTime_WhenRollingExpirationTrue()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldUpdateExpirationTime_WhenRollingExpirationTrue(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
var initialResponse = await _accessService.RequestAccess(userId);
|
var initialResponse = await _accessService.RequestAccess(userId);
|
||||||
await Task.Delay(ACT_MILLIS);
|
await Task.Delay(ACT_MILLIS);
|
||||||
var updatedResponse = await _accessService.RequestAccess(userId);
|
var updatedResponse = await _accessService.RequestAccess(userId);
|
||||||
Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn);
|
Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldGrantAccess_WhenUsersWithAccessInactive()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldGrantAccess_WhenUsersWithAccessInactive(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
FillSlots();
|
CreateService(repo);
|
||||||
var userId = "user";
|
for (int i = 0; i < CAP_LIMIT; i++)
|
||||||
|
{
|
||||||
|
await _accessService.RequestAccess(Guid.NewGuid());
|
||||||
|
}
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
var response = await _accessService.RequestAccess(userId);
|
var response = await _accessService.RequestAccess(userId);
|
||||||
Assert.False(response.HasAccess);
|
Assert.False(response.HasAccess);
|
||||||
await Task.Delay(ACT_MILLIS);
|
await Task.Delay(ACT_MILLIS);
|
||||||
|
@ -163,60 +191,75 @@ namespace AccessQueueServiceTests
|
||||||
Assert.True(response.HasAccess);
|
Assert.True(response.HasAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldRevokeAccess_WhenExpired()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldRevokeAccess_WhenExpired(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
var response = await _accessService.RequestAccess(userId);
|
var response = await _accessService.RequestAccess(userId);
|
||||||
Assert.True(response.HasAccess);
|
Assert.True(response.HasAccess);
|
||||||
await Task.Delay(EXP_MILLIS);
|
await Task.Delay(EXP_MILLIS);
|
||||||
FillSlots();
|
for (int i = 0; i < CAP_LIMIT; i++)
|
||||||
|
{
|
||||||
|
await _accessService.RequestAccess(Guid.NewGuid());
|
||||||
|
}
|
||||||
response = await _accessService.RequestAccess(userId);
|
response = await _accessService.RequestAccess(userId);
|
||||||
Assert.False(response.HasAccess);
|
Assert.False(response.HasAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldRetailAccess_WhenNotExpired()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldRetailAccess_WhenNotExpired(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
var response = await _accessService.RequestAccess(userId);
|
var response = await _accessService.RequestAccess(userId);
|
||||||
Assert.True(response.HasAccess);
|
Assert.True(response.HasAccess);
|
||||||
await Task.Delay(ACT_MILLIS);
|
await Task.Delay(ACT_MILLIS);
|
||||||
for (int i = 0; i < CAP_LIMIT; i++)
|
for (int i = 0; i < CAP_LIMIT; i++)
|
||||||
{
|
{
|
||||||
response = await _accessService.RequestAccess(Guid.NewGuid().ToString());
|
response = await _accessService.RequestAccess(Guid.NewGuid());
|
||||||
Assert.True(response.HasAccess);
|
Assert.True(response.HasAccess);
|
||||||
}
|
}
|
||||||
response = await _accessService.RequestAccess(userId);
|
response = await _accessService.RequestAccess(userId);
|
||||||
Assert.True(response.HasAccess);
|
Assert.True(response.HasAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldProcessBulkRequests()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldProcessBulkRequests(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
var userId = "user";
|
CreateService(repo);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
await _accessService.RequestAccess(userId);
|
await _accessService.RequestAccess(userId);
|
||||||
FillSlots(BULK_COUNT);
|
for (int i = 0; i < BULK_COUNT; i++)
|
||||||
|
{
|
||||||
|
_ = _accessService.RequestAccess(Guid.NewGuid());
|
||||||
|
}
|
||||||
var response = await _accessService.RequestAccess(userId);
|
var response = await _accessService.RequestAccess(userId);
|
||||||
Assert.NotNull(response);
|
Assert.NotNull(response);
|
||||||
Assert.True(response.HasAccess);
|
Assert.True(response.HasAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldReportLessInQueue_AsTicketsInactivate()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldReportLessInQueue_AsTicketsInactivate(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
|
CreateService(repo);
|
||||||
var start = DateTime.UtcNow;
|
var start = DateTime.UtcNow;
|
||||||
for (int i = 0; i < CAP_LIMIT; i++)
|
for (int i = 0; i < CAP_LIMIT; i++)
|
||||||
{
|
{
|
||||||
var elapsed = DateTime.UtcNow - start;
|
var elapsed = DateTime.UtcNow - start;
|
||||||
await _accessService.RequestAccess(Guid.NewGuid().ToString());
|
Console.WriteLine($"Elapsed time: {elapsed.TotalSeconds} s: Adding {i}");
|
||||||
|
await _accessService.RequestAccess(Guid.NewGuid());
|
||||||
await Task.Delay(ACT_MILLIS / CAP_LIMIT);
|
await Task.Delay(ACT_MILLIS / CAP_LIMIT);
|
||||||
}
|
}
|
||||||
var users = new[]
|
var users = new[]
|
||||||
{
|
{
|
||||||
"user1",
|
Guid.NewGuid(),
|
||||||
"user2",
|
Guid.NewGuid(),
|
||||||
"user3"
|
Guid.NewGuid()
|
||||||
};
|
};
|
||||||
|
|
||||||
await _accessService.RequestAccess(users[0]);
|
await _accessService.RequestAccess(users[0]);
|
||||||
|
@ -233,45 +276,21 @@ namespace AccessQueueServiceTests
|
||||||
Assert.Equal(0, response.RequestsAhead);
|
Assert.Equal(0, response.RequestsAhead);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task RequestAccess_ShouldShowCorrectRequestsAhead_WhenAccessRerequested()
|
[MemberData(nameof(RepoImplementations))]
|
||||||
|
public async Task RequestAccess_ShouldShowCorrectRequestsAhead_WhenAccessRerequested(IAccessQueueRepo repo)
|
||||||
{
|
{
|
||||||
FillSlots();
|
CreateService(repo);
|
||||||
|
for (int i = 0; i < CAP_LIMIT + 3; i++)
|
||||||
var id1 = "user1";
|
|
||||||
var id2 = "user2";
|
|
||||||
var id3 = "user3";
|
|
||||||
|
|
||||||
var response1 = await _accessService.RequestAccess(id1);
|
|
||||||
var response2 = await _accessService.RequestAccess(id2);
|
|
||||||
var response3 = await _accessService.RequestAccess(id3);
|
|
||||||
|
|
||||||
Assert.Equal(0, response1.RequestsAhead);
|
|
||||||
Assert.Equal(1, response2.RequestsAhead);
|
|
||||||
Assert.Equal(2, response3.RequestsAhead);
|
|
||||||
|
|
||||||
response1 = await _accessService.RequestAccess(id1);
|
|
||||||
response2 = await _accessService.RequestAccess(id2);
|
|
||||||
response3 = await _accessService.RequestAccess(id3);
|
|
||||||
|
|
||||||
Assert.Equal(0, response1.RequestsAhead);
|
|
||||||
Assert.Equal(1, response2.RequestsAhead);
|
|
||||||
Assert.Equal(2, response3.RequestsAhead);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Status_ShouldReturnCorrectCounts()
|
|
||||||
{
|
{
|
||||||
FillSlots();
|
await _accessService.RequestAccess(Guid.NewGuid());
|
||||||
await _accessService.RequestAccess("user");
|
}
|
||||||
var status = _accessService.Status;
|
var id = Guid.NewGuid();
|
||||||
|
var response = await _accessService.RequestAccess(id);
|
||||||
Assert.NotNull(status);
|
Assert.Equal(3, response.RequestsAhead);
|
||||||
Assert.Equal(CAP_LIMIT, status.UnexpiredTicketsCount);
|
response = await _accessService.RequestAccess(id);
|
||||||
Assert.Equal(CAP_LIMIT, status.ActiveTicketsCount);
|
Assert.Equal(3, response?.RequestsAhead);
|
||||||
Assert.Equal(1, status.QueueCount);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Henry Hobbs
|
Copyright (c) [year] [fullname]
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
204
README.md
204
README.md
|
@ -1,205 +1 @@
|
||||||
**NOTE:** I have added github as a remote but for the latest commits and issue tracking please visit https://git.hobbs.zone/henry/AccessQueueService
|
|
||||||
|
|
||||||
# AccessQueueService
|
# AccessQueueService
|
||||||
|
|
||||||
AccessQueueService is a microservice API designed to control access to a resource with a limited number of concurrent users. It ensures fair access by:
|
|
||||||
|
|
||||||
- Granting immediate access if capacity is available.
|
|
||||||
- Placing users in a queue when the resource is full.
|
|
||||||
- Automatically managing the queue in a first-in, first-out (FIFO) order.
|
|
||||||
- Allowing users to revoke their access, freeing up capacity for others.
|
|
||||||
|
|
||||||
This service is ideal for scenarios where you need to limit the number of users accessing a resource at the same time, such as online ticket sale platforms that control how many users can purchase tickets concurrently.
|
|
||||||
|
|
||||||
Note: This service is not intended to be called directly from end-user client applications, as it could be easily bypassed. Instead, it should be integrated as middleware within your own APIs or backend services.
|
|
||||||
|
|
||||||
## How the Service Works
|
|
||||||
|
|
||||||
1. **Requesting Access:**
|
|
||||||
- When a user requests access, the service checks if the current number of active users is below `CapacityLimit`.
|
|
||||||
- If there is capacity, the user is granted access immediately and receives an expiration date set by `ExpirationSeconds`.
|
|
||||||
- If not, the user is added to a queue and receives their position in the queue.
|
|
||||||
|
|
||||||
2. **Queueing:**
|
|
||||||
- If a user is placed in the queue, subsequent access requests will return the number of users ahead.
|
|
||||||
- Users must continually re-request access to remain active in the queue; inactivity may result in losing their spot.
|
|
||||||
|
|
||||||
3. **Dequeuing:**
|
|
||||||
- Users in the queue are managed in a FIFO (first-in, first-out) order.
|
|
||||||
- Whenever an access request is made, if there is capacity, the service attempts to dequeue users until capacity is met.
|
|
||||||
- If a user is dequeued but the time since their last activity is greater than `ActivitySeconds`, they are not granted access and lose their spot in the queue.
|
|
||||||
|
|
||||||
4. **Maintaining Access:**
|
|
||||||
- Users should continually re-request access while they are active to avoid being considered inactive.
|
|
||||||
- If `RollingExpiration` is enabled, the expiration is reset whenever access is re-requested.
|
|
||||||
|
|
||||||
5. **Revoking Access:**
|
|
||||||
- If a user requests access after their expiration date, they must restart the process and re-join the queue if there isn't capacity.
|
|
||||||
- When a user revokes access (or their access times out), their access expires immediately.
|
|
||||||
|
|
||||||
### Note on inactivity vs expiration
|
|
||||||
|
|
||||||
It is possible for the number of users with access to temporarily exceed the `CapacityLimit` if `ActivitySeconds` is less than `ExpirationSeconds`. This happens because:
|
|
||||||
|
|
||||||
- The number of available slots is determined by the time since a user's last activity (`ActivitySeconds`), not by their access expiration (`ExpirationSeconds`).
|
|
||||||
- If a user is inactive for longer than `ActivitySeconds`, they no longer count toward the capacity, allowing another user to gain access.
|
|
||||||
- However, the original user still technically has access until their `ExpirationSeconds` elapses.
|
|
||||||
|
|
||||||
**To ensure the number of users with access never exceeds `CapacityLimit`, set `ActivitySeconds` equal to `ExpirationSeconds`.**
|
|
||||||
|
|
||||||
## API Routes
|
|
||||||
|
|
||||||
### Request Access
|
|
||||||
- **GET /access/{id}**
|
|
||||||
- **Description:** Request access for a user with the specified `id`.
|
|
||||||
- **Response:** Returns an `AccessResponse` object indicating whether access was granted or the user's position in the queue.
|
|
||||||
|
|
||||||
### Revoke Access
|
|
||||||
- **DELETE /access/{id}**
|
|
||||||
- **Description:** Revoke access for a user with the specified `id`. This will remove the user from the active list or queue and may allow the next user in the queue to gain access.
|
|
||||||
- **Response:** Returns a boolean indicating success.
|
|
||||||
|
|
||||||
### Get Service Status
|
|
||||||
- **GET /status**
|
|
||||||
- **Description:** Returns the current status of the access queue, including active users, queue length, and other statistics.
|
|
||||||
- **Response:** Returns an `AccessQueueStatus` object.
|
|
||||||
|
|
||||||
### Get Configuration
|
|
||||||
- **GET /config**
|
|
||||||
- **Description:** Returns the current configuration of the access queue service.
|
|
||||||
- **Response:** Returns an `AccessQueueConfig` object.
|
|
||||||
|
|
||||||
### Update Configuration
|
|
||||||
- **POST /config**
|
|
||||||
- **Description:** Updates the configuration of the access queue service. Accepts a partial or full `AccessQueueConfig` object in the request body.
|
|
||||||
- **Response:** Returns no content on success (HTTP 204).
|
|
||||||
|
|
||||||
## Configuration Variables
|
|
||||||
|
|
||||||
Configuration is set in `appsettings.json` or via environment variables. The main configuration section is `AccessQueue`:
|
|
||||||
|
|
||||||
- **CapacityLimit**: The maximum number of users that can have access at the same time (default: 100).
|
|
||||||
- **ActivitySeconds**: How long (in seconds) a user can remain active before being considered inactive (default: 900).
|
|
||||||
- **ExpirationSeconds**: How long (in seconds) before an access ticket expires (default: 43200).
|
|
||||||
- **RollingExpiration**: If true, the expiration timer resets on activity (default: true).
|
|
||||||
- **CleanupIntervalSeconds**: How often (in seconds) the background cleanup runs to remove expired/inactive users (default: 60).
|
|
||||||
- **BackupFilePath**: The file path where the access queue state will be periodically saved (no default; optional).
|
|
||||||
- **BackupIntervalSeconds**: How often (in seconds) the state is backed up to disk (no default; optional).
|
|
||||||
|
|
||||||
Example `appsettings.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"AccessQueue": {
|
|
||||||
"CapacityLimit": 100,
|
|
||||||
"ActivitySeconds": 900,
|
|
||||||
"ExpirationSeconds": 43200,
|
|
||||||
"RollingExpiration": true,
|
|
||||||
"CleanupIntervalSeconds": 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Persistence and Backup
|
|
||||||
|
|
||||||
AccessQueueService automatically saves its in-memory state (active users and queue) to disk at regular intervals and restores it on startup. This helps prevent data loss in case of unexpected shutdowns or restarts.
|
|
||||||
|
|
||||||
- **Backup Location:** The backup file path is set via the `AccessQueue:BackupFilePath` configuration variable. If this is not set, no backup will be performed.
|
|
||||||
- **Backup Interval:** The frequency of backups is controlled by `AccessQueue:BackupIntervalSeconds` (in seconds). If this is not set or is zero, backups are disabled.
|
|
||||||
- **Startup Restore:** On startup, if a backup file exists at the specified path, the service will attempt to restore the previous state from this file. If the file is missing or corrupted, the service starts with an empty queue and access list.
|
|
||||||
- **Failure Handling:** Any errors during backup or restore are logged, but do not prevent the service from running.
|
|
||||||
- **Backup Format:** The backup is saved as a JSON file containing the current state of the access queue and active users.
|
|
||||||
|
|
||||||
Example configuration:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"AccessQueue": {
|
|
||||||
"BackupFilePath": "Logs/backup.json",
|
|
||||||
"BackupIntervalSeconds": 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** Ensure the backup file path is writable by the service. Regular backups help prevent data loss in case of unexpected shutdowns.
|
|
||||||
|
|
||||||
## AccessResponse Object
|
|
||||||
|
|
||||||
The `AccessResponse` object returned by the API contains the following properties:
|
|
||||||
|
|
||||||
- **ExpiresOn** (`DateTime?`): The UTC timestamp when the user's access will expire. `null` if the user does not have access.
|
|
||||||
- **RequestsAhead** (`int`): The number of requests ahead of the user in the queue. `0` if the user has access.
|
|
||||||
- **HasAccess** (`bool`): Indicates whether the user currently has access (true if `ExpiresOn` is set and in the future).
|
|
||||||
|
|
||||||
## Running the Service
|
|
||||||
|
|
||||||
1. Build and run the project using .NET 8.0 or later:
|
|
||||||
```powershell
|
|
||||||
dotnet run --project AccessQueueService/AccessQueueService.csproj
|
|
||||||
```
|
|
||||||
2. By default, the API will be available at:
|
|
||||||
- HTTP: http://localhost:5199
|
|
||||||
- HTTPS: https://localhost:7291
|
|
||||||
(See `AccessQueueService/Properties/launchSettings.json` for details.)
|
|
||||||
|
|
||||||
## Running the Tests
|
|
||||||
|
|
||||||
Unit tests for the service are located in the `AccessQueueServiceTests` project. To run all tests, use the following command from the root of the repository:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Run all tests in the solution
|
|
||||||
dotnet test
|
|
||||||
```
|
|
||||||
|
|
||||||
Test results will be displayed in the terminal. You can also use Visual Studio's Test Explorer for a graphical interface.
|
|
||||||
|
|
||||||
## AccessQueuePlayground (Demo UI)
|
|
||||||
|
|
||||||
The `AccessQueuePlayground` project provides a simple web-based UI for interacting with the AccessQueueService API. This is useful for testing and demonstration purposes.
|
|
||||||
|
|
||||||
### Running
|
|
||||||
|
|
||||||
1. Build and run the playground project:
|
|
||||||
```powershell
|
|
||||||
dotnet run --project AccessQueuePlayground/AccessQueuePlayground.csproj
|
|
||||||
```
|
|
||||||
2. By default, the playground will be available at:
|
|
||||||
- HTTP: http://localhost:5108
|
|
||||||
- HTTPS: https://localhost:7211
|
|
||||||
(See `AccessQueuePlayground/Properties/launchSettings.json` for details.)
|
|
||||||
|
|
||||||
### Using
|
|
||||||
|
|
||||||
- Open the provided URL in your browser.
|
|
||||||
- Use the UI to request and revoke access for different user IDs.
|
|
||||||
- The UI will display your access status, queue position, and expiration time.
|
|
||||||
|
|
||||||
This playground is intended only for local development and demonstration.
|
|
||||||
|
|
||||||
### Configuring
|
|
||||||
|
|
||||||
The `AccessQueuePlayground` project can be configured via its `appsettings.json` file. The main options are:
|
|
||||||
|
|
||||||
- **RefreshRateMilliseconds**: Determines how often (in milliseconds) the playground requests access for all active users and updates the UI. Lower values provide more real-time updates but may increase load on the service.
|
|
||||||
- **ServiceUrl**: The URL of the AccessQueueService API to use. If this is set, all API requests from the playground will be sent to the specified service URL (e.g., `https://localhost:7291/`).
|
|
||||||
- If `ServiceUrl` is **not provided**, the playground will use an internal instance of AccessQueueService, configured using the playground's own `appsettings.json` values under the `AccessQueue` section. This is useful for local development and testing without running the service separately.
|
|
||||||
|
|
||||||
Example configuration in `AccessQueuePlayground/appsettings.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"AccessQueuePlayground": {
|
|
||||||
"RefreshRateMilliseconds": 200,
|
|
||||||
"ServiceUrl": "https://localhost:7291/"
|
|
||||||
},
|
|
||||||
"AccessQueue": {
|
|
||||||
"CapacityLimit": 3,
|
|
||||||
"ActivitySeconds": 2,
|
|
||||||
"ExpirationSeconds": 10,
|
|
||||||
"RollingExpiration": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Tip:** Adjust `RefreshRateMilliseconds` for your use case. For most demos, 100–500ms works well. If you are connecting to a remote or production AccessQueueService, consider increasing the refresh interval (e.g., 1000ms or higher) to account for network latency and reduce unnecessary load.
|
|
||||||
|
|
||||||
## License
|
|
||||||
See [LICENSE.txt](./LICENSE.txt) for license information.
|
|
Loading…
Reference in New Issue