Skip to content

Commit 660926a

Browse files
committed
v2
1 parent 96a9bd9 commit 660926a

17 files changed

+387
-28
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace UrlShortener.Api;
2+
3+
public class EnvironmentManager : IEnvironmentManager
4+
{
5+
public void FatalError()
6+
{
7+
Environment.Exit(-1);
8+
}
9+
}
10+
11+
public interface IEnvironmentManager
12+
{
13+
public void FatalError();
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using UrlShortener.Core.Urls.Add;
2+
3+
namespace UrlShortener.Api.Extensions;
4+
5+
public static class ServiceCollectionExtensions
6+
{
7+
public static IServiceCollection AddUrlFeature(this IServiceCollection services)
8+
{
9+
services.AddScoped<AddUrlHandler>();
10+
services.AddSingleton<TokenProvider>();
11+
services.AddScoped<ShortUrlGenerator>();
12+
13+
return services;
14+
}
15+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Global using directives
2+
3+
global using UrlShortener.Core;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace UrlShortener.Api;
2+
3+
public class IApiAssemblyMarker
4+
{
5+
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace UrlShortener.Api;
2+
3+
public interface ITokenRangeApiClient
4+
{
5+
Task<TokenRange?> AssignRangeAsync(string machineKey, CancellationToken cancellationToken);
6+
}

Api/src/UrlShortener.Api/Program.cs

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
using Azure.Identity;
2+
using UrlShortener.Api;
3+
using UrlShortener.Api.Extensions;
4+
using UrlShortener.Core.Urls.Add;
25

36
var builder = WebApplication.CreateBuilder(args);
47

@@ -10,6 +13,21 @@
1013
new DefaultAzureCredential());
1114
}
1215

16+
builder.Services.AddSingleton(TimeProvider.System)
17+
.AddSingleton<IEnvironmentManager, EnvironmentManager>();
18+
builder.Services
19+
.AddUrlFeature();
20+
21+
builder.Services.AddHttpClient("TokenRangeService",
22+
client =>
23+
{
24+
client.BaseAddress =
25+
new Uri(builder.Configuration["TokenRangeService:Endpoint"]!); // TODO: Add to bicep
26+
});
27+
28+
builder.Services.AddSingleton<ITokenRangeApiClient, TokenRangeApiClient>();
29+
builder.Services.AddHostedService<TokenManager>();
30+
1331
builder.Services.AddOpenApi();
1432

1533
var app = builder.Build();
@@ -21,28 +39,24 @@
2139

2240
app.UseHttpsRedirection();
2341

24-
var summaries = new[]
25-
{
26-
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
27-
};
28-
29-
app.MapGet("/weatherforecast", () =>
30-
{
31-
var forecast = Enumerable.Range(1, 5).Select(index =>
32-
new WeatherForecast
33-
(
34-
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
35-
Random.Shared.Next(-20, 55),
36-
summaries[Random.Shared.Next(summaries.Length)]
37-
))
38-
.ToArray();
39-
return forecast;
40-
})
41-
.WithName("GetWeatherForecast");
42+
app.MapPost("/api/urls",
43+
async (AddUrlHandler handler,
44+
AddUrlRequest request,
45+
CancellationToken cancellationToken) =>
46+
{
47+
var requestWithUser = request with
48+
{
49+
CreatedBy = "alnuaimi.me"
50+
};
51+
var result = await handler.HandleAsync(requestWithUser, cancellationToken);
52+
53+
if (!result.Succeeded)
54+
{
55+
return Results.BadRequest(result.Error);
56+
}
57+
58+
return Results.Created($"/api/urls/{result.Value!.ShortUrl}",
59+
result.Value);
60+
});
4261

4362
app.Run();
44-
45-
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
46-
{
47-
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
48-
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace UrlShortener.Api;
2+
3+
public class TokenManager : IHostedService
4+
{
5+
private readonly ITokenRangeApiClient _client;
6+
private readonly ILogger<TokenManager> _logger;
7+
private readonly string _machineIdentifier;
8+
private readonly TokenProvider _tokenProvider;
9+
private readonly IEnvironmentManager _environmentManager;
10+
11+
public TokenManager(ITokenRangeApiClient client, ILogger<TokenManager> logger,
12+
TokenProvider tokenProvider, IEnvironmentManager environmentManager)
13+
{
14+
_logger = logger;
15+
_tokenProvider = tokenProvider;
16+
_environmentManager = environmentManager;
17+
_client = client;
18+
_machineIdentifier = Environment.GetEnvironmentVariable("WEBSITE_INSTANCE_ID") ?? "unknown";
19+
}
20+
21+
public async Task StartAsync(CancellationToken cancellationToken)
22+
{
23+
try
24+
{
25+
_logger.LogInformation("Starting token manager");
26+
27+
_tokenProvider.ReachingRangeLimit += async (sender, args) =>
28+
{
29+
await AssignNewRangeAsync(cancellationToken);
30+
};
31+
32+
await AssignNewRangeAsync(cancellationToken);
33+
}
34+
catch (Exception ex)
35+
{
36+
_logger.LogCritical(ex, "TokenManager failed to start due to an error.");
37+
_environmentManager.FatalError(); // Stop the application with a fatal error
38+
}
39+
}
40+
41+
private async Task AssignNewRangeAsync(CancellationToken cancellationToken)
42+
{
43+
var range = await _client.AssignRangeAsync(_machineIdentifier, cancellationToken);
44+
45+
if (range is null)
46+
{
47+
throw new Exception("No tokens assigned");
48+
}
49+
50+
_tokenProvider.AssignRange(range);
51+
_logger.LogInformation("Assigned range: {Start}-{End}", range.Start, range.End);
52+
}
53+
54+
public Task StopAsync(CancellationToken cancellationToken)
55+
{
56+
_logger.LogInformation("Stopping token manager");
57+
return Task.CompletedTask;
58+
}
59+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Polly;
2+
using Polly.Extensions.Http;
3+
using Polly.Retry;
4+
5+
namespace UrlShortener.Api;
6+
7+
public class TokenRangeApiClient : ITokenRangeApiClient
8+
{
9+
private readonly HttpClient _httpClient;
10+
11+
private static readonly AsyncRetryPolicy<HttpResponseMessage> RetryPolicy =
12+
HttpPolicyExtensions
13+
.HandleTransientHttpError()
14+
.WaitAndRetryAsync(3, retryAttempt =>
15+
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
16+
17+
18+
public TokenRangeApiClient(IHttpClientFactory httpClientFactory)
19+
{
20+
_httpClient = httpClientFactory.CreateClient("TokenRangeService");
21+
}
22+
23+
public async Task<TokenRange?> AssignRangeAsync(string machineKey, CancellationToken cancellationToken)
24+
{
25+
var response = await RetryPolicy.ExecuteAsync(() =>
26+
_httpClient.PostAsJsonAsync("assign",
27+
new { Key = machineKey }, cancellationToken));
28+
29+
if (!response.IsSuccessStatusCode)
30+
{
31+
throw new Exception("Failed to assign new token range");
32+
}
33+
34+
var range = await response.Content
35+
.ReadFromJsonAsync<TokenRange>(cancellationToken: cancellationToken);
36+
37+
return range;
38+
}
39+
}

Api/src/UrlShortener.Api/UrlShortener.Api.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.2" />
1111
<PackageReference Include="Azure.Identity" Version="1.13.1" />
1212
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
13+
<PackageReference Include="Polly" Version="8.5.0" />
14+
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<ProjectReference Include="..\UrlShortener.Core\UrlShortener.Core.csproj" />
1319
</ItemGroup>
1420

1521
</Project>
Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,70 @@
1-
namespace UrlShortener.Core;
1+
using System.Collections.Concurrent;
2+
3+
namespace UrlShortener.Core;
24

35
public class TokenProvider
46
{
5-
private TokenRange? _tokenRange;
7+
private readonly object _tokenLock = new();
8+
private readonly ConcurrentQueue<TokenRange> _ranges = new();
9+
10+
private long _currentToken = 0;
11+
private TokenRange? _currentTokenRange;
12+
613
public void AssignRange(int start, int end)
714
{
8-
_tokenRange = new TokenRange(start, end);
15+
AssignRange(new TokenRange(start, end));
916
}
17+
1018
public void AssignRange(TokenRange tokenRange)
1119
{
12-
_tokenRange = tokenRange;
20+
_ranges.Enqueue(tokenRange);
1321
}
22+
1423
public long GetToken()
1524
{
16-
return _tokenRange.Start;
25+
lock (_tokenLock)
26+
{
27+
if (_currentTokenRange is null)
28+
MoveToNextRange();
29+
30+
if (_currentToken > _currentTokenRange?.End)
31+
MoveToNextRange();
32+
33+
if (IsReachingRangeLimit())
34+
OnRangeThresholdReached(new ReachingRangeLimitEventArgs()
35+
{
36+
RangeLimit = _currentTokenRange!.End,
37+
Token = _currentToken
38+
});
39+
40+
return _currentToken++;
41+
}
42+
}
43+
44+
private bool IsReachingRangeLimit()
45+
{
46+
var currentIndex = _currentToken + 1 - _currentTokenRange!.Start;
47+
var total = _currentTokenRange.End - _currentTokenRange.Start;
48+
return currentIndex >= total * 0.8;
49+
}
50+
51+
public event EventHandler? ReachingRangeLimit;
52+
53+
protected virtual void OnRangeThresholdReached(EventArgs e)
54+
{
55+
ReachingRangeLimit?.Invoke(this, e);
1756
}
57+
58+
private void MoveToNextRange()
59+
{
60+
if (!_ranges.TryDequeue(out _currentTokenRange))
61+
throw new IndexOutOfRangeException();
62+
_currentToken = _currentTokenRange.Start;
63+
}
64+
}
65+
66+
public class ReachingRangeLimitEventArgs : EventArgs
67+
{
68+
public long Token { get; set; }
69+
public long RangeLimit { get; set; }
1870
}

0 commit comments

Comments
 (0)