diff --git a/.github/workflows/docker-deploy.yaml b/.github/workflows/docker-deploy.yaml index 82676a9..7bf1b7f 100644 --- a/.github/workflows/docker-deploy.yaml +++ b/.github/workflows/docker-deploy.yaml @@ -1,8 +1,6 @@ name: Build and Push Docker Image on: - pull_request: - branches: [ master ] push: branches: [ master ] @@ -14,10 +12,24 @@ jobs: - name: Check out the repository uses: actions/checkout@v2 + - name: Install .NET Core + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.x' + - name: Log in to Docker Hub run: | docker login guildwarspartysearch.azurecr.io --username "${{ secrets.DOCKERHUB_USERNAME }}" --password "${{ secrets.DOCKERHUB_PASSWORD }}" + - name: Replace Config Placeholders + shell: pwsh + run: | + cd GuildWarsPartySearch + $content = Get-Content Config.json + $updatedContent = $content -replace "[API_KEY_PLACEHOLDER]", "${{ secrets.APIKEY }}" + Set-Content -Path Config.json -Value $updatedContent + Write-Host "Placeholder replaced successfully in Config.json" + - name: Build and push Docker image run: | cd GuildWarsPartySearch diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..23b804a --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,23 @@ +name: Run tests + +on: + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Check out the repository + uses: actions/checkout@v2 + + - name: Install .NET Core + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.x' + + - name: Run tests + shell: pwsh + run: | + dotnet test .\GuildWarsPartySearch.Tests\ \ No newline at end of file diff --git a/GuildWarsPartySearch.sln b/GuildWarsPartySearch.sln index 5070dc1..b228bbb 100644 --- a/GuildWarsPartySearch.sln +++ b/GuildWarsPartySearch.sln @@ -15,6 +15,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipelines", "Pipelines", "{6C63473D-A6D9-400D-94AD-2E4DBA48B8B8}" ProjectSection(SolutionItems) = preProject .github\workflows\docker-deploy.yaml = .github\workflows\docker-deploy.yaml + .github\workflows\test.yaml = .github\workflows\test.yaml EndProjectSection EndProject Global diff --git a/GuildWarsPartySearch/Config.json b/GuildWarsPartySearch/Config.json new file mode 100644 index 0000000..17c15b2 --- /dev/null +++ b/GuildWarsPartySearch/Config.json @@ -0,0 +1,5 @@ +{ + "ServerOptions": { + "ApiKey": "[API_KEY_PLACEHOLDER]" + } +} \ No newline at end of file diff --git a/GuildWarsPartySearch/Converters/NoneWebsocketMessageConverter.cs b/GuildWarsPartySearch/Converters/NoneWebsocketMessageConverter.cs new file mode 100644 index 0000000..a98f319 --- /dev/null +++ b/GuildWarsPartySearch/Converters/NoneWebsocketMessageConverter.cs @@ -0,0 +1,18 @@ +using GuildWarsPartySearch.Server.Models.Endpoints; +using MTSC.Common.WebSockets; +using MTSC.Common.WebSockets.RoutingModules; + +namespace GuildWarsPartySearch.Server.Converters; + +public sealed class NoneWebsocketMessageConverter : IWebsocketMessageConverter +{ + public None ConvertFromWebsocketMessage(WebsocketMessage websocketMessage) + { + return new None(); + } + + public WebsocketMessage ConvertToWebsocketMessage(None message) + { + return new WebsocketMessage { FIN = true }; + } +} diff --git a/GuildWarsPartySearch/Converters/PostPartyRequestWebsocketMessageConverter.cs b/GuildWarsPartySearch/Converters/PostPartyRequestWebsocketMessageConverter.cs new file mode 100644 index 0000000..5f34f99 --- /dev/null +++ b/GuildWarsPartySearch/Converters/PostPartyRequestWebsocketMessageConverter.cs @@ -0,0 +1,39 @@ +using GuildWarsPartySearch.Server.Models.Endpoints; +using MTSC.Common.WebSockets; +using MTSC.Common.WebSockets.RoutingModules; +using Newtonsoft.Json; +using System.Text; + +namespace GuildWarsPartySearch.Server.Converters; + +public sealed class PostPartyRequestWebsocketMessageConverter : IWebsocketMessageConverter +{ + public PostPartyRequestWebsocketMessageConverter() + { + } + + public WebsocketMessage ConvertToWebsocketMessage(PostPartySearchRequest message) + { + var serializedData = JsonConvert.SerializeObject(message); + var bytes = Encoding.UTF8.GetBytes(serializedData); + return new WebsocketMessage + { + Data = bytes, + Opcode = WebsocketMessage.Opcodes.Text + }; + } + + PostPartySearchRequest IWebsocketMessageConverter.ConvertFromWebsocketMessage(WebsocketMessage websocketMessage) + { + var bytes = websocketMessage.Data; + var serializedData = Encoding.UTF8.GetString(bytes); + try + { + return JsonConvert.DeserializeObject(serializedData)!; + } + catch(Exception ex) + { + return default!; + } + } +} diff --git a/GuildWarsPartySearch/Converters/PostPartyResponseWebsocketMessageConverter.cs b/GuildWarsPartySearch/Converters/PostPartyResponseWebsocketMessageConverter.cs new file mode 100644 index 0000000..1e16bff --- /dev/null +++ b/GuildWarsPartySearch/Converters/PostPartyResponseWebsocketMessageConverter.cs @@ -0,0 +1,29 @@ +using GuildWarsPartySearch.Server.Models.Endpoints; +using MTSC.Common.WebSockets; +using MTSC.Common.WebSockets.RoutingModules; +using Newtonsoft.Json; +using System.Text; + +namespace GuildWarsPartySearch.Server.Converters; + +public sealed class PostPartyResponseWebsocketMessageConverter : IWebsocketMessageConverter +{ + public WebsocketMessage ConvertToWebsocketMessage(PostPartySearchResponse message) + { + var serializedData = JsonConvert.SerializeObject(message); + var bytes = Encoding.UTF8.GetBytes(serializedData); + return new WebsocketMessage + { + Data = bytes, + Opcode = WebsocketMessage.Opcodes.Text, + FIN = true + }; + } + + PostPartySearchResponse IWebsocketMessageConverter.ConvertFromWebsocketMessage(WebsocketMessage websocketMessage) + { + var bytes = websocketMessage.Data; + var serializedData = Encoding.UTF8.GetString(bytes); + return JsonConvert.DeserializeObject(serializedData)!; + } +} diff --git a/GuildWarsPartySearch/Endpoints/GetPartySearch.cs b/GuildWarsPartySearch/Endpoints/GetPartySearch.cs deleted file mode 100644 index 7835d1d..0000000 --- a/GuildWarsPartySearch/Endpoints/GetPartySearch.cs +++ /dev/null @@ -1,117 +0,0 @@ -using GuildWarsPartySearch.Common.Models.GuildWars; -using GuildWarsPartySearch.Server.Filters; -using GuildWarsPartySearch.Server.Models.Endpoints; -using GuildWarsPartySearch.Server.Services.PartySearch; -using Microsoft.Extensions.Logging; -using MTSC.Common.Http; -using MTSC.Common.Http.Attributes; -using MTSC.Common.Http.RoutingModules; -using Newtonsoft.Json; -using System.Core.Extensions; - -namespace GuildWarsPartySearch.Server.Endpoints; - -[DecodeUrlFilter] -[SimpleStringTokenFilter] -[ReturnBadRequestOnDataBindingFailure] -public sealed class GetPartySearch : HttpRouteBase -{ - private readonly IPartySearchService partySearchService; - private readonly ILogger logger; - - [FromUrl("campaign")] - public Campaign? Campaign { get; init; } - - [FromUrl("continent")] - public Continent? Continent { get; init; } - - [FromUrl("region")] - public Region? Region { get; init; } - - [FromUrl("map")] - public Map? Map { get; init; } - - [FromUrl("district")] - public string? District { get; init; } - - public GetPartySearch( - IPartySearchService partySearchService, - ILogger logger) - { - this.partySearchService = partySearchService.ThrowIfNull(); - this.logger = logger.ThrowIfNull(); - } - - public override async Task HandleRequest(None request) - { - var result = await this.partySearchService.GetPartySearch(this.Campaign, this.Continent, this.Region, this.Map, this.District); - return result.Switch( - onSuccess: entries => Success200(entries), - onFailure: failure => failure switch - { - GetPartySearchFailure.InvalidPayload => NoPayload400, - GetPartySearchFailure.InvalidCampaign => InvalidCampaign400, - GetPartySearchFailure.InvalidContinent => InvalidContinent400, - GetPartySearchFailure.InvalidRegion => InvalidRegion400, - GetPartySearchFailure.InvalidMap => InvalidMap400, - GetPartySearchFailure.InvalidDistrict => InvalidDistrict400, - GetPartySearchFailure.EntriesNotFound => EntriesNotFound404, - GetPartySearchFailure.UnspecifiedFailure => UnspecifiedFailure500, - _ => UnspecifiedFailure500 - }); - } - - private static HttpResponse Success200(List entries) => new() - { - StatusCode = HttpMessage.StatusCodes.OK, - BodyString = JsonConvert.SerializeObject(entries) - }; - - private static HttpResponse NoPayload400 => new() - { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "No payload" - }; - - private static HttpResponse InvalidCampaign400 => new() - { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Campaign" - }; - - private static HttpResponse InvalidContinent400 => new() - { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Continent" - }; - - private static HttpResponse InvalidRegion400 => new() - { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Region" - }; - - private static HttpResponse InvalidMap400 => new() - { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Map" - }; - - private static HttpResponse InvalidDistrict400 => new() - { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid District" - }; - - private static HttpResponse EntriesNotFound404 => new() - { - StatusCode = HttpMessage.StatusCodes.NotFound, - BodyString = "Entries not found" - }; - - private static HttpResponse UnspecifiedFailure500 => new() - { - StatusCode = HttpMessage.StatusCodes.InternalServerError, - BodyString = "Encountered an error while processing request" - }; -} diff --git a/GuildWarsPartySearch/Endpoints/LiveFeed.cs b/GuildWarsPartySearch/Endpoints/LiveFeed.cs new file mode 100644 index 0000000..e6061b3 --- /dev/null +++ b/GuildWarsPartySearch/Endpoints/LiveFeed.cs @@ -0,0 +1,61 @@ +using GuildWarsPartySearch.Server.Models.Endpoints; +using GuildWarsPartySearch.Server.Services.Feed; +using GuildWarsPartySearch.Server.Services.PartySearch; +using Microsoft.Extensions.Logging; +using MTSC.Common.WebSockets; +using MTSC.Common.WebSockets.RoutingModules; +using Newtonsoft.Json; +using System.Core.Extensions; +using System.Extensions; +using System.Text; + +namespace GuildWarsPartySearch.Server.Endpoints; + +public sealed class LiveFeed : WebsocketRouteBase +{ + private readonly IPartySearchService partySearchService; + private readonly ILiveFeedService liveFeedService; + private readonly ILogger logger; + + public LiveFeed( + IPartySearchService partySearchService, + ILiveFeedService liveFeedService, + ILogger logger) + { + this.partySearchService = partySearchService.ThrowIfNull(); + this.liveFeedService = liveFeedService.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + } + + public override void ConnectionClosed() + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionInitialized), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); + scopedLogger.LogInformation("Client connected"); + this.liveFeedService.RemoveClient(this.ClientData); + } + + public override async void ConnectionInitialized() + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionInitialized), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); + scopedLogger.LogInformation("Client connected"); + this.liveFeedService.AddClient(this.ClientData); + scopedLogger.LogInformation("Sending all party searches"); + var updates = await this.partySearchService.GetAllPartySearches(); + var serialized = JsonConvert.SerializeObject(updates); + var payload = Encoding.UTF8.GetBytes(serialized); + this.SendMessage(new WebsocketMessage + { + Data = payload, + FIN = true, + Opcode = WebsocketMessage.Opcodes.Text + }); + } + + public override void HandleReceivedMessage(None message) + { + } + + public override void Tick() + { + } +} diff --git a/GuildWarsPartySearch/Endpoints/PostPartySearch.cs b/GuildWarsPartySearch/Endpoints/PostPartySearch.cs index 8563682..23b82e6 100644 --- a/GuildWarsPartySearch/Endpoints/PostPartySearch.cs +++ b/GuildWarsPartySearch/Endpoints/PostPartySearch.cs @@ -1,123 +1,150 @@ -using GuildWarsPartySearch.Server.Filters; -using GuildWarsPartySearch.Server.Models.Endpoints; +using GuildWarsPartySearch.Server.Models.Endpoints; +using GuildWarsPartySearch.Server.Services.Feed; using GuildWarsPartySearch.Server.Services.PartySearch; using Microsoft.Extensions.Logging; -using MTSC.Common.Http; -using MTSC.Common.Http.Attributes; -using MTSC.Common.Http.RoutingModules; +using MTSC.Common.WebSockets.RoutingModules; using System.Core.Extensions; +using System.Extensions; namespace GuildWarsPartySearch.Server.Endpoints; -[SimpleStringTokenFilter] -[ReturnBadRequestOnDataBindingFailure] -public sealed class PostPartySearch : HttpRouteBase +public sealed class PostPartySearch : WebsocketRouteBase { - [FromBody] - public PostPartySearchRequest? Payload { get; init; } - + private readonly ILiveFeedService liveFeedService; private readonly IPartySearchService partySearchService; private readonly ILogger logger; public PostPartySearch( + ILiveFeedService liveFeedService, IPartySearchService partySearchService, ILogger logger) { + this.liveFeedService = liveFeedService.ThrowIfNull(); this.partySearchService = partySearchService.ThrowIfNull(); this.logger = logger.ThrowIfNull(); } - public override async Task HandleRequest(None request) + public override void ConnectionClosed() + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionInitialized), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); + scopedLogger.LogInformation("Client disconnected"); + } + + public override void ConnectionInitialized() + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionInitialized), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); + scopedLogger.LogInformation("Client connected"); + } + + public override async void HandleReceivedMessage(PostPartySearchRequest message) { - var result = await this.partySearchService.PostPartySearch(this.Payload); - return result.Switch( - onSuccess: _ => Success200, + var result = await this.partySearchService.PostPartySearch(message); + var response = result.Switch( + onSuccess: _ => + { + this.liveFeedService.PushUpdate(this.Server, new PartySearchUpdate + { + Campaign = message.Campaign, + Continent = message.Continent, + District = message.District, + Map = message.Map, + PartySearchEntries = message.PartySearchEntries, + Region = message.Region + }); + return Success; + }, onFailure: failure => failure switch { - PostPartySearchFailure.InvalidPayload => NoPayload400, - PostPartySearchFailure.InvalidCampaign => InvalidCampaign400, - PostPartySearchFailure.InvalidContinent => InvalidContinent400, - PostPartySearchFailure.InvalidRegion => InvalidRegion400, - PostPartySearchFailure.InvalidMap => InvalidMap400, - PostPartySearchFailure.InvalidDistrict => InvalidDistrict400, - PostPartySearchFailure.InvalidEntries => InvalidPartySearchEntries400, - PostPartySearchFailure.InvalidPartySize => InvalidPartySize400, - PostPartySearchFailure.InvalidPartyMaxSize => InvalidPartyMaxSize400, - PostPartySearchFailure.InvalidNpcs => InvalidNpcs400, - PostPartySearchFailure.UnspecifiedFailure => UnspecifiedFailure500, - _ => UnspecifiedFailure500 + PostPartySearchFailure.InvalidPayload => InvalidPayload, + PostPartySearchFailure.InvalidCampaign => InvalidCampaign, + PostPartySearchFailure.InvalidContinent => InvalidContinent, + PostPartySearchFailure.InvalidRegion => InvalidRegion, + PostPartySearchFailure.InvalidMap => InvalidMap, + PostPartySearchFailure.InvalidDistrict => InvalidDistrict, + PostPartySearchFailure.InvalidEntries => InvalidEntries, + PostPartySearchFailure.InvalidPartySize => InvalidPartySize, + PostPartySearchFailure.InvalidPartyMaxSize => InvalidPartyMaxSize, + PostPartySearchFailure.InvalidNpcs => InvalidNpcs, + PostPartySearchFailure.UnspecifiedFailure => UnspecifiedFailure, + _ => UnspecifiedFailure }); + + this.SendMessage(response); + } + + public override void Tick() + { } - private static HttpResponse Success200 => new() + private static PostPartySearchResponse Success => new() { - StatusCode = HttpMessage.StatusCodes.OK, - BodyString = "Party search posted" + Result = 0, + Description = "Posted entries" }; - private static HttpResponse NoPayload400 => new() + private static PostPartySearchResponse InvalidPayload => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "No payload" + Result = 0, + Description = "Invalid payload" }; - private static HttpResponse InvalidCampaign400 => new() + private static PostPartySearchResponse InvalidCampaign => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Campaign" + Result = 0, + Description = "Invalid campaign" }; - private static HttpResponse InvalidContinent400 => new() + private static PostPartySearchResponse InvalidContinent => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Continent" + Result = 0, + Description = "Invalid continent" }; - private static HttpResponse InvalidRegion400 => new() + private static PostPartySearchResponse InvalidRegion => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Region" + Result = 0, + Description = "Invalid region" }; - private static HttpResponse InvalidMap400 => new() + private static PostPartySearchResponse InvalidMap => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Map" + Result = 0, + Description = "Invalid map" }; - private static HttpResponse InvalidDistrict400 => new() + private static PostPartySearchResponse InvalidDistrict => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid District" + Result = 0, + Description = "Invalid district" }; - private static HttpResponse InvalidPartySearchEntries400 => new() + private static PostPartySearchResponse InvalidEntries => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid PartySearchEntries" + Result = 0, + Description = "Invalid entries" }; - private static HttpResponse InvalidPartySize400 => new() + private static PostPartySearchResponse InvalidPartySize => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid PartySize" + Result = 0, + Description = "Invalid party size" }; - private static HttpResponse InvalidPartyMaxSize400 => new() + private static PostPartySearchResponse InvalidPartyMaxSize => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid PartyMaxSize" + Result = 0, + Description = "Invalid max party size" }; - private static HttpResponse InvalidNpcs400 => new() + private static PostPartySearchResponse InvalidNpcs => new() { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = "Invalid Npcs" + Result = 0, + Description = "Invalid npcs" }; - private static HttpResponse UnspecifiedFailure500 => new() + private static PostPartySearchResponse UnspecifiedFailure => new() { - StatusCode = HttpMessage.StatusCodes.InternalServerError, - BodyString = "Encountered an error while processing request" + Result = 0, + Description = "Unspecified failure" }; } diff --git a/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj b/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj index 0ef7370..0cfac37 100644 --- a/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj +++ b/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj @@ -21,4 +21,10 @@ + + + PreserveNewest + + + diff --git a/GuildWarsPartySearch/Launch/Program.cs b/GuildWarsPartySearch/Launch/Program.cs index c0be94d..5dab3be 100644 --- a/GuildWarsPartySearch/Launch/Program.cs +++ b/GuildWarsPartySearch/Launch/Program.cs @@ -16,10 +16,9 @@ private static async Task Main() server.ServiceManager.SetupServiceManager(); server .AddHandler( - new HttpRoutingHandler() + new WebsocketRoutingHandler() .SetupRoutes() - .WithReturn404OnNotFound(true) - .WithReturn500OnUnhandledException(true)) + .WithHeartbeatEnabled(true)) .AddServerUsageMonitor(new TickrateEnforcer() { TicksPerSecond = 240, Silent = true }) .SetScheduler(new TaskAwaiterScheduler()) .WithLoggingMessageContents(false); diff --git a/GuildWarsPartySearch/Launch/ServerConfiguration.cs b/GuildWarsPartySearch/Launch/ServerConfiguration.cs index 112ffb3..1881ef4 100644 --- a/GuildWarsPartySearch/Launch/ServerConfiguration.cs +++ b/GuildWarsPartySearch/Launch/ServerConfiguration.cs @@ -1,20 +1,29 @@ using GuildWarsPartySearch.Server.Endpoints; +using GuildWarsPartySearch.Server.Options; using GuildWarsPartySearch.Server.Services.Database; +using GuildWarsPartySearch.Server.Services.Feed; using GuildWarsPartySearch.Server.Services.Lifetime; using GuildWarsPartySearch.Server.Services.Logging; +using GuildWarsPartySearch.Server.Services.Options; using GuildWarsPartySearch.Server.Services.PartySearch; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using MTSC.Common.Http; +using MTSC.ServerSide; using MTSC.ServerSide.Handlers; using Slim; using System.Core.Extensions; using System.Extensions; using System.Logging; +using System.Runtime.CompilerServices; using static MTSC.Common.Http.HttpMessage; namespace GuildWarsPartySearch.Server.Launch; public static class ServerConfiguration { + private const string ApiKeyHeader = "X-ApiKey"; + public static IServiceManager SetupServiceManager(this IServiceManager serviceManager) { serviceManager.ThrowIfNull(); @@ -22,6 +31,7 @@ public static IServiceManager SetupServiceManager(this IServiceManager serviceMa serviceManager.RegisterResolver(new LoggerResolver()); serviceManager.RegisterOptionsResolver(); serviceManager.RegisterLogWriter(); + serviceManager.RegisterOptionsManager(); return serviceManager; } @@ -33,16 +43,43 @@ public static IServiceCollection SetupServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); return services; } - public static HttpRoutingHandler SetupRoutes(this HttpRoutingHandler httpRoutingHandler) + public static WebsocketRoutingHandler SetupRoutes(this WebsocketRoutingHandler websocketRoutingHandler) + { + websocketRoutingHandler.ThrowIfNull(); + websocketRoutingHandler + .AddRoute("party-search/live-feed") + .AddRoute("party-search/update", FilterUpdateMessages); + return websocketRoutingHandler; + } + + private static RouteEnablerResponse FilterUpdateMessages(MTSC.ServerSide.Server server, HttpRequest req, ClientData clientData) { - httpRoutingHandler.ThrowIfNull(); + var serverOptions = server.ServiceManager.GetRequiredService>(); + if (serverOptions.Value.ApiKey!.IsNullOrWhiteSpace()) + { + return RouteEnablerResponse.Error(Forbidden403); + } - httpRoutingHandler - .AddRoute(HttpMethods.Post, "party-search") - .AddRoute(HttpMethods.Get, "party-search/{campaign}/{continent}/{region}/{map}/{district}"); - return httpRoutingHandler; + if (!req.Headers.ContainsHeader(ApiKeyHeader)) + { + return RouteEnablerResponse.Error(Forbidden403); + } + + var apiKey = req.Headers[ApiKeyHeader]; + if (apiKey != serverOptions.Value.ApiKey) + { + return RouteEnablerResponse.Error(Forbidden403); + } + + return RouteEnablerResponse.Accept; } + + private static HttpResponse Forbidden403 => new() + { + StatusCode = StatusCodes.Forbidden + }; } diff --git a/GuildWarsPartySearch/Models/Endpoints/None.cs b/GuildWarsPartySearch/Models/Endpoints/None.cs index 209fc0a..f3d55e9 100644 --- a/GuildWarsPartySearch/Models/Endpoints/None.cs +++ b/GuildWarsPartySearch/Models/Endpoints/None.cs @@ -1,9 +1,11 @@ using GuildWarsPartySearch.Server.Converters; +using MTSC.Common.WebSockets.RoutingModules; using System.ComponentModel; namespace GuildWarsPartySearch.Server.Models.Endpoints; [TypeConverter(typeof(NoneConverter))] +[WebsocketMessageConvert(typeof(NoneWebsocketMessageConverter))] public sealed class None { } diff --git a/GuildWarsPartySearch/Models/Endpoints/PartySearchUpdate.cs b/GuildWarsPartySearch/Models/Endpoints/PartySearchUpdate.cs new file mode 100644 index 0000000..91afa3c --- /dev/null +++ b/GuildWarsPartySearch/Models/Endpoints/PartySearchUpdate.cs @@ -0,0 +1,25 @@ +using GuildWarsPartySearch.Common.Models.GuildWars; +using Newtonsoft.Json; + +namespace GuildWarsPartySearch.Server.Models.Endpoints; + +public sealed class PartySearchUpdate +{ + [JsonProperty(nameof(Campaign))] + public Campaign? Campaign { get; set; } + + [JsonProperty(nameof(Continent))] + public Continent? Continent { get; set; } + + [JsonProperty(nameof(Region))] + public Region? Region { get; set; } + + [JsonProperty(nameof(Map))] + public Map? Map { get; set; } + + [JsonProperty(nameof(District))] + public string? District { get; set; } + + [JsonProperty(nameof(PartySearchEntries))] + public List? PartySearchEntries { get; set; } +} diff --git a/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs index 6f62fc1..e1c844d 100644 --- a/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs +++ b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs @@ -1,8 +1,11 @@ using GuildWarsPartySearch.Common.Models.GuildWars; +using GuildWarsPartySearch.Server.Converters; +using MTSC.Common.WebSockets.RoutingModules; using Newtonsoft.Json; namespace GuildWarsPartySearch.Server.Models.Endpoints; +[WebsocketMessageConvert(typeof(PostPartyRequestWebsocketMessageConverter))] public sealed class PostPartySearchRequest { [JsonProperty(nameof(Campaign))] diff --git a/GuildWarsPartySearch/Models/Endpoints/PostPartySearchResponse.cs b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchResponse.cs new file mode 100644 index 0000000..ce48f3b --- /dev/null +++ b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchResponse.cs @@ -0,0 +1,15 @@ +using GuildWarsPartySearch.Server.Converters; +using MTSC.Common.WebSockets.RoutingModules; +using Newtonsoft.Json; + +namespace GuildWarsPartySearch.Server.Models.Endpoints; + +[WebsocketMessageConvert(typeof(PostPartyResponseWebsocketMessageConverter))] +public sealed class PostPartySearchResponse +{ + [JsonProperty(nameof(Result))] + public int Result { get; set; } + + [JsonProperty(nameof(Description))] + public string? Description { get; set; } +} diff --git a/GuildWarsPartySearch/Options/ServerOptions.cs b/GuildWarsPartySearch/Options/ServerOptions.cs new file mode 100644 index 0000000..cfeb59e --- /dev/null +++ b/GuildWarsPartySearch/Options/ServerOptions.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace GuildWarsPartySearch.Server.Options; + +public sealed class ServerOptions +{ + [JsonProperty(nameof(ApiKey))] + public string? ApiKey { get; set; } +} diff --git a/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs b/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs index a2d5164..ea0de87 100644 --- a/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs +++ b/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs @@ -1,9 +1,11 @@ using GuildWarsPartySearch.Common.Models.GuildWars; +using GuildWarsPartySearch.Server.Models.Endpoints; namespace GuildWarsPartySearch.Server.Services.Database; public interface IPartySearchDatabase { + Task> GetAllPartySearches(); Task SetPartySearches(Campaign campaign, Continent continent, Region region, Map map, string district, List partySearch); Task?> GetPartySearches(Campaign campaign, Continent continent, Region region, Map map, string district); } diff --git a/GuildWarsPartySearch/Services/Database/InMemoryPartySearchDatabase.cs b/GuildWarsPartySearch/Services/Database/InMemoryPartySearchDatabase.cs index ba62a94..ce857d4 100644 --- a/GuildWarsPartySearch/Services/Database/InMemoryPartySearchDatabase.cs +++ b/GuildWarsPartySearch/Services/Database/InMemoryPartySearchDatabase.cs @@ -1,4 +1,5 @@ using GuildWarsPartySearch.Common.Models.GuildWars; +using GuildWarsPartySearch.Server.Models.Endpoints; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Core.Extensions; @@ -8,7 +9,7 @@ namespace GuildWarsPartySearch.Server.Services.Database; public sealed class InMemoryPartySearchDatabase : IPartySearchDatabase { - private static readonly ConcurrentDictionary> partySearchCache = new(); + private static readonly ConcurrentDictionary<(Campaign Campaign, Continent Continent, Region Region, Map Map, string District), List> PartySearchCache = new(); private readonly ILogger logger; @@ -22,7 +23,7 @@ public Task SetPartySearches(Campaign campaign, Continent continent, Regio { var scopedLogger = this.logger.CreateScopedLogger(nameof(SetPartySearches), string.Empty); var key = BuildKey(campaign, continent, region, map, district); - partySearchCache[key] = partySearch; + PartySearchCache[key] = partySearch; scopedLogger.LogInformation($"Set cache for {key}"); return Task.FromResult(true); } @@ -32,7 +33,7 @@ public Task SetPartySearches(Campaign campaign, Continent continent, Regio district = district.Replace("%20", " "); var scopedLogger = this.logger.CreateScopedLogger(nameof(GetPartySearches), string.Empty); var key = BuildKey(campaign, continent, region, map, district); - if (!partySearchCache.TryGetValue(key, out var partySearch)) + if (!PartySearchCache.TryGetValue(key, out var partySearch)) { return Task.FromResult?>(default); } @@ -40,10 +41,29 @@ public Task SetPartySearches(Campaign campaign, Continent continent, Regio return Task.FromResult?>(partySearch); } - private static string BuildKey(Campaign campaign, Continent continent, Region region, Map map, string district) + public Task> GetAllPartySearches() { - return $"{campaign.Name};{continent.Name};{region.Name};{map.Name};{district}"; + return Task.FromResult(PartySearchCache.Select(t => + { + return new PartySearchUpdate + { + Campaign = t.Key.Campaign, + Continent = t.Key.Continent, + Region = t.Key.Region, + Map = t.Key.Map, + District = t.Key.District, + PartySearchEntries = t.Value.Select(e => new PartySearchEntry + { + Npcs = e.Npcs, + PartyMaxSize = e.PartyMaxSize, + PartySize = e.PartySize, + }).ToList() + }; + }).ToList()); } - + private static (Campaign Campaign, Continent Continent, Region Region, Map Map, string District) BuildKey(Campaign campaign, Continent continent, Region region, Map map, string district) + { + return (campaign, continent, region, map, district); + } } diff --git a/GuildWarsPartySearch/Services/Feed/ILiveFeedService.cs b/GuildWarsPartySearch/Services/Feed/ILiveFeedService.cs new file mode 100644 index 0000000..4b85249 --- /dev/null +++ b/GuildWarsPartySearch/Services/Feed/ILiveFeedService.cs @@ -0,0 +1,11 @@ +using GuildWarsPartySearch.Server.Models.Endpoints; +using MTSC.ServerSide; + +namespace GuildWarsPartySearch.Server.Services.Feed; + +public interface ILiveFeedService +{ + void AddClient(ClientData client); + void RemoveClient(ClientData client); + void PushUpdate(MTSC.ServerSide.Server server, PartySearchUpdate partySearchUpdate); +} diff --git a/GuildWarsPartySearch/Services/Feed/LiveFeedService.cs b/GuildWarsPartySearch/Services/Feed/LiveFeedService.cs new file mode 100644 index 0000000..2218430 --- /dev/null +++ b/GuildWarsPartySearch/Services/Feed/LiveFeedService.cs @@ -0,0 +1,66 @@ +using GuildWarsPartySearch.Server.Models.Endpoints; +using MTSC.Common.WebSockets; +using MTSC.ServerSide; +using Newtonsoft.Json; +using System.Text; + +namespace GuildWarsPartySearch.Server.Services.Feed; + +public sealed class LiveFeedService : ILiveFeedService +{ + private static readonly List Clients = []; + + public void AddClient(ClientData client) + { + AddClientInternal(client); + } + + public void PushUpdate(MTSC.ServerSide.Server server, PartySearchUpdate partySearchUpdate) + { + var payloadString = JsonConvert.SerializeObject(partySearchUpdate); + var payload = Encoding.UTF8.GetBytes(payloadString); + var websocketMessage = new WebsocketMessage + { + Data = payload, + FIN = true, + Opcode = WebsocketMessage.Opcodes.Text + }; + var messageBytes = websocketMessage.GetMessageBytes(); + ExecuteOnClientsInternal(client => + { + server.QueueMessage(client, messageBytes); + }); + } + + public void RemoveClient(ClientData client) + { + RemoveClientInternal(client); + } + + private static void AddClientInternal(ClientData clientData) + { + lock (Clients) + { + Clients.Add(clientData); + } + } + + private static void RemoveClientInternal(ClientData clientData) + { + lock (Clients) + { + Clients.Remove(clientData); + } + } + + private static void ExecuteOnClientsInternal(Action action) + { + lock (Clients) + { + foreach (var client in Clients) + { + action(client); + } + } + } +} diff --git a/GuildWarsPartySearch/Services/Logging/ConsoleLogger.cs b/GuildWarsPartySearch/Services/Logging/ConsoleLogger.cs index e47a87d..c10a84e 100644 --- a/GuildWarsPartySearch/Services/Logging/ConsoleLogger.cs +++ b/GuildWarsPartySearch/Services/Logging/ConsoleLogger.cs @@ -10,6 +10,11 @@ public ConsoleLogger() public void WriteLog(Log log) { + if (log.LogLevel is Microsoft.Extensions.Logging.LogLevel.Debug or Microsoft.Extensions.Logging.LogLevel.Trace) + { + return; + } + Console.WriteLine($"[{log.LogLevel}] [{log.LogTime.ToString("s")}] [{log.Category}] [{log.CorrelationVector}]\n{log.Message}\n{log.Exception}"); } } diff --git a/GuildWarsPartySearch/Services/Options/JsonOptionsManager.cs b/GuildWarsPartySearch/Services/Options/JsonOptionsManager.cs new file mode 100644 index 0000000..6411852 --- /dev/null +++ b/GuildWarsPartySearch/Services/Options/JsonOptionsManager.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Configuration; + +namespace GuildWarsPartySearch.Server.Services.Options; + +public sealed class JsonOptionsManager : IOptionsManager +{ + private const string ConfigurationFile = "Config.json"; + private static readonly Dictionary Configuration = []; + + public JsonOptionsManager() + { + LoadConfiguration(); + } + + public T GetOptions() where T : class + { + if (!Configuration.TryGetValue(typeof(T).Name, out var token) || + token is null) + { + throw new InvalidOperationException($"Unable to find entry {typeof(T).Name}"); + } + + return token.ToObject() ?? throw new InvalidOperationException($"Unable to deserialize entry {typeof(T).Name}"); + } + + public void UpdateOptions(T value) where T : class + { + throw new NotImplementedException(); + } + + private static void LoadConfiguration() + { + Configuration.Clear(); + if (!File.Exists(ConfigurationFile)) + { + throw new InvalidOperationException("Unable to load configuration"); + } + + var config = File.ReadAllText(ConfigurationFile); + var configObj = JsonConvert.DeserializeObject(config); + if (configObj is null) + { + throw new InvalidOperationException("Unable to load configuration"); + } + + foreach(var prop in configObj) + { + Configuration[prop.Key] = prop.Value; + } + } +} diff --git a/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs b/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs index f572b35..ddaa44d 100644 --- a/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs +++ b/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs @@ -6,6 +6,8 @@ namespace GuildWarsPartySearch.Server.Services.PartySearch; public interface IPartySearchService { + Task> GetAllPartySearches(); + Task> PostPartySearch(PostPartySearchRequest? request); Task, GetPartySearchFailure>> GetPartySearch(Campaign? campaign, Continent? continent, Region? region, Map? map, string? district); diff --git a/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs b/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs index 25298ae..93cbd1b 100644 --- a/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs +++ b/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs @@ -20,6 +20,11 @@ public PartySearchService( this.logger = logger.ThrowIfNull(); } + public Task> GetAllPartySearches() + { + return this.partySearchDatabase.GetAllPartySearches(); + } + public async Task, GetPartySearchFailure>> GetPartySearch(Campaign? campaign, Continent? continent, Region? region, Map? map, string? district) { if (campaign is null)