diff --git a/README.md b/README.md index de96ba4f..e4df759a 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,16 @@ This readme describes how to deploy the application using the pre-built containe **turnierplan.NET** comes as a pre-built container image which can be deployed with minimal configuration. The image is available on GitHub: [ghcr.io/turnierplan-net/turnierplan](https://github.com/turnierplan-NET/turnierplan.NET/pkgs/container/turnierplan) -In the simplest case, run the container directly using the following command. Make sure to substitute the correct PostgreSQL database connection string: +In the simplest case, you can configure the container to use an in-memory data store. Note that this in-memory store is only meant for quick testing and is *not stable* for production! ```shell -docker run -p 80:8080 -e Turnierplan__ApplicationUrl="http://localhost" -e Database__ConnectionString="" ghcr.io/turnierplan-net/turnierplan:latest +docker run -p 80:8080 -e Turnierplan__ApplicationUrl="http://localhost" -e Database__InMemory="true" ghcr.io/turnierplan-net/turnierplan:latest +``` + +A PostgreSQL database can be configured by specifying the `Database__ConnectionString` environment variable: + +```shell +docker run -p 80:8080 -e Turnierplan__ApplicationUrl="http://localhost" -e Database__ConnectionString="" ghcr.io/turnierplan-net/turnierplan:latest ``` The credentials of the initial admin user are displayed in the container logs. @@ -191,26 +197,6 @@ Below are some screenshots of the application: ## Turnierplan.Adapter -If you want to use the **turnierplan.NET** API programatically in a .NET environment, you can use the `Turnierplan.Adapter` [package](https://www.nuget.org/packages/Turnierplan.Adapter) which contains all model classes and an abstraction layer to easily query the API endpoints. - -Add the package reference to your project: +If you want to use the **turnierplan.NET** API programatically in a .NET environment, you can use the `Turnierplan.Adapter` [NuGet package](https://www.nuget.org/packages/Turnierplan.Adapter) which contains all model classes and an abstraction layer to easily query the API endpoints. -```csproj - - - -``` - -In your program, instantiate the `TurnierplanClient` class providing your API key. Finally, you can query the API! - -```cs -var config = new TurnierplanClientOptions("http://localhost:45000", "", "") -{ - UserAgent = "" -}; - -using var client = new TurnierplanClient(config); - -var x = await client.GetTournaments(""); -var y = await client.GetTournament(""); -``` +Please refer to the [package readme](src/Turnierplan.Adapter/README.md) for details and usage examples. diff --git a/src/Turnierplan.Adapter.Test.Functional/GlobalUsings.cs b/src/Turnierplan.Adapter.Test.Functional/GlobalUsings.cs new file mode 100644 index 00000000..5a482fe0 --- /dev/null +++ b/src/Turnierplan.Adapter.Test.Functional/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using FluentAssertions; +global using Xunit; diff --git a/src/Turnierplan.Adapter.Test.Functional/Turnierplan.Adapter.Test.Functional.csproj b/src/Turnierplan.Adapter.Test.Functional/Turnierplan.Adapter.Test.Functional.csproj new file mode 100644 index 00000000..d5e05c36 --- /dev/null +++ b/src/Turnierplan.Adapter.Test.Functional/Turnierplan.Adapter.Test.Functional.csproj @@ -0,0 +1,33 @@ + + + + + net9.0 + enable + enable + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/src/Turnierplan.Adapter.Test.Functional/TurnierplanAdapterTest.cs b/src/Turnierplan.Adapter.Test.Functional/TurnierplanAdapterTest.cs new file mode 100644 index 00000000..da14a4f1 --- /dev/null +++ b/src/Turnierplan.Adapter.Test.Functional/TurnierplanAdapterTest.cs @@ -0,0 +1,420 @@ +using System.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Turnierplan.Adapter.Enums; +using Turnierplan.Adapter.Models; +using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Extensions; +using Turnierplan.Core.Folder; +using Turnierplan.Core.Organization; +using Turnierplan.Core.RoleAssignment; +using Turnierplan.Core.Tournament; +using Turnierplan.Dal; +using Group = Turnierplan.Adapter.Models.Group; +using GroupParticipant = Turnierplan.Adapter.Models.GroupParticipant; +using Match = Turnierplan.Adapter.Models.Match; +using MatchOutcomeType = Turnierplan.Core.Tournament.MatchOutcomeType; +using MatchType = Turnierplan.Adapter.Enums.MatchType; +using Team = Turnierplan.Adapter.Models.Team; +using TeamGroupStatistics = Turnierplan.Adapter.Models.TeamGroupStatistics; +using Tournament = Turnierplan.Core.Tournament.Tournament; +using Visibility = Turnierplan.Core.Tournament.Visibility; + +namespace Turnierplan.Adapter.Test.Functional; + +public sealed class TurnierplanAdapterTest +{ + [Fact] + public async Task Turnierplan_Client_Works_As_Expected_With_Test_Server() + { + var server = new TestServer(); + SeedingResult seedingResult; + + await using (var scope = server.Services.CreateAsyncScope()) + { + seedingResult = await SeedDatabaseAsync( + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService>()); + } + + var options = new TurnierplanClientOptions( + server.ClientOptions.BaseAddress.ToString(), + seedingResult.ApiKeyId, + seedingResult.ApiKeySecret); + + var client = new TurnierplanClient(server.CreateClient(), options); + + var tournaments = await client.GetTournaments(seedingResult.FolderId); + + tournaments.Should().BeEquivalentTo([ + new TournamentHeader + { + Id = seedingResult.Tournament1Id, + Name = "T1", + OrganizationName = "TestOrg", + FolderName = "TestFolder", + Visibility = Enums.Visibility.Private + }, + new TournamentHeader + { + Id = seedingResult.Tournament2Id, + Name = "T2", + OrganizationName = "TestOrg", + FolderName = "TestFolder", + Visibility = Enums.Visibility.Public + } + ]); + + var tournament1 = await client.GetTournament(seedingResult.Tournament1Id); + var tournament2 = await client.GetTournament(seedingResult.Tournament2Id); + + tournament1.Should().BeEquivalentTo(new Turnierplan.Adapter.Models.Tournament + { + Id = seedingResult.Tournament1Id, + Name = "T1", + OrganizationName = "TestOrg", + FolderName = "TestFolder", + VenueName = null, + Visibility = Enums.Visibility.Private, + PublicPageViews = 0, + Teams = [], + Groups = [], + Matches = [], + Rankings = [] + }); + + tournament2.Should().BeEquivalentTo(new Turnierplan.Adapter.Models.Tournament + { + Id = seedingResult.Tournament2Id, + Name = "T2", + OrganizationName = "TestOrg", + FolderName = "TestFolder", + VenueName = null, + Visibility = Enums.Visibility.Public, + PublicPageViews = 3, + Teams = [ + new Team + { + Id = 1, + Name = "Team 1", + OutOfCompetition = false + }, + new Team + { + Id = 2, + Name = "Team 2", + OutOfCompetition = false + }, + new Team + { + Id = 3, + Name = "Team 3", + OutOfCompetition = false + } + ], + Groups = [ + new Group + { + Id = 4, + AlphabeticalId = 'A', + DisplayName = "Gruppe A", + HasCustomDisplayName = false, + Participants = new[] + { + new GroupParticipant + { + TeamId = 1, + Priority = 0, + Statistics = new TeamGroupStatistics + { + Position = 3, + ScoreFor = 2, + ScoreAgainst = 3, + ScoreDifference = -1, + MatchesPlayed = 1, + MatchesWon = 0, + MatchesDrawn = 0, + MatchesLost = 1, + Points = 0 + } + }, + new GroupParticipant + { + TeamId = 2, + Priority = 0, + Statistics = new TeamGroupStatistics + { + Position = 1, + ScoreFor = 3, + ScoreAgainst = 2, + ScoreDifference = 1, + MatchesPlayed = 1, + MatchesWon = 1, + MatchesDrawn = 0, + MatchesLost = 0, + Points = 3 + } + }, + new GroupParticipant + { + TeamId = 3, + Priority = 2, + Statistics = new TeamGroupStatistics + { + Position = 2, + ScoreFor = 0, + ScoreAgainst = 0, + ScoreDifference = 0, + MatchesPlayed = 0, + MatchesWon = 0, + MatchesDrawn = 0, + MatchesLost = 0, + Points = 0 + } + } + } + } + ], + Matches = [ + new Match + { + Id = 5, + Index = 1, + Court = 0, + Kickoff = null, + Type = MatchType.GroupMatch, + FormattedType = "Gruppenspiel", + GroupId = 4, + TeamA = new MatchTeamInfo + { + TeamSelector = new TeamSelector + { + Key = "G4/0", + Localized = "Mannschaft 1, Gruppe A" + }, + TeamId = 1, + Score = 2 + }, + TeamB = new MatchTeamInfo + { + TeamSelector = new TeamSelector + { + Key = "G4/1", + Localized = "Mannschaft 2, Gruppe A" + }, + TeamId = 2, + Score = 3 + }, + State = MatchState.Finished, + OutcomeType = Enums.MatchOutcomeType.Standard + }, + new Match + { + Id = 6, + Index = 2, + Court = 0, + Kickoff = null, + Type = MatchType.GroupMatch, + FormattedType = "Gruppenspiel", + GroupId = 4, + TeamA = new MatchTeamInfo + { + TeamSelector = new TeamSelector + { + Key = "G4/1", + Localized = "Mannschaft 2, Gruppe A" + }, + TeamId = 2, + Score = 1 + }, + TeamB = new MatchTeamInfo + { + TeamSelector = new TeamSelector + { + Key = "G4/2", + Localized = "Mannschaft 3, Gruppe A" + }, + TeamId = 3, + Score = 1 + }, + State = MatchState.CurrentlyPlaying, + OutcomeType = Enums.MatchOutcomeType.AfterOvertime + }, + new Match + { + Id = 7, + Index = 3, + Court = 0, + Kickoff = null, + Type = MatchType.GroupMatch, + FormattedType = "Gruppenspiel", + GroupId = 4, + TeamA = new MatchTeamInfo + { + TeamSelector = new TeamSelector + { + Key = "G4/2", + Localized = "Mannschaft 3, Gruppe A" + }, + TeamId = 3, + Score = null + }, + TeamB = new MatchTeamInfo + { + TeamSelector = new TeamSelector + { + Key = "G4/0", + Localized = "Mannschaft 1, Gruppe A" + }, + TeamId = 1, + Score = null + }, + State = MatchState.NotStarted, + OutcomeType = null + } + ], + Rankings = [ + new Ranking + { + PlacementRank = 1, + IsDefined = false, + TeamId = null + }, + new Ranking + { + PlacementRank = 2, + IsDefined = false, + TeamId = null + }, + new Ranking + { + PlacementRank = 3, + IsDefined = false, + TeamId = null + } + ] + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Turnierplan_Client_Throws_Exception_When_Version_Does_Not_Match(bool sendHeader) + { + using var httpClient = new HttpClient(new MockHttpMessageHandler(_ => + { + var response = new HttpResponseMessage(); + response.StatusCode = HttpStatusCode.Unauthorized; + + if (sendHeader) + { + response.Headers.Add("x-turnierplan-version", "2024.0.0"); // old version that does not exist + } + + return response; + })); + + var options = new TurnierplanClientOptions(new Uri("http://localhost"), "_", "_"); + var client = new TurnierplanClient(httpClient, options); + + var action = async () => + { + _ = await client.GetTournament("x"); + }; + + var actualVersion = typeof(TurnierplanClient).Assembly.GetName().Version!.ToString(); + var expectedMessage = sendHeader + ? $"Server version '2024.0.0' does not match the Turnierplan.Adapter version '{actualVersion}'." + : "Could not get 'X-Turnierplan-Version' header from response."; + + await action.Should() + .ThrowAsync() + .WithMessage(expectedMessage); + } + + private static async Task SeedDatabaseAsync(TurnierplanContext context, IPasswordHasher secretHasher) + { + var organization = new Organization("TestOrg"); + + var folder = new Folder(organization, "TestFolder"); + + var tournament1 = new Tournament(organization, "T1", Visibility.Private); + var tournament2 = new Tournament(organization, "T2", Visibility.Public); + + tournament1.SetFolder(folder); + tournament2.SetFolder(folder); + + { + var team1 = tournament2.AddTeam("Team 1"); + var team2 = tournament2.AddTeam("Team 2"); + var team3 = tournament2.AddTeam("Team 3"); + + var group = tournament2.AddGroup('A'); + + tournament2.AddGroupParticipant(group, team1); + tournament2.AddGroupParticipant(group, team2); + tournament2.AddGroupParticipant(group, team3, 2); + + tournament2.GenerateMatchPlan(new MatchPlanConfiguration + { + GroupRoundConfig = new GroupRoundConfig + { + GroupMatchOrder = GroupMatchOrder.Alternating, + GroupPhaseRounds = 1 + }, + FinalsRoundConfig = null, + ScheduleConfig = null + }); + + tournament2.Matches[0].SetOutcome(false, 2, 3, MatchOutcomeType.Standard); + tournament2.Matches[1].SetOutcome(true, 1, 1, MatchOutcomeType.AfterOvertime); + + tournament2.IncrementPublicPageViews(); + tournament2.IncrementPublicPageViews(); + tournament2.IncrementPublicPageViews(); + } + + var apiKey = new ApiKey(organization, "Test", null, DateTime.UtcNow + TimeSpan.FromDays(1)); + apiKey.AssignNewSecret(plainText => secretHasher.HashPassword(apiKey, plainText), out var secretPlainText); + + organization.AddRoleAssignment(Role.Owner, apiKey.AsPrincipal()); + + context.Organizations.Add(organization); + await context.SaveChangesAsync(); + + return new SeedingResult(folder.PublicId.ToString(), tournament1.PublicId.ToString(), tournament2.PublicId.ToString(), apiKey.PublicId.ToString(), secretPlainText); + } + + private sealed record SeedingResult(string FolderId, string Tournament1Id, string Tournament2Id, string ApiKeyId, string ApiKeySecret); + + private sealed class TestServer : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration(config => + { + config.AddInMemoryCollection([ + new KeyValuePair("Database:InMemory", "true") + ]); + }); + } + } + + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public MockHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_handler(request)); + } + } +} diff --git a/src/Turnierplan.Adapter/Models/Group.cs b/src/Turnierplan.Adapter/Models/Group.cs index cd1a7812..ea6be499 100644 --- a/src/Turnierplan.Adapter/Models/Group.cs +++ b/src/Turnierplan.Adapter/Models/Group.cs @@ -1,7 +1,7 @@ namespace Turnierplan.Adapter.Models; /// -/// Represents a single group in the context of a . +/// A group in the context of a . /// public sealed record Group { diff --git a/src/Turnierplan.Adapter/Models/Match.cs b/src/Turnierplan.Adapter/Models/Match.cs index 66ded3da..b9a0e6f9 100644 --- a/src/Turnierplan.Adapter/Models/Match.cs +++ b/src/Turnierplan.Adapter/Models/Match.cs @@ -6,9 +6,8 @@ namespace Turnierplan.Adapter.Models; /// -/// Represents a single match in the context of a . A match has two participants -/// which are computed by evaluating the team selectors. Once the match is finished, each participant is -/// assigned a score. +/// A match in the context of a . A match has two participants which are computed +/// by evaluating the team selectors. Once the match is finished, each participant is assigned a score. /// public sealed record Match { @@ -56,6 +55,7 @@ public sealed record Match /// if this match is a deciding/ranking match. /// /// + /// This value is generated client-side because it is not part of the API definition. [JsonIgnore] [MemberNotNullWhen(true, nameof(GroupId))] public bool IsGroupMatch => GroupId is not null; diff --git a/src/Turnierplan.Adapter/Models/Ranking.cs b/src/Turnierplan.Adapter/Models/Ranking.cs index 3040e1f9..3a0dc44e 100644 --- a/src/Turnierplan.Adapter/Models/Ranking.cs +++ b/src/Turnierplan.Adapter/Models/Ranking.cs @@ -3,8 +3,7 @@ namespace Turnierplan.Adapter.Models; /// -/// Represents a single ranking in the context of a . There are -/// equally many rankings in a tournament as there are teams. +/// A ranking in the context of a . There are equally many rankings in a tournament as there are teams. /// public sealed record Ranking { diff --git a/src/Turnierplan.Adapter/Models/Team.cs b/src/Turnierplan.Adapter/Models/Team.cs index c9fdfc7e..107e788d 100644 --- a/src/Turnierplan.Adapter/Models/Team.cs +++ b/src/Turnierplan.Adapter/Models/Team.cs @@ -1,7 +1,7 @@ namespace Turnierplan.Adapter.Models; /// -/// Represents a single team in the context of a . +/// A team in the context of a . /// public sealed record Team { diff --git a/src/Turnierplan.Adapter/Models/TeamGroupStatistics.cs b/src/Turnierplan.Adapter/Models/TeamGroupStatistics.cs index ac9b9459..3bd16e95 100644 --- a/src/Turnierplan.Adapter/Models/TeamGroupStatistics.cs +++ b/src/Turnierplan.Adapter/Models/TeamGroupStatistics.cs @@ -1,8 +1,7 @@ namespace Turnierplan.Adapter.Models; /// -/// Value object which holds a single s statistics in the context -/// of the to which this team is assigned to. +/// Represents a team's game statistics in the context of its group. /// public sealed record TeamGroupStatistics { diff --git a/src/Turnierplan.Adapter/Models/Tournament.cs b/src/Turnierplan.Adapter/Models/Tournament.cs index 8a7f203f..02048a8c 100644 --- a/src/Turnierplan.Adapter/Models/Tournament.cs +++ b/src/Turnierplan.Adapter/Models/Tournament.cs @@ -3,12 +3,12 @@ namespace Turnierplan.Adapter.Models; /// -/// Represents a single tournament consisting of s, es and s +/// A tournament including all details such as teams, groups and matches. /// public sealed record Tournament { /// - /// The ID of the tournament which is an 11-character sequence of [A-Za-z0-9_-]. + /// The id of the tournament. /// public required string Id { get; init; } @@ -25,7 +25,12 @@ public sealed record Tournament /// /// The name of the folder to which this tournament is assigned or null if not applicable. /// - public required string? FolderName { get; init; } + public string? FolderName { get; init; } + + /// + /// The name of the venue assigned to this tournament or null if not applicable. + /// + public string? VenueName { get; init; } /// /// The visibility of the tournament. diff --git a/src/Turnierplan.Adapter/Models/TournamentHeader.cs b/src/Turnierplan.Adapter/Models/TournamentHeader.cs index 1435177a..69c53ff9 100644 --- a/src/Turnierplan.Adapter/Models/TournamentHeader.cs +++ b/src/Turnierplan.Adapter/Models/TournamentHeader.cs @@ -3,12 +3,12 @@ namespace Turnierplan.Adapter.Models; /// -/// Represents a single tournament but does not include any specific data for brevity. +/// Information about a tournament, excluding any game-specific information for brevity. /// public sealed record TournamentHeader { /// - /// The ID of the tournament which is an 11-character sequence of [A-Za-z0-9_-]. + /// The id of the tournament. /// public required string Id { get; init; } @@ -25,7 +25,7 @@ public sealed record TournamentHeader /// /// The name of the folder to which this tournament is assigned or null if not applicable. /// - public required string? FolderName { get; init; } + public string? FolderName { get; init; } /// /// The visibility of the tournament. diff --git a/src/Turnierplan.Adapter/README.md b/src/Turnierplan.Adapter/README.md new file mode 100644 index 00000000..fabc41d7 --- /dev/null +++ b/src/Turnierplan.Adapter/README.md @@ -0,0 +1,27 @@ +# turnierplan.NET - client library + +*.NET client library for the [turnierplan.NET](https://github.com/turnierplan-NET/turnierplan.NET) API* + +## Introduction + +This NuGet package can be used to query the API endpoints provided by the [turnierplan.NET](https://github.com/turnierplan-NET/turnierplan.NET) application. + +## Usage + +Instantiate the `TurnierplanClientOptions` and provide the base url as well as the credentials: + +```cs +var config = new TurnierplanClientOptions("http://localhost:45000", "", ""); + +using var client = new TurnierplanClient(config); + +var x = await client.GetTournaments(""); +var y = await client.GetTournament(""); +``` + +## Supported Endpoints + +Currently, the `Turnierplan.Adapter` packages supports the following endpoints: + +- `GET /api/tournaments/{id}` - get a single tournament including all details +- `GET /api/tournaments?folderId={id}` - gets all tournaments in a folder diff --git a/src/Turnierplan.Adapter/Turnierplan.Adapter.csproj b/src/Turnierplan.Adapter/Turnierplan.Adapter.csproj index 79170c1e..21072cc2 100644 --- a/src/Turnierplan.Adapter/Turnierplan.Adapter.csproj +++ b/src/Turnierplan.Adapter/Turnierplan.Adapter.csproj @@ -22,6 +22,6 @@ - + diff --git a/src/Turnierplan.Adapter/TurnierplanClient.cs b/src/Turnierplan.Adapter/TurnierplanClient.cs index 2603dd47..5017c30b 100644 --- a/src/Turnierplan.Adapter/TurnierplanClient.cs +++ b/src/Turnierplan.Adapter/TurnierplanClient.cs @@ -2,19 +2,19 @@ using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Net.Http.Headers; using Turnierplan.Adapter.Models; namespace Turnierplan.Adapter; /// -/// Provides an abstraction for accessing the turnierplan.NET public API in the context of a .NET application. +/// Provides an abstraction for accessing the turnierplan.NET API in the context of a .NET application. /// -public sealed partial class TurnierplanClient : IDisposable +public sealed class TurnierplanClient : IDisposable { - private const string TurnierplanVersionHeaderName = "x-turnierplan-version"; + private const string ApiKeyIdHeaderName = "X-Api-Key"; + private const string ApiKeySecretHeaderName = "X-Api-Key-Secret"; + private const string TurnierplanVersionHeaderName = "X-Turnierplan-Version"; private static readonly string __turnierplanAdapterVersion = typeof(TurnierplanClient).Assembly.GetName().Version?.ToString() @@ -28,42 +28,31 @@ public sealed partial class TurnierplanClient : IDisposable private readonly HttpClient _httpClient; private readonly bool _disposeHttpClient; - private readonly bool _disableIdVerification; - private readonly bool _disableVersionVerification; /// - /// Initializes a new instance of the class, using an - /// existing and the specified options. Any modifications to the - /// specified instance are not recognized after - /// this constructor has exited. + /// Initializes a new instance of the class, using an existing + /// and the specified options. /// /// The to use. /// The options to use. - /// The specified is not disposed by this class. + /// + /// The specified is not disposed when this is disposed. + /// public TurnierplanClient(HttpClient httpClient, TurnierplanClientOptions options) { ArgumentException.ThrowIfNullOrWhiteSpace(options.ApiKey); ArgumentException.ThrowIfNullOrWhiteSpace(options.ApiKeySecret); - ArgumentException.ThrowIfNullOrWhiteSpace(options.UserAgent); - ArgumentException.ThrowIfNullOrWhiteSpace(options.ApplicationUrl); _httpClient = httpClient; - _httpClient.BaseAddress = new Uri(options.ApplicationUrl); + _httpClient.BaseAddress = options.ApplicationUri; - _httpClient.DefaultRequestHeaders.Add("X-Api-Key", options.ApiKey); - _httpClient.DefaultRequestHeaders.Add("X-Api-Key-Secret", options.ApiKeySecret); - _httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, options.UserAgent); - - _disableIdVerification = options.DisableIdVerification; - _disableVersionVerification = options.DisableVersionVerification; + _httpClient.DefaultRequestHeaders.Add(ApiKeyIdHeaderName, options.ApiKey); + _httpClient.DefaultRequestHeaders.Add(ApiKeySecretHeaderName, options.ApiKeySecret); } /// - /// Initializes a new instance of the class and the - /// specified options. This constructor will create a new - /// instance which will be disposed automatically by this class. Any modifications to the - /// specified instance are not recognized after - /// this constructor has exited. + /// Initializes a new instance of the class using the specified options. This + /// constructor will create a new instance which will be disposed automatically by this class. /// /// The options to use. public TurnierplanClient(TurnierplanClientOptions options) @@ -80,21 +69,17 @@ public TurnierplanClient(TurnierplanClientOptions options) /// /// /// Thrown if the API returns a non-200 status code or if the response body can not be deserialized. - /// Thrown if is false and the specified is not a valid ID. - /// Thrown if is false and the version of the server does not match the version of the Turnierplan.Adapter library. + /// Thrown if the version of the server does not match the version of the Turnierplan.Adapter library. /// /// - /// To be considered valid, the ID must match the following pattern: [A-Za-z0-9_-]{11} public async Task GetTournament(string tournamentId) { - VerifyId(tournamentId); - var request = new HttpRequestMessage(HttpMethod.Get, $"/api/tournaments/{tournamentId}"); - var response = await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request).ConfigureAwait(false); VerifyServerVersion(response); - return await Deserialize(response); + return await Deserialize(response).ConfigureAwait(false); } /// @@ -106,22 +91,18 @@ public async Task GetTournament(string tournamentId) /// /// /// Thrown if the API returns a non-200 status code or if the response body can not be deserialized. - /// Thrown if is false and the specified is not a valid ID. - /// Thrown if is false and the version of the server does not match the version of the Turnierplan.Adapter library. + /// Thrown if the version of the server does not match the version of the Turnierplan.Adapter library. /// /// - /// To be considered valid, the ID must match the following pattern: [A-Za-z0-9_-]{11} public async Task> GetTournaments(string folderId) { - VerifyId(folderId); - var query = new QueryBuilder { { "folderId", folderId } }; var request = new HttpRequestMessage(HttpMethod.Get, $"/api/tournaments{query}"); - var response = await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request).ConfigureAwait(false); VerifyServerVersion(response); - return await Deserialize>(response); + return await Deserialize>(response).ConfigureAwait(false); } /// @@ -137,41 +118,23 @@ public void Dispose() } } - private void VerifyId(string id) + private static void VerifyServerVersion(HttpResponseMessage response) { - if (_disableIdVerification) - { - return; - } - - if (!ValidIdRegex().IsMatch(id)) - { - throw new TurnierplanClientException("The specified ID does not match the expected format. Specify a correct ID or disable this check using 'DisableIdVerification'."); - } - } - - private void VerifyServerVersion(HttpResponseMessage response) - { - if (_disableVersionVerification) - { - return; - } - if (!response.Headers.TryGetValues(TurnierplanVersionHeaderName, out var headerValue)) { - throw new TurnierplanClientException($"Could not get '{TurnierplanVersionHeaderName}' header from response. Fix the issue or disable the version check using 'DisableServerVersionVerification'."); + throw new TurnierplanClientException($"Could not get '{TurnierplanVersionHeaderName}' header from response."); } var serverVersion = headerValue.FirstOrDefault(); if (string.IsNullOrWhiteSpace(serverVersion)) { - throw new TurnierplanClientException($"Server returned an empty '{TurnierplanVersionHeaderName}' header. Fix the issue or disable the version check using 'DisableServerVersionVerification'."); + throw new TurnierplanClientException($"Server returned an empty '{TurnierplanVersionHeaderName}' header."); } if (!serverVersion.Equals(__turnierplanAdapterVersion)) { - throw new TurnierplanClientException($"Server version '{serverVersion}' does not match the Turnierplan.Adapter version '{__turnierplanAdapterVersion}'. Fix the issue or disable the version check using 'DisableServerVersionVerification'."); + throw new TurnierplanClientException($"Server version '{serverVersion}' does not match the Turnierplan.Adapter version '{__turnierplanAdapterVersion}'."); } } @@ -182,7 +145,7 @@ private static async Task Deserialize(HttpResponseMessage response) throw new TurnierplanClientException($"API returned unexpected status code: {response.StatusCode}"); } - var data = await response.Content.ReadFromJsonAsync(__serializerOptions); + var data = await response.Content.ReadFromJsonAsync(__serializerOptions).ConfigureAwait(false); if (data is null) { @@ -191,7 +154,4 @@ private static async Task Deserialize(HttpResponseMessage response) return data; } - - [GeneratedRegex("^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-]{11}$")] - private static partial Regex ValidIdRegex(); } diff --git a/src/Turnierplan.Adapter/TurnierplanClientException.cs b/src/Turnierplan.Adapter/TurnierplanClientException.cs index b8ff0cbc..63918028 100644 --- a/src/Turnierplan.Adapter/TurnierplanClientException.cs +++ b/src/Turnierplan.Adapter/TurnierplanClientException.cs @@ -1,7 +1,7 @@ namespace Turnierplan.Adapter; /// -/// Represents an error that occurred in the API abstraction layer. +/// An error that occurred in the . /// public sealed class TurnierplanClientException : Exception { diff --git a/src/Turnierplan.Adapter/TurnierplanClientOptions.cs b/src/Turnierplan.Adapter/TurnierplanClientOptions.cs index 2908e50b..4b2fdd8d 100644 --- a/src/Turnierplan.Adapter/TurnierplanClientOptions.cs +++ b/src/Turnierplan.Adapter/TurnierplanClientOptions.cs @@ -1,53 +1,46 @@ namespace Turnierplan.Adapter; /// -/// Represents the options necessary for constructing an instance of . +/// The options required for constructing an instance of . /// public sealed record TurnierplanClientOptions { /// - /// Initializes a new instance of the class with the two required parameters representing the API key to use. + /// Initializes a new instance of the class with the specified options. /// - /// The base URL of the turnierplan.NET instance that should be queried. - /// The API key ID as displayed in the turnierplan.NET Portal. - /// The API key secret as displayed in the turnierplan.NET Portal. - public TurnierplanClientOptions(string applicationUrl, string apiKey, string apiKeySecret) + /// The base URI of the turnierplan.NET instance that should be queried. + /// The id of the API key. + /// The secret of the API key. + public TurnierplanClientOptions(Uri applicationUri, string apiKey, string apiKeySecret) { - ApplicationUrl = applicationUrl; + ApplicationUri = applicationUri; ApiKey = apiKey; ApiKeySecret = apiKeySecret; } /// - /// The base URL of the turnierplan.NET instance that should be queried. + /// Initializes a new instance of the class with the specified options. /// - public string ApplicationUrl { get; } - - /// - /// The API key ID as displayed in the turnierplan.NET portal. - /// - public string ApiKey { get; } - - /// - /// The API key secret as displayed in the turnierplan.NET portal. - /// - public string ApiKeySecret { get; } + /// The base URI of the turnierplan.NET instance that should be queried. + /// The id of the API key. + /// The secret of the API key. + public TurnierplanClientOptions(string applicationUri, string apiKey, string apiKeySecret) + : this(new Uri(applicationUri), apiKey, apiKeySecret) + { + } /// - /// The value that should be included in the "User-Agent" HTTP header. The default is - /// to Turnierplan.Adapter and this property may not be empty or white-space. + /// The base URI of the turnierplan.NET instance that should be queried. /// - public string UserAgent { get; init; } = "Turnierplan.Adapter"; + public Uri ApplicationUri { get; } /// - /// If set to true, the does not verify the ID - /// strings before sending the HTTP requests. + /// The id of the API key. /// - public bool DisableIdVerification { get; init; } = false; + public string ApiKey { get; } /// - /// If set to true, the does not verify the server version against - /// the used version of Turnierplan.Adapter. + /// The secret of the API key. /// - public bool DisableVersionVerification { get; init; } = false; + public string ApiKeySecret { get; } } diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index 84c57cca..6377dbdf 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -22,7 +22,11 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) var logger = scope.ServiceProvider.GetRequiredService>(); var context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(); + if (context.Database.IsNpgsql()) + { + // If the database is in-memory, no migration is necessary + await context.Database.MigrateAsync(); + } var userCount = await context.Users.CountAsync(); diff --git a/src/Turnierplan.App/Program.cs b/src/Turnierplan.App/Program.cs index c6427c0e..29715691 100644 --- a/src/Turnierplan.App/Program.cs +++ b/src/Turnierplan.App/Program.cs @@ -114,3 +114,6 @@ await app.InitializeDatabaseAsync(); await app.RunAsync(); + +// Public class definition is required for functional tests +public sealed partial class Program; diff --git a/src/Turnierplan.Dal/Extensions/ConfigurationExtensions.cs b/src/Turnierplan.Dal/Extensions/ConfigurationExtensions.cs index df01ac67..1f255dd6 100644 --- a/src/Turnierplan.Dal/Extensions/ConfigurationExtensions.cs +++ b/src/Turnierplan.Dal/Extensions/ConfigurationExtensions.cs @@ -4,6 +4,11 @@ namespace Turnierplan.Dal.Extensions; public static class ConfigurationExtensions { + public static bool UseInMemoryDatabase(this IConfiguration configuration) + { + return configuration.GetSection("Database").GetValue("InMemory", false); + } + public static string? GetDatabaseConnectionString(this IConfiguration configuration) { return configuration.GetSection("Database").GetValue("ConnectionString"); diff --git a/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs b/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs index b5fdaabd..0d729930 100644 --- a/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs +++ b/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Azure.Monitor.OpenTelemetry.Exporter; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -23,6 +24,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddTurnierplanDataAccessLayer(this IServiceCollection services, IConfiguration configuration) { + string? inMemoryDatabaseName = null; + services.AddDbContext((sp, options) => { if (sp.GetRequiredService().IsDevelopment()) @@ -30,13 +33,22 @@ public static IServiceCollection AddTurnierplanDataAccessLayer(this IServiceColl options.EnableSensitiveDataLogging(); } - var connectionString = configuration.GetDatabaseConnectionString(); - - options.UseNpgsql(connectionString, npgsqlOptions => + if (configuration.UseInMemoryDatabase()) + { + inMemoryDatabaseName ??= Guid.NewGuid().ToString(); + options.UseInMemoryDatabase(inMemoryDatabaseName); + options.ConfigureWarnings(x => x.Log(InMemoryEventId.TransactionIgnoredWarning)); + } + else { - npgsqlOptions.MigrationsAssembly(typeof(TurnierplanContext).Assembly.GetName().Name); - npgsqlOptions.MigrationsHistoryTable("__EFMigrationHistory", TurnierplanContext.Schema); - }); + var connectionString = configuration.GetDatabaseConnectionString(); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(TurnierplanContext).Assembly.GetName().Name); + npgsqlOptions.MigrationsHistoryTable("__EFMigrationHistory", TurnierplanContext.Schema); + }); + } }); var applicationInsightsConnectionString = configuration.GetSection("ApplicationInsights").GetValue("ConnectionString"); diff --git a/src/Turnierplan.Dal/Turnierplan.Dal.csproj b/src/Turnierplan.Dal/Turnierplan.Dal.csproj index 1b604378..77249aa2 100644 --- a/src/Turnierplan.Dal/Turnierplan.Dal.csproj +++ b/src/Turnierplan.Dal/Turnierplan.Dal.csproj @@ -13,6 +13,7 @@ + diff --git a/src/turnierplan.NET.sln b/src/turnierplan.NET.sln index 0717214f..a6b1c963 100644 --- a/src/turnierplan.NET.sln +++ b/src/turnierplan.NET.sln @@ -32,6 +32,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Files", "Solution EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{56957FEA-CDDB-4D12-B25E-0836A4E62A95}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Turnierplan.Adapter.Test.Functional", "Turnierplan.Adapter.Test.Functional\Turnierplan.Adapter.Test.Functional.csproj", "{BE025CDE-2BE5-4138-A03E-0670314A624E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -86,6 +88,10 @@ Global {A59A3A12-63CE-4BEC-B411-C15709391D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU {A59A3A12-63CE-4BEC-B411-C15709391D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU {A59A3A12-63CE-4BEC-B411-C15709391D2E}.Release|Any CPU.Build.0 = Release|Any CPU + {BE025CDE-2BE5-4138-A03E-0670314A624E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE025CDE-2BE5-4138-A03E-0670314A624E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE025CDE-2BE5-4138-A03E-0670314A624E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE025CDE-2BE5-4138-A03E-0670314A624E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {82CD2098-2466-43C5-9F6A-9FA0E3D641A0} = {56957FEA-CDDB-4D12-B25E-0836A4E62A95} @@ -93,5 +99,6 @@ Global {2E974732-60EC-4AA3-9DB0-3AF56ED34BE8} = {56957FEA-CDDB-4D12-B25E-0836A4E62A95} {E470B250-3682-4462-ADF6-52310773524E} = {56957FEA-CDDB-4D12-B25E-0836A4E62A95} {53A03C1E-3671-4C09-B2A4-DBFB1F4A2604} = {56957FEA-CDDB-4D12-B25E-0836A4E62A95} + {BE025CDE-2BE5-4138-A03E-0670314A624E} = {56957FEA-CDDB-4D12-B25E-0836A4E62A95} EndGlobalSection EndGlobal