diff --git a/.docfx/Dockerfile.docfx b/.docfx/Dockerfile.docfx index 04c49b3..9d42262 100644 --- a/.docfx/Dockerfile.docfx +++ b/.docfx/Dockerfile.docfx @@ -1,4 +1,4 @@ -ARG NGINX_VERSION=1.30.0-alpine +ARG NGINX_VERSION=1.31.0-alpine FROM --platform=$BUILDPLATFORM nginx:${NGINX_VERSION} AS base RUN rm -rf /usr/share/nginx/html/* diff --git a/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt b/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt index 9352e9a..6165b8c 100644 --- a/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.0.6 Availability: .NET 10 and .NET 9 diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c351c..229ae8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba > [!NOTE] > Changelog entries prior to version 8.4.0 was migrated from previous versions of Cuemon.Extensions.Asp.Versioning. +## [10.0.7] - 2026-05-23 + +This is a service update that focuses on package dependencies and explicit dual-framework support through conditionally-targeted Asp.Versioning package versions, ensuring compatibility with .NET 9 (Asp.Versioning 8.1.1) and .NET 10 (Asp.Versioning 10.0.0). + +### Changed + +- `Directory.Packages.props` to conditionally target Asp.Versioning package versions: version 8.1.1 for .NET 9 target framework and version 10.0.0 for .NET 10 target framework, providing version-appropriate behavior for each framework, +- Dependencies upgraded to the latest compatible versions for all supported target frameworks (.NET 10 and .NET 9). + ## [10.0.6] - 2026-04-18 This is a service update that focuses on package dependencies. @@ -120,7 +129,9 @@ This major release is first and foremost focused on ironing out any wrinkles tha - RestfulApiVersionReader class in the Codebelt.Extensions.Asp.Versioning namespace that represents a RESTful API version reader that reads the value from a filtered list of HTTP Accept headers in the request - RestfulProblemDetailsFactory class in the Codebelt.Extensions.Asp.Versioning namespace that represents a RESTful implementation of the IProblemDetailsFactory which throws variants of HttpStatusCodeException that needs to be translated accordingly -[Unreleased]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.5...HEAD +[Unreleased]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.7...HEAD +[10.0.7]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.6...v10.0.7 +[10.0.6]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.5...v10.0.6 [10.0.5]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.4...v10.0.5 [10.0.4]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.3...v10.0.4 [10.0.3]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.2...v10.0.3 diff --git a/Directory.Packages.props b/Directory.Packages.props index 52de1f8..4e49a37 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,23 +3,31 @@ true - - - - - - - - - - - - + + + + + + + + - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/ApplicationBuilderExtensionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/ApplicationBuilderExtensionsTest.cs new file mode 100644 index 0000000..c8572ec --- /dev/null +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/ApplicationBuilderExtensionsTest.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading.Tasks; +using Codebelt.Extensions.Asp.Versioning.Assets; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning +{ + public class ApplicationBuilderExtensionsTest : Test + { + public ApplicationBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + private static void ConfigureServices(IServiceCollection services) + { + // Use minimal controller registration. + // [ApiController]'s ClientErrorResultFilter automatically converts StatusCode(4xx) results + // to problem details (sets application/problem+json content-type), which prevents + // UseStatusCodePages from firing (it requires content-type to be null). + // The minimal-API lambda endpoints added in ConfigureApp bypass [ApiController] filters. + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + } + + private static void ConfigureApp(IApplicationBuilder app, Func factory = null) + { + if (factory != null) + { + app.UseRestfulApiVersioning(factory); + } + else + { + app.UseRestfulApiVersioning(); + } + + app.UseRouting(); + app.UseEndpoints(endpoints => + { + // Minimal-API lambda endpoints: just set the status code with no content-type. + // These bypass [ApiController]'s ClientErrorResultFilter so UseStatusCodePages can fire. + endpoints.MapGet("/test/not-acceptable", ctx => + { + ctx.Response.StatusCode = StatusCodes.Status406NotAcceptable; + return Task.CompletedTask; + }); + endpoints.MapGet("/test/teapot", ctx => + { + ctx.Response.StatusCode = StatusCodes.Status418ImATeapot; + return Task.CompletedTask; + }); + }); + } + + [Fact] + public async Task UseRestfulApiVersioning_WithCustomFactory_ShouldInvokeProvidedFactory() + { + var customFactoryInvoked = false; + + using (var app = WebHostTestFactory.Create(ConfigureServices, appBuilder => + { + ConfigureApp(appBuilder, context => + { + customFactoryInvoked = true; + return new NotAcceptableException("Custom factory: version not acceptable"); + }); + })) + { + var client = app.Host.GetTestClient(); + + // /test/not-acceptable is a minimal-API endpoint that sets 406 with no content-type. + // UseStatusCodePages fires; the custom factory (non-null ??= branch) is invoked. + var ex = await Assert.ThrowsAsync(() => client.GetAsync("/test/not-acceptable")); + + Assert.True(customFactoryInvoked); + Assert.StartsWith("Custom factory:", ex.Message); + } + } + + [Fact] + public async Task UseRestfulApiVersioning_DefaultFactory_WithMappedStatusCode_ShouldThrowCorrespondingException() + { + using (var app = WebHostTestFactory.Create(ConfigureServices, app => ConfigureApp(app))) + { + var client = app.Host.GetTestClient(); + + // /test/not-acceptable returns 406 with no content-type. + // UseStatusCodePages fires; default factory invoked with Response.StatusCode=406. + // TryParse(406) succeeds -> NotAcceptableException is returned and thrown. + await Assert.ThrowsAsync(() => client.GetAsync("/test/not-acceptable")); + } + } + + [Fact] + public async Task UseRestfulApiVersioning_DefaultFactory_WithUnmappedStatusCode_ShouldThrowInternalServerErrorException() + { + using (var app = WebHostTestFactory.Create(ConfigureServices, app => ConfigureApp(app))) + { + var client = app.Host.GetTestClient(); + + // /test/teapot returns 418 with no content-type. + // UseStatusCodePages fires; default factory invoked with Response.StatusCode=418. + // TryParse(418) fails (418 is not in Cuemon's mapped codes) -> InternalServerErrorException. + await Assert.ThrowsAsync(() => client.GetAsync("/test/teapot")); + } + } + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs index 5cf8010..766113c 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs @@ -1,6 +1,9 @@ -using Cuemon.AspNetCore.Http; +using System.Threading.Tasks; +using Cuemon.AspNetCore.Http; using Cuemon.AspNetCore.Mvc.Filters.Diagnostics; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Options; namespace Codebelt.Extensions.Asp.Versioning.Assets @@ -34,5 +37,35 @@ public IActionResult GetException() { throw new GoneException(); } + + [HttpGet] + [Route("not-acceptable")] + public IActionResult GetNotAcceptable() + { + return StatusCode(StatusCodes.Status406NotAcceptable); + } + + [HttpGet] + [Route("teapot")] + public IActionResult GetTeapot() + { + return StatusCode(StatusCodes.Status418ImATeapot); + } + + [HttpGet] + [Route("problem418")] + public async Task GetProblem418([FromServices] IProblemDetailsService problemDetailsService) + { + await problemDetailsService.WriteAsync(new ProblemDetailsContext + { + HttpContext = HttpContext, + ProblemDetails = new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Status = StatusCodes.Status418ImATeapot, + Detail = "I'm a teapot - an unmapped status code" + } + }); + return new EmptyResult(); + } } } diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs index 4c2a284..83b1be0 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs @@ -72,6 +72,16 @@ public void RestfulApiVersioningOptions_ValidAcceptHeadersIsNull_ShouldThrowInva Assert.IsType(sut3.InnerException); } + [Fact] + public void RestfulApiVersioningOptions_UseApiVersionSelector_ShouldUpdateApiVersionSelectorType() + { + var sut = new RestfulApiVersioningOptions(); + var result = sut.UseApiVersionSelector(); + + Assert.Same(sut, result); + Assert.Equal(typeof(LowestImplementedApiVersionSelector), sut.ApiVersionSelectorType); + } + [Fact] public void RestfulApiVersioningOptions_ShouldHaveDefaultValues() { diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceCollectionExtensionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000..2f223df --- /dev/null +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceCollectionExtensionsTest.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Codebelt.Extensions.Asp.Versioning.Assets; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning +{ + public class ServiceCollectionExtensionsTest : Test + { + public ServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddRestfulApiVersioning_ShouldConfigureApiExplorerOptions_WithExpectedGroupNameFormat() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(o => + { + o.ParameterName = "version"; + o.DefaultApiVersion = new ApiVersion(2, 0); + }); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => routes.MapControllers()); + })) + { + var options = app.Host.Services.GetRequiredService>().Value; + + TestOutput.WriteLine($"GroupNameFormat: {options.GroupNameFormat}"); + TestOutput.WriteLine($"SubstituteApiVersionInUrl: {options.SubstituteApiVersionInUrl}"); + TestOutput.WriteLine($"DefaultApiVersion: {options.DefaultApiVersion}"); + + Assert.Equal("'version'VVV", options.GroupNameFormat); + Assert.True(options.SubstituteApiVersionInUrl); + Assert.Equal(new ApiVersion(2, 0), options.DefaultApiVersion); + } + } + + [Fact] + public async Task AddRestfulApiVersioning_CustomizeProblemDetails_ShouldThrowInternalServerErrorException_WhenStatusCodeIsUnmapped() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => routes.MapControllers()); + })) + { + var client = app.Host.GetTestClient(); + + // /fake/problem418 invokes IProblemDetailsService.WriteAsync with Status=418 + // CustomizeProblemDetails fires; TryParse(418) fails (418 is not in Cuemon's mapped codes) + // -> throws InternalServerErrorException (line 57 in ServiceCollectionExtensions.cs) + await Assert.ThrowsAsync(() => client.GetAsync("/fake/problem418")); + } + } + } +}