diff --git a/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs b/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs index e4f86ad..49a89f7 100644 --- a/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs +++ b/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs @@ -1,4 +1,5 @@ using OpenProject.Api.Data.Models.Create; +using OpenProject.Api.Data.Models.Update; namespace OpenProject.Api.Test.Tests; @@ -23,36 +24,31 @@ public void IsReadOnly_DefaultsToFalse() => new OpenProjectClientOptions { Uri = new Uri("http://127.0.0.1:1"), ApiKey = "k" } .IsReadOnly.Should().BeFalse(); - [Fact] - public async Task ReadOnly_Create_ThrowsInvalidOperationBeforeSending() + private static async Task WasRefusedAsync(bool isReadOnly, Func> call) { - using var client = ClientFor(isReadOnly: true); + using var client = ClientFor(isReadOnly); - var act = () => client.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None); - - var thrown = await act.Should().ThrowAsync(); - IsRefused(thrown.Which).Should().BeTrue(); + var thrown = await FluentActions.Awaiting(() => call(client)).Should().ThrowAsync(); + return IsRefused(thrown.Which); } [Fact] - public async Task NotReadOnly_Create_IsAttemptedAndFailsOnConnectionNotOnPolicy() - { - using var client = ClientFor(isReadOnly: false); + public async Task ReadOnly_Create_ThrowsInvalidOperationBeforeSending() + => (await WasRefusedAsync(true, c => c.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None))) + .Should().BeTrue(); - var act = () => client.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None); + [Fact] + public async Task ReadOnly_WorkPackageUpdate_ThrowsInvalidOperationBeforeSending() + => (await WasRefusedAsync(true, c => c.WorkPackages.UpdateAsync(1, new WorkPackageUpdate { LockVersion = 1 }, CancellationToken.None))) + .Should().BeTrue(); - var thrown = await act.Should().ThrowAsync(); - IsRefused(thrown.Which).Should().BeFalse(); - } + [Fact] + public async Task NotReadOnly_Create_IsAttemptedAndFailsOnConnectionNotOnPolicy() + => (await WasRefusedAsync(false, c => c.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None))) + .Should().BeFalse(); [Fact] public async Task ReadOnly_Get_IsNotRefused() - { - using var client = ClientFor(isReadOnly: true); - - var act = () => client.Projects.GetAllAsync(CancellationToken.None); - - var thrown = await act.Should().ThrowAsync(); - IsRefused(thrown.Which).Should().BeFalse(); - } + => (await WasRefusedAsync(true, c => c.Projects.GetAllAsync(CancellationToken.None))) + .Should().BeFalse(); } diff --git a/OpenProject.Api.Test/Tests/SortByTests.cs b/OpenProject.Api.Test/Tests/SortByTests.cs new file mode 100644 index 0000000..3fd07fd --- /dev/null +++ b/OpenProject.Api.Test/Tests/SortByTests.cs @@ -0,0 +1,22 @@ +using OpenProject.Api.Queries; + +namespace OpenProject.Api.Test.Tests; + +/// +/// renders the sortBy query syntax; no configuration, network or server needed. +/// +public class SortByTests +{ + [Fact] + public void ToString_RendersEachFieldAndDirectionAsAJsonPair() + => new SortBy( + [ + new FieldAndDirection { Field = "id", Direction = Direction.Ascending }, + new FieldAndDirection { Field = "updatedAt", Direction = Direction.Descending } + ]) + .ToString().Should().Be("""[["id","asc"],["updatedAt","desc"]]"""); + + [Fact] + public void ToString_OfNoFields_IsAnEmptyArray() + => new SortBy([]).ToString().Should().Be("[]"); +} diff --git a/OpenProject.Api/AuthenticatedHttpClientHandler.cs b/OpenProject.Api/AuthenticatedHttpClientHandler.cs index a87e5e7..8ada654 100644 --- a/OpenProject.Api/AuthenticatedHttpClientHandler.cs +++ b/OpenProject.Api/AuthenticatedHttpClientHandler.cs @@ -34,29 +34,15 @@ protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - if (_options.IsReadOnly - && request.Method != HttpMethod.Get - && request.Method != HttpMethod.Head - && request.Method != HttpMethod.Options) + if (_options.IsReadOnly && !IsRead(request.Method)) { throw new InvalidOperationException($"The client is read-only; {request.Method} {request.RequestUri?.AbsolutePath} would change the server."); } - // Set basic auth header request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"apikey:{_options.ApiKey}"))); - // Get a GUID to uniquely identify the request var guid = Guid.NewGuid(); - if (_logger?.IsEnabled(LogLevel.Debug) == true) - { - _logger.LogDebug("{Guid}:{RequestMethod}:{RequestUri}\nHeaders:{Headers}\nBody:{Body}", - guid.ToString(), - request.Method, - request.RequestUri, - request.Headers, - request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) - ); - } + await LogRequestAsync(guid, request, cancellationToken).ConfigureAwait(false); HttpResponseMessage response; try @@ -71,6 +57,28 @@ protected override async Task SendAsync( throw; } + await LogResponseAsync(guid, request, response, cancellationToken).ConfigureAwait(false); + return response; + } + + private static bool IsRead(HttpMethod method) + => method == HttpMethod.Get || method == HttpMethod.Head || method == HttpMethod.Options; + + private async Task LogRequestAsync(Guid guid, HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_logger?.IsEnabled(LogLevel.Debug) == true) + { + _logger.LogDebug("{Guid}:{RequestMethod}:{RequestUri}\nHeaders:{Headers}\nBody:{Body}", + guid.ToString(), + request.Method, + request.RequestUri, + request.Headers, + request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + } + } + + private async Task LogResponseAsync(Guid guid, HttpRequestMessage request, HttpResponseMessage response, CancellationToken cancellationToken) + { if (_logger?.IsEnabled(LogLevel.Debug) == true) { _logger.LogDebug("{Guid}:{ResponseStatusCode}:{Body}", @@ -78,8 +86,6 @@ protected override async Task SendAsync( response.StatusCode, request.Content is null ? null : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); } - - return response; } /// diff --git a/OpenProject.Api/Interfaces/Controllers/IWorkPackages.cs b/OpenProject.Api/Interfaces/Controllers/IWorkPackages.cs index c387f87..b4d7bdb 100644 --- a/OpenProject.Api/Interfaces/Controllers/IWorkPackages.cs +++ b/OpenProject.Api/Interfaces/Controllers/IWorkPackages.cs @@ -1,5 +1,6 @@ using OpenProject.Api.Data.Models; using OpenProject.Api.Data.Models.Create; +using OpenProject.Api.Data.Models.Update; using OpenProject.Api.Queries; namespace OpenProject.Api.Interfaces.Controllers; @@ -53,6 +54,18 @@ Task CreateAsync( [Body] WorkPackageCreate entity, CancellationToken cancellationToken); + /// + /// Update a Work Package by ID and return the updated entity + /// + /// The work package ID. + /// The fields to change; LockVersion must match the current version. + /// The cancellation token. + [Patch("/work_packages/{id}")] + Task UpdateAsync( + int id, + [Body] WorkPackageUpdate entity, + CancellationToken cancellationToken); + /// /// Delete a Work Package by ID ///