Skip to content

Commit 5104152

Browse files
authored
Blelkes/response codes (#261)
* Fix endpoint documentation and populate responses with more accurate response codes. Refactored error handling logic into `ErrorResultGeneratorMiddleware`. * Updated Swagger configuration to improve API documentation ordering and readability. * Add integration tests. Moved health check DI registration into separate file. Updated `Program.cs` to be non-static to support testing. Added `.runsettings` to configure the test environment. * Amend runsettings used by pipeline with ASPNETCORE_ENVIRONMENT environment variable. * Revert "Amend runsettings used by pipeline with ASPNETCORE_ENVIRONMENT environment variable." This reverts commit 2d1c28d. * Replace WebApplicationFactory with TestServer. * Removed unused logger and renamed HealthCheckExtensions.cs * UserController cleanup.
1 parent 38eedf8 commit 5104152

File tree

13 files changed

+492
-198
lines changed

13 files changed

+492
-198
lines changed

OneBeyond.Studio.Obelisk.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OneBeyond.Studio.Obelisk.Do
3030
EndProject
3131
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OneBeyond.Studio.Obelisk.Workers", "src\OneBeyond.Studio.Obelisk.Workers\OneBeyond.Studio.Obelisk.Workers.csproj", "{3826B005-74AD-48CA-9CF1-C9BFED6A1196}"
3232
EndProject
33+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneBeyond.Studio.Obelisk.WebApi.Tests", "src\OneBeyond.Studio.Obelisk.WebApi.Tests\OneBeyond.Studio.Obelisk.WebApi.Tests.csproj", "{672DE642-8FAF-46BC-9272-BED0CFA2AE9F}"
34+
EndProject
3335
Global
3436
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3537
Debug|Any CPU = Debug|Any CPU
@@ -68,6 +70,10 @@ Global
6870
{3826B005-74AD-48CA-9CF1-C9BFED6A1196}.Debug|Any CPU.Build.0 = Debug|Any CPU
6971
{3826B005-74AD-48CA-9CF1-C9BFED6A1196}.Release|Any CPU.ActiveCfg = Release|Any CPU
7072
{3826B005-74AD-48CA-9CF1-C9BFED6A1196}.Release|Any CPU.Build.0 = Release|Any CPU
73+
{672DE642-8FAF-46BC-9272-BED0CFA2AE9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
74+
{672DE642-8FAF-46BC-9272-BED0CFA2AE9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
75+
{672DE642-8FAF-46BC-9272-BED0CFA2AE9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
76+
{672DE642-8FAF-46BC-9272-BED0CFA2AE9F}.Release|Any CPU.Build.0 = Release|Any CPU
7177
EndGlobalSection
7278
GlobalSection(SolutionProperties) = preSolution
7379
HideSolutionNode = FALSE

src/OneBeyond.Studio.Obelisk.Application/Features/Users/Dto/CreateUserDto.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ namespace OneBeyond.Studio.Obelisk.Application.Features.Users.Dto;
22

33
public sealed record CreateUserDto
44
{
5-
public string Email { get; private init; } = default!;
6-
public string UserName { get; private init; } = default!;
7-
public string? RoleId { get; private init; }
5+
public required string Email { get; init; }
6+
public required string UserName { get; init; }
7+
public string? RoleId { get; init; }
88
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using FluentAssertions;
4+
using FluentAssertions.Execution;
5+
using Microsoft.AspNetCore.Mvc;
6+
using OneBeyond.Studio.Obelisk.WebApi.Middlewares;
7+
8+
namespace OneBeyond.Studio.Obelisk.WebApi.Tests.Middlewares;
9+
10+
public class ErrorResultGeneratorMiddlewareTests : IClassFixture<TestServerFixture>
11+
{
12+
private readonly HttpClient _client;
13+
14+
public ErrorResultGeneratorMiddlewareTests(TestServerFixture testServer)
15+
{
16+
_client = testServer.Client;
17+
}
18+
19+
[Fact]
20+
public async Task InvokeAsync_NoException_Succeeds()
21+
{
22+
// Act
23+
var response = await _client.GetAsync("/health/live");
24+
25+
// Assert
26+
response.IsSuccessStatusCode.Should().BeTrue();
27+
}
28+
29+
[Fact]
30+
public async Task InvokeAsync_ValidationException_ReturnsBadRequest()
31+
{
32+
// Arrange
33+
var invalidRequest = new { };
34+
35+
// Act
36+
var response = await _client.PostAsJsonAsync("/api/users/v1", invalidRequest);
37+
38+
// Assert
39+
using var _ = new AssertionScope();
40+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
41+
42+
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();
43+
problemDetails?.Title.Should().Be("One or more validation errors occurred.");
44+
45+
var traceId = problemDetails?.Extensions[ErrorResultGeneratorMiddleware.TraceIdKey]?.ToString();
46+
traceId.Should().NotBeNullOrEmpty();
47+
}
48+
49+
[Fact]
50+
public async Task InvokeAsync_EntityNotFoundException_ReturnsNotFound()
51+
{
52+
// Arrange
53+
var nonExistentId = Guid.NewGuid();
54+
55+
// Act
56+
var response = await _client.GetAsync($"/api/users/v1/{nonExistentId}");
57+
58+
// Assert
59+
using var _ = new AssertionScope();
60+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
61+
62+
var mediaType = response.Content.Headers.ContentType?.MediaType;
63+
mediaType.Should().Be(ErrorResultGeneratorMiddleware.ProblemContent);
64+
65+
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();
66+
problemDetails?.Title.Should().Be("Not Found");
67+
68+
var traceId = problemDetails?.Extensions[ErrorResultGeneratorMiddleware.TraceIdKey]?.ToString();
69+
traceId.Should().NotBeNullOrEmpty();
70+
}
71+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="FluentAssertions" Version="6.12.0" />
15+
<PackageReference Include="coverlet.collector" Version="6.0.0" />
16+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.20" />
17+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
18+
<PackageReference Include="Moq" Version="4.20.72" />
19+
<PackageReference Include="xunit" Version="2.5.3" />
20+
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<ProjectReference Include="..\OneBeyond.Studio.Obelisk.WebApi\OneBeyond.Studio.Obelisk.WebApi.csproj" />
25+
</ItemGroup>
26+
27+
<ItemGroup>
28+
<Using Include="Xunit" />
29+
</ItemGroup>
30+
31+
</Project>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Security.Claims;
2+
using System.Text.Encodings.Web;
3+
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace OneBeyond.Studio.Obelisk.WebApi.Tests;
8+
9+
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
10+
{
11+
public const string AuthenticationType = "Test";
12+
public const string AuthenticationScheme = "Test";
13+
14+
public TestAuthHandler(
15+
IOptionsMonitor<AuthenticationSchemeOptions> options,
16+
ILoggerFactory logger,
17+
UrlEncoder encoder)
18+
: base(options, logger, encoder)
19+
{
20+
}
21+
22+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
23+
{
24+
var claims = new[]
25+
{
26+
new Claim(ClaimTypes.Name, "TestUser"),
27+
new Claim(ClaimTypes.Role, "Administrator")
28+
};
29+
var identity = new ClaimsIdentity(claims, AuthenticationType);
30+
var principal = new ClaimsPrincipal(identity);
31+
var ticket = new AuthenticationTicket(principal, AuthenticationScheme);
32+
33+
var result = AuthenticateResult.Success(ticket);
34+
return Task.FromResult(result);
35+
}
36+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using MediatR;
2+
using Microsoft.AspNetCore.Authentication;
3+
using Microsoft.AspNetCore.Builder;
4+
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
5+
using Microsoft.AspNetCore.Hosting;
6+
using Microsoft.AspNetCore.TestHost;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.DependencyInjection.Extensions;
9+
using Microsoft.Extensions.Diagnostics.HealthChecks;
10+
using Microsoft.Extensions.Hosting;
11+
using Microsoft.Extensions.Options;
12+
using Moq;
13+
using OneBeyond.Studio.Application.SharedKernel.Entities.Queries;
14+
using OneBeyond.Studio.Application.SharedKernel.Repositories.Exceptions;
15+
using OneBeyond.Studio.Obelisk.Application.Features.Users.Dto;
16+
using OneBeyond.Studio.Obelisk.Domain.Features.Users.Entities;
17+
using OneBeyond.Studio.Obelisk.WebApi.Controllers;
18+
using OneBeyond.Studio.Obelisk.WebApi.Extensions;
19+
using OneBeyond.Studio.Obelisk.WebApi.Helpers;
20+
using OneBeyond.Studio.Obelisk.WebApi.Middlewares;
21+
22+
namespace OneBeyond.Studio.Obelisk.WebApi.Tests;
23+
24+
public sealed class TestServerFixture : IAsyncLifetime
25+
{
26+
public IHost Host { get; private set; } = default!;
27+
public HttpClient Client { get; private set; } = default!;
28+
29+
public async Task InitializeAsync()
30+
{
31+
Host = await new HostBuilder()
32+
.ConfigureWebHost(webBuilder =>
33+
{
34+
webBuilder
35+
.UseTestServer()
36+
.ConfigureServices((context, services) =>
37+
{
38+
var options = new ClientApplicationOptions { Url = "fake" };
39+
services.TryAddSingleton(Options.Create(options));
40+
41+
var mediatorMock = new Mock<IMediator>();
42+
mediatorMock
43+
.Setup(mediator => mediator.Send(It.IsAny<GetById<GetUserDto, UserBase, Guid>>(),It.IsAny<CancellationToken>()))
44+
.ThrowsAsync(new EntityNotFoundException<User, Guid>(Guid.NewGuid()));
45+
46+
services.TryAddTransient(_ => mediatorMock.Object);
47+
services.TryAddTransient<ClientApplicationLinkGenerator, ClientApplicationLinkGenerator>();
48+
49+
services.AddAuthentication(options =>
50+
{
51+
options.DefaultAuthenticateScheme = TestAuthHandler.AuthenticationScheme;
52+
})
53+
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.AuthenticationScheme, _ => { });
54+
55+
services.AddControllers()
56+
.AddApplicationPart(typeof(UsersController).Assembly);
57+
58+
services.AddApiVersioning();
59+
services.AddHealthChecks()
60+
.AddCheck(HealthCheckExtensions.SelfCheck, () => HealthCheckResult.Healthy());
61+
})
62+
.Configure(app =>
63+
{
64+
app.UseRouting();
65+
app.Use(async (context, next) =>
66+
{
67+
using var activity = new System.Diagnostics.Activity("HttpRequest");
68+
activity.Start();
69+
await next();
70+
});
71+
app.UseMiddleware<ErrorResultGeneratorMiddleware>();
72+
app.UseAuthentication();
73+
app.UseAuthorization();
74+
app.UseEndpoints(endpoints =>
75+
{
76+
endpoints.MapControllers();
77+
endpoints.MapHealthChecks("/health/live", new HealthCheckOptions
78+
{
79+
Predicate = registration => registration.Name.Contains(HealthCheckExtensions.SelfCheck)
80+
});
81+
});
82+
});
83+
})
84+
.StartAsync();
85+
86+
Client = Host.GetTestClient();
87+
}
88+
89+
public async Task DisposeAsync()
90+
{
91+
Client?.Dispose();
92+
93+
if (Host is not null)
94+
{
95+
await Host.StopAsync();
96+
Host.Dispose();
97+
}
98+
}
99+
}

src/OneBeyond.Studio.Obelisk.WebApi/Controllers/QBasedController.cs

Lines changed: 24 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -22,78 +22,51 @@ public abstract class QBasedController<TEntityGetDTO, TEntityListDTO, TEntity, T
2222
{
2323
protected QBasedController(IMediator mediator)
2424
{
25-
EnsureArg.IsNotNull(mediator, nameof(mediator));
26-
27-
Mediator = mediator;
25+
Mediator = EnsureArg.IsNotNull(mediator, nameof(mediator));
2826
}
2927

3028
protected IMediator Mediator { get; }
3129

3230
/// <summary>
3331
/// Gets a list of entities.
3432
/// </summary>
35-
/// <param name="queryParameters"></param>
36-
/// <param name="query"></param>
37-
/// <param name="cancellationToken"></param>
38-
/// <response code="200">If the list of entities returned</response>
39-
/// <response code="400">If the request is invalid</response>
33+
/// <param name="queryParameters">The <see cref="ListRequest"/> representation of the query parameters to apply.</param>
34+
/// <param name="query">TODO!</param>
35+
/// <param name="cancellationToken"><see cref="CancellationToken"/> token to cancel the operation.</param>
36+
/// <returns><see cref="OkResult"/> with a <see cref="PagedList{TEntity}"/> of entities.</returns>
37+
/// <response code="200">Returns a paged list of entities.</response>
38+
/// <response code="400">If a query parameter is invalid.</response>
39+
[HttpGet]
4040
[ProducesResponseType(StatusCodes.Status200OK)]
4141
[ProducesResponseType(StatusCodes.Status400BadRequest)]
42-
[HttpGet]
43-
public virtual async Task<IActionResult> Get(
42+
public virtual async Task<ActionResult<PagedList<TEntityListDTO>>> Get(
4443
[FromQuery] ListRequest queryParameters,
4544
Dictionary<string, IReadOnlyCollection<string>> query,
4645
CancellationToken cancellationToken)
4746
{
4847
query = ControllerHelpers.CleanQuery(query);
49-
var result = await ListAsync(queryParameters, query, cancellationToken);
50-
return Json(result);
48+
var request = queryParameters.ToListQuery<TEntityListDTO, TEntity, TEntityId>(query);
49+
var result = await Mediator.Send(request, cancellationToken);
50+
return result;
5151
}
5252

5353
/// <summary>
5454
/// Gets a specified entity.
5555
/// </summary>
56-
/// <param name="id" example="3fa85f64-5717-4562-b3fc-2c963f66afa6">The ID of the entity</param>
57-
/// <param name="cancellationToken"></param>
58-
/// <response code="200">If the entity is returned</response>
59-
/// <response code="400">If the entity with the specified ID does not exist</response>
56+
/// <param name="id" example="3fa85f64-5717-4562-b3fc-2c963f66afa6">The ID of the entity.</param>
57+
/// <param name="cancellationToken"><see cref="CancellationToken"/> token to cancel the operation.</param>
58+
/// <returns><see cref="OkResult"/> with the <typeparamref name="TEntityGetDTO"/>.</returns>
59+
/// <response code="200">Returns the specified entity.</response>
60+
/// <response code="404">If the entity does not exist.</response>
61+
/// <response code="400">If the ID is invalid.</response>
62+
[HttpGet("{id}")]
6063
[ProducesResponseType(StatusCodes.Status200OK)]
6164
[ProducesResponseType(StatusCodes.Status400BadRequest)]
62-
[HttpGet("{id}")]
63-
public virtual async Task<IActionResult> GetById(
64-
TEntityId id,
65-
CancellationToken cancellationToken)
66-
{
67-
var result = await GetByIdAsync<TEntityGetDTO>(id, cancellationToken);
68-
return result is not null
69-
? Json(result)
70-
: BadRequest("Not found");
71-
}
72-
73-
protected virtual Task<PagedList<TEntityListDTO>> ListAsync(
74-
ListRequest queryParameters,
75-
Dictionary<string, IReadOnlyCollection<string>> query,
76-
CancellationToken cancellationToken)
77-
=> Mediator.Send(
78-
queryParameters.ToListQuery<TEntityListDTO, TEntity, TEntityId>(query),
79-
cancellationToken);
80-
81-
protected virtual Task<TDTO> GetByIdAsync<TDTO>(
82-
TEntityId aggregateRootId,
83-
CancellationToken cancellationToken)
84-
=> Mediator.Send(
85-
new GetById<TDTO, TEntity, TEntityId>(aggregateRootId),
86-
cancellationToken);
87-
}
88-
89-
public abstract class QBasedController<TEntityListDTO, TEntity, TEntityId>
90-
: QBasedController<TEntityListDTO, TEntityListDTO, TEntity, TEntityId>
91-
where TEntity : DomainEntity<TEntityId>
92-
where TEntityListDTO : new()
93-
{
94-
protected QBasedController(
95-
IMediator mediator)
96-
: base(mediator)
65+
[ProducesResponseType(StatusCodes.Status404NotFound)]
66+
public virtual async Task<ActionResult<TEntityGetDTO>> GetById(TEntityId id, CancellationToken cancellationToken)
9767
{
68+
var request = new GetById<TEntityGetDTO, TEntity, TEntityId>(id);
69+
var result = await Mediator.Send(request, cancellationToken);
70+
return result;
9871
}
9972
}

0 commit comments

Comments
 (0)