Skip to content

Commit

Permalink
Setup bot status endpoint (#43)
Browse files Browse the repository at this point in the history
* Setup bot status endpoint

* Setup swagger docs
  • Loading branch information
AlexMacocian authored Nov 28, 2023
1 parent f48a682 commit 3659feb
Show file tree
Hide file tree
Showing 13 changed files with 294 additions and 3 deletions.
21 changes: 21 additions & 0 deletions GuildWarsPartySearch.Tests/Infra/TestLoggerWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.Extensions.Logging;

namespace GuildWarsPartySearch.Tests.Infra;

public sealed class TestLoggerWrapper<T> : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
throw new NotImplementedException();
}

public bool IsEnabled(LogLevel logLevel)
{
return true;
}

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Console.WriteLine($"[{logLevel}] [{eventId}]\n{formatter(state, exception)}");
}
}
102 changes: 102 additions & 0 deletions GuildWarsPartySearch.Tests/Services/BotStatus/BotStatusServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using FluentAssertions;
using GuildWarsPartySearch.Server.Services.BotStatus;
using GuildWarsPartySearch.Tests.Infra;
using System.Net.WebSockets;

namespace GuildWarsPartySearch.Tests.Services.BotStatus;

[TestClass]
public sealed class BotStatusServiceTests
{
private readonly BotStatusService botStatusService;

public BotStatusServiceTests()
{
this.botStatusService = new BotStatusService(new TestLoggerWrapper<BotStatusService>());
}

[TestMethod]
public async Task AddBot_UniqueId_Succeeds()
{
var result = await this.botStatusService.AddBot("uniqueId", new ClientWebSocket());

result.Should().BeTrue();
}

[TestMethod]
public async Task AddBot_DuplicateId_Fails()
{
await this.botStatusService.AddBot("nonUniqueId", new ClientWebSocket());
var result = await this.botStatusService.AddBot("nonUniqueId", new ClientWebSocket());

result.Should().BeFalse();
}

[TestMethod]
public async Task AddBot_MultipleUniqueIds_Succeed()
{
await this.botStatusService.AddBot("uniqueId1", new ClientWebSocket());
var result = await this.botStatusService.AddBot("uniqueId2", new ClientWebSocket());

result.Should().BeTrue();
}

[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public async Task AddBot_NullOrEmptyId_Fails(string id)
{
var result = await this.botStatusService.AddBot(id, new ClientWebSocket());

result.Should().BeFalse();
}

[TestMethod]
public async Task RemoveBot_ExistingId_Succeeds()
{
await this.botStatusService.AddBot("uniqueId", new ClientWebSocket());
var result = await this.botStatusService.RemoveBot("uniqueId");

result.Should().BeTrue();
}

[TestMethod]
public async Task RemoveBot_NonExistingId_Fails()
{
var result = await this.botStatusService.RemoveBot("uniqueId");

result.Should().BeFalse();
}

[TestMethod]
public async Task RemoveBot_MultipleRemoves_Fails()
{
await this.botStatusService.AddBot("uniqueId", new ClientWebSocket());
await this.botStatusService.RemoveBot("uniqueId");

var result = await this.botStatusService.RemoveBot("uniqueId");

result.Should().BeFalse();
}

[TestMethod]
public async Task GetBots_NoBots_ReturnsNoBots()
{
var result = await this.botStatusService.GetBots();

result.Should().BeEmpty();
}

[TestMethod]
public async Task GetBots_WithBots_ReturnsExpectedBots()
{
await this.botStatusService.AddBot("uniqueId1", new ClientWebSocket());
await this.botStatusService.AddBot("uniqueId2", new ClientWebSocket());

var result = await this.botStatusService.GetBots();

result.Should().HaveCount(2);
result.Should().BeEquivalentTo(["uniqueId1", "uniqueId2"]);
}
}
6 changes: 3 additions & 3 deletions GuildWarsPartySearch/Endpoints/LiveFeed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ public override async Task SocketAccepted(CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.SocketAccepted), this.Context?.Connection.RemoteIpAddress?.ToString() ?? string.Empty);
this.liveFeedService.AddClient(this.WebSocket!);
scopedLogger.LogInformation("Client accepted to livefeed");
scopedLogger.LogDebug("Client accepted to livefeed");

scopedLogger.LogInformation("Sending all party searches");
scopedLogger.LogDebug("Sending all party searches");
var updates = await this.partySearchService.GetAllPartySearches(cancellationToken);
await this.SendMessage(new PartySearchList { Searches = updates }, cancellationToken);
}
Expand All @@ -48,7 +48,7 @@ public override Task SocketClosed()
{
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.SocketAccepted), this.Context?.Connection.RemoteIpAddress?.ToString() ?? string.Empty);
this.liveFeedService.RemoveClient(this.WebSocket!);
scopedLogger.LogInformation("Client removed from livefeed");
scopedLogger.LogDebug("Client removed from livefeed");
return Task.CompletedTask;
}
}
35 changes: 35 additions & 0 deletions GuildWarsPartySearch/Endpoints/PostPartySearch.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using GuildWarsPartySearch.Server.Filters;
using GuildWarsPartySearch.Server.Models;
using GuildWarsPartySearch.Server.Models.Endpoints;
using GuildWarsPartySearch.Server.Services.BotStatus;
using GuildWarsPartySearch.Server.Services.Feed;
using GuildWarsPartySearch.Server.Services.PartySearch;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -9,24 +10,58 @@

namespace GuildWarsPartySearch.Server.Endpoints;

[ServiceFilter<RequireSsl>]
[ServiceFilter<ApiKeyProtected>]
[ServiceFilter<UserAgentRequired>]
public sealed class PostPartySearch : WebSocketRouteBase<PostPartySearchRequest, PostPartySearchResponse>
{
private readonly IBotStatusService botStatusService;
private readonly ILiveFeedService liveFeedService;
private readonly IPartySearchService partySearchService;
private readonly ILogger<PostPartySearch> logger;

public PostPartySearch(
IBotStatusService botStatusService,
ILiveFeedService liveFeedService,
IPartySearchService partySearchService,
ILogger<PostPartySearch> logger)
{
this.botStatusService = botStatusService.ThrowIfNull();
this.liveFeedService = liveFeedService.ThrowIfNull();
this.partySearchService = partySearchService.ThrowIfNull();
this.logger = logger.ThrowIfNull();
}

public override async Task SocketAccepted(CancellationToken cancellationToken)
{
if (this.Context?.Items.TryGetValue(UserAgentRequired.UserAgentKey, out var userAgentValue) is not true ||
userAgentValue is not string userAgent)
{
await this.WebSocket!.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, "Failed to extract user agent", cancellationToken);
return;
}

if (!await this.botStatusService.AddBot(userAgent, this.WebSocket!))
{
await this.WebSocket!.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.PolicyViolation, $"Failed to add bot with id {userAgent}", cancellationToken);
return;
}
}

public override async Task SocketClosed()
{
if (this.Context?.Items.TryGetValue(UserAgentRequired.UserAgentKey, out var userAgentValue) is not true ||
userAgentValue is not string userAgent)
{
throw new InvalidOperationException("Unable to extract user agent on client disconnect");
}

if (!await this.botStatusService.RemoveBot(userAgent))
{
throw new InvalidOperationException($"Failed to remove bot with id {userAgent}");
}
}

public override async Task ExecuteAsync(PostPartySearchRequest? message, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ExecuteAsync), string.Empty);
Expand Down
30 changes: 30 additions & 0 deletions GuildWarsPartySearch/Endpoints/StatusController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using GuildWarsPartySearch.Server.Filters;
using GuildWarsPartySearch.Server.Services.BotStatus;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using System.Core.Extensions;

namespace GuildWarsPartySearch.Server.Endpoints;

[Route("status")]
[ServiceFilter<ApiKeyProtected>]
[ServiceFilter<RequireSsl>]
public class StatusController : Controller
{
private readonly IBotStatusService botStatusService;

public StatusController(
IBotStatusService botStatusService)
{
this.botStatusService = botStatusService.ThrowIfNull();
}

[HttpGet("bots")]
[ProducesResponseType(200)]
[ProducesResponseType(403)]
[SwaggerOperation(Description = $"Protected by *{ApiKeyProtected.ApiKeyHeader}* header.\r\n\r\nRequires *SSL* protocol. (https://)")]
public async Task<IActionResult> GetBotStatus([FromHeader(Name = ApiKeyProtected.ApiKeyHeader)] string _)
{
return this.Ok(await this.botStatusService.GetBots());
}
}
18 changes: 18 additions & 0 deletions GuildWarsPartySearch/Filters/RequireSsl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc.Filters;

namespace GuildWarsPartySearch.Server.Filters;

public sealed class RequireSsl : IActionFilter
{
public void OnActionExecuted(ActionExecutedContext context)
{
}

public void OnActionExecuting(ActionExecutingContext context)
{
if (context.HttpContext.Request.Scheme is not "https" or "wss")
{
context.Result = new ForbiddenResponseActionResult($"This endpoint requires SSL communication");
}
}
}
4 changes: 4 additions & 0 deletions GuildWarsPartySearch/Filters/UserAgentRequired.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ namespace GuildWarsPartySearch.Server.Filters;

public class UserAgentRequired : IActionFilter
{
public const string UserAgentKey = "UserAgent";

public void OnActionExecuted(ActionExecutedContext context)
{
}
Expand All @@ -15,5 +17,7 @@ public void OnActionExecuting(ActionExecutingContext context)
context.Result = new ForbiddenResponseActionResult("Missing user agent");
return;
}

context.HttpContext.Items.Add(UserAgentKey, userAgent);
}
}
1 change: 1 addition & 0 deletions GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.21.0" />
<PackageReference Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.21.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="6.5.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
Expand Down
1 change: 1 addition & 0 deletions GuildWarsPartySearch/Launch/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ private static async Task Main()
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Guild Wars Party Search API", Version = "v1" });
c.DocumentFilter<WebSocketEndpointsDocumentFilter>();
c.EnableAnnotations();
});
builder.Services.AddSingleton(jsonOptions);
builder.Logging.SetupLogging();
Expand Down
3 changes: 3 additions & 0 deletions GuildWarsPartySearch/Launch/ServerConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using GuildWarsPartySearch.Server.Extensions;
using GuildWarsPartySearch.Server.Filters;
using GuildWarsPartySearch.Server.Options;
using GuildWarsPartySearch.Server.Services.BotStatus;
using GuildWarsPartySearch.Server.Services.CharName;
using GuildWarsPartySearch.Server.Services.Content;
using GuildWarsPartySearch.Server.Services.Database;
Expand Down Expand Up @@ -82,6 +83,7 @@ public static IServiceCollection SetupServices(this IServiceCollection services)
services.AddApplicationInsightsTelemetryProcessor<WebSocketTelemetryProcessor>();
services.AddMemoryCache();
services.AddInMemoryRateLimiting();
services.AddScoped<RequireSsl>();
services.AddScoped<ApiKeyProtected>();
services.AddScoped<UserAgentRequired>();
services.AddScoped<IServerLifetimeService, ServerLifetimeService>();
Expand All @@ -90,6 +92,7 @@ public static IServiceCollection SetupServices(this IServiceCollection services)
services.AddScoped<ICharNameValidator, CharNameValidator>();
services.AddSingleton<ILiveFeedService, LiveFeedService>();
services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
services.AddSingleton<IBotStatusService, BotStatusService>();
services.AddScopedTableClient<PartySearchTableOptions>();
services.AddSingletonBlobContainerClient<ContentOptions>();
return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Protected by *{ApiKeyProtected.ApiKeyHeader}* header.
Requires *User-Agent* header to be set.
Requires *SSL* protocol (wss://).
Accepts json payloads. Example:
```json
{{
Expand Down
64 changes: 64 additions & 0 deletions GuildWarsPartySearch/Services/BotStatus/BotStatusService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.Collections.Concurrent;
using System.Core.Extensions;
using System.Extensions;
using System.Net.WebSockets;

namespace GuildWarsPartySearch.Server.Services.BotStatus;

public sealed class BotStatusService : IBotStatusService
{
private readonly ConcurrentDictionary<string, WebSocket> connectedBots = [];

private readonly ILogger<BotStatusService> logger;

public BotStatusService(
ILogger<BotStatusService> logger)
{
this.logger = logger.ThrowIfNull();
}

public Task<bool> AddBot(string botId, WebSocket client)
{
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.AddBot), botId);
if (botId.IsNullOrWhiteSpace())
{
scopedLogger.LogInformation("Unable to add bot. Null id");
return Task.FromResult(false);
}

if (!this.connectedBots.TryAdd(botId, client))
{
scopedLogger.LogInformation("Unable to add bot. Failed to add to cache");
return Task.FromResult(false);
}

scopedLogger.LogDebug("Added bot");
return Task.FromResult(true);
}

public Task<IEnumerable<string>> GetBots()
{
var bots = this.connectedBots.Keys.AsEnumerable();
return Task.FromResult(bots);
}

public Task<bool> RemoveBot(string botId)
{
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.RemoveBot), botId);
if (botId.IsNullOrEmpty())
{
scopedLogger.LogInformation("Unable to remove bot. Null id");
return Task.FromResult(false);
}

if (!this.connectedBots.TryRemove(botId, out var client) ||
client is null)
{
scopedLogger.LogInformation("Unable to remove bot. Failed to remove bot from cache");
return Task.FromResult(false);
}

scopedLogger.LogDebug("Removed bot");
return Task.FromResult(true);
}
}
Loading

0 comments on commit 3659feb

Please sign in to comment.