diff --git a/.bot/README.md b/.bot/README.md index f3aa97d..f7e13f0 100644 --- a/.bot/README.md +++ b/.bot/README.md @@ -1,10 +1,10 @@ -# .bot Workspace - -This folder is reserved for local-only AI working material such as: - -- brainstorm notes -- draft implementation plans -- design alternatives -- temporary agent state - -Keep this folder out of source control. Move only finalized, non-confidential guidance into `AGENTS.md`. +# .bot Workspace + +This folder is reserved for local-only AI working material such as: + +- brainstorm notes +- draft implementation plans +- design alternatives +- temporary agent state + +Keep this folder out of source control. Move only finalized, non-confidential guidance into `AGENTS.md`. diff --git a/.docfx/BuildDocfxImage.ps1 b/.docfx/BuildDocfxImage.ps1 index cd1ddd3..08d6134 100644 --- a/.docfx/BuildDocfxImage.ps1 +++ b/.docfx/BuildDocfxImage.ps1 @@ -1,4 +1,4 @@ -$version = minver -i -t v -v w -docfx metadata docfx.json -docker buildx build -t jcr.codebelt.net/geekle/asp-versioning-docfx:$version --platform linux/arm64,linux/amd64 --load -f Dockerfile.docfx . -get-childItem -recurse -path api -include *.yml, .manifest | remove-item +$version = minver -i -t v -v w +docfx metadata docfx.json +docker buildx build -t jcr.codebelt.net/geekle/asp-versioning-docfx:$version --platform linux/arm64,linux/amd64 --load -f Dockerfile.docfx . +get-childItem -recurse -path api -include *.yml, .manifest | remove-item diff --git a/.docfx/Dockerfile.docfx b/.docfx/Dockerfile.docfx index 57a6e0f..e29eda1 100644 --- a/.docfx/Dockerfile.docfx +++ b/.docfx/Dockerfile.docfx @@ -1,16 +1,16 @@ -ARG NGINX_VERSION=1.31-alpine - -FROM --platform=$BUILDPLATFORM dhi.io/nginx:${NGINX_VERSION} AS base -RUN rm -rf /usr/share/nginx/html/* - -FROM --platform=$BUILDPLATFORM codebeltnet/docfx:2 AS build - -ADD [".", "docfx"] - -RUN cd docfx; \ - docfx build - -FROM dhi.io/nginx:${NGINX_VERSION} AS final -WORKDIR /usr/share/nginx/html -COPY --from=build /build/docfx/wwwroot /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +ARG NGINX_VERSION=1.31-alpine + +FROM --platform=$BUILDPLATFORM dhi.io/nginx:${NGINX_VERSION} AS base +RUN rm -rf /usr/share/nginx/html/* + +FROM --platform=$BUILDPLATFORM codebeltnet/docfx:2 AS build + +ADD [".", "docfx"] + +RUN cd docfx; \ + docfx build + +FROM dhi.io/nginx:${NGINX_VERSION} AS final +WORKDIR /usr/share/nginx/html +COPY --from=build /build/docfx/wwwroot /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/.docfx/PublishDocfxImage.ps1 b/.docfx/PublishDocfxImage.ps1 index 8193fcb..d626f8b 100644 --- a/.docfx/PublishDocfxImage.ps1 +++ b/.docfx/PublishDocfxImage.ps1 @@ -1,3 +1,3 @@ -$version = minver -i -t v -v w -docker tag asp-versioning-docfx:$version jcr.codebelt.net/geekle/asp-versioning-docfx:$version -docker push jcr.codebelt.net/geekle/asp-versioning-docfx:$version +$version = minver -i -t v -v w +docker tag asp-versioning-docfx:$version jcr.codebelt.net/geekle/asp-versioning-docfx:$version +docker push jcr.codebelt.net/geekle/asp-versioning-docfx:$version diff --git a/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md b/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md index 8d9098e..6dfa4aa 100644 --- a/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md +++ b/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md @@ -1,25 +1,25 @@ ---- -uid: Codebelt.Extensions.Asp.Versioning -summary: *content ---- -Use this namespace when building versioned ASP.NET Core RESTful APIs that accept the API version from the HTTP `Accept` header — a common pattern for APIs consumed by real browsers and heterogeneous clients — without writing bespoke middleware to filter browser MIME types or translate Asp.Versioning error codes into structured exception types. It also provides semantic API version metadata for APIs whose public contract needs `major.minor.patch`, pre-release, and build metadata while still participating in the `Asp.Versioning.ApiVersion` model. - -RESTful API versioning built on `Asp.Versioning` normally requires coordinating three separate registration calls — `AddApiVersioning`, `AddMvc`, and `AddApiExplorer` — alongside a version reader that copes with the broad range of `Accept` header values real browsers and HTTP clients send. This namespace provides a single-call registration path and a filtered media-type version reader that handles that coordination automatically. - -Start with `AddRestfulApiVersioning` on `IServiceCollection`: it wires all three calls together, installs `RestfulApiVersionReader` to parse the API version from the `Accept` header while ignoring irrelevant browser MIME types, and registers problem-details integration compatible with RFC 7807. To translate Asp.Versioning status-code responses into typed `HttpStatusCodeException` values that the rest of your pipeline can handle, call `UseRestfulApiVersioning` on `IApplicationBuilder` in the middleware pipeline. Configure behaviour — default API version, parameter name, accepted media types, version selector strategy, and problem-details style — through `RestfulApiVersioningOptions`. - -Use `SemanticApiVersion`, `SemanticApiVersionParser`, and the semantic version attributes when you need `Asp.Versioning` endpoints to advertise or map versions such as `1.2.3-alpha+build.5`. `SemanticApiVersion.CompareTo` follows Semantic Versioning precedence and ignores build metadata for ordering, while equality keeps build metadata as part of exact version identity. The existing RESTful API Explorer group-name format is unchanged; `SemanticApiVersionFormatter` collapses semantic endpoint versions such as `1.0.1`, `1.2.0`, and `1.2.3-alpha+build.5` to the `v1` group. - -[!INCLUDE [availability-modern](../../includes/availability-modern.md)] - -Complements: [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) together with Open API/Swagger for RESTful APIs. 🔗 - -### Extension Members - -|Type|Ext|Methods| -|--:|:-:|---| -|`IApplicationBuilder`|⬇️|`UseRestfulApiVersioning`| -|`IServiceCollection`|⬇️|`AddRestfulApiVersioning`| -|`IServiceCollection`|⬇️|`AddApiVersionParser`| - -Use `AddApiVersionParser` to register a custom `IApiVersionParser` — for example, an `ApiVersionAliasParser` built with `CreateSemanticVersionAlias` that maps short version tokens to canonical `SemanticApiVersion` instances. +--- +uid: Codebelt.Extensions.Asp.Versioning +summary: *content +--- +Use this namespace when building versioned ASP.NET Core RESTful APIs that accept the API version from the HTTP `Accept` header — a common pattern for APIs consumed by real browsers and heterogeneous clients — without writing bespoke middleware to filter browser MIME types or translate Asp.Versioning error codes into structured exception types. It also provides semantic API version metadata for APIs whose public contract needs `major.minor.patch`, pre-release, and build metadata while still participating in the `Asp.Versioning.ApiVersion` model. + +RESTful API versioning built on `Asp.Versioning` normally requires coordinating three separate registration calls — `AddApiVersioning`, `AddMvc`, and `AddApiExplorer` — alongside a version reader that copes with the broad range of `Accept` header values real browsers and HTTP clients send. This namespace provides a single-call registration path and a filtered media-type version reader that handles that coordination automatically. + +Start with `AddRestfulApiVersioning` on `IServiceCollection`: it wires all three calls together, installs `RestfulApiVersionReader` to parse the API version from the `Accept` header while ignoring irrelevant browser MIME types, and registers problem-details integration compatible with RFC 7807. To translate Asp.Versioning status-code responses into typed `HttpStatusCodeException` values that the rest of your pipeline can handle, call `UseRestfulApiVersioning` on `IApplicationBuilder` in the middleware pipeline. Configure behaviour — default API version, parameter name, accepted media types, version selector strategy, and problem-details style — through `RestfulApiVersioningOptions`. + +Use `SemanticApiVersion`, `SemanticApiVersionParser`, and the semantic version attributes when you need `Asp.Versioning` endpoints to advertise or map versions such as `1.2.3-alpha+build.5`. `SemanticApiVersion.CompareTo` follows Semantic Versioning precedence and ignores build metadata for ordering, while equality keeps build metadata as part of exact version identity. The existing RESTful API Explorer group-name format is unchanged; `SemanticApiVersionFormatter` collapses semantic endpoint versions such as `1.0.1`, `1.2.0`, and `1.2.3-alpha+build.5` to the `v1` group. + +[!INCLUDE [availability-modern](../../includes/availability-modern.md)] + +Complements: [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) together with Open API/Swagger for RESTful APIs. 🔗 + +### Extension Members + +|Type|Ext|Methods| +|--:|:-:|---| +|`IApplicationBuilder`|⬇️|`UseRestfulApiVersioning`| +|`IServiceCollection`|⬇️|`AddRestfulApiVersioning`| +|`IServiceCollection`|⬇️|`AddApiVersionParser`| + +Use `AddApiVersionParser` to register a custom `IApiVersionParser` — for example, an `ApiVersionAliasParser` built with `CreateSemanticVersionAlias` that maps short version tokens to canonical `SemanticApiVersion` instances. diff --git a/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersionReader.md b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersionReader.md index ab8cbe1..05a3488 100644 --- a/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersionReader.md +++ b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersionReader.md @@ -1,38 +1,38 @@ ---- -uid: Codebelt.Extensions.Asp.Versioning.RestfulApiVersionReader -example: -- *content ---- -Use `RestfulApiVersionReader` when you need fine-grained control over which Accept media types are eligible for version parsing — for example when configuring `AddApiVersioning` directly rather than using the `AddRestfulApiVersioning` convenience method. The reader filters the incoming `Accept` header collection to only the entries whose media types start with one of the configured `validAcceptHeaders` values before delegating to `MediaTypeApiVersionReader`, which prevents browser-injected entries such as `text/html` or `image/webp` from being misread as version indicators. - -```csharp -using System.Collections.Generic; -using Asp.Versioning; -using Codebelt.Extensions.Asp.Versioning; -using Microsoft.Extensions.DependencyInjection; - -namespace Codebelt.Extensions.Asp.Versioning; - -public class CustomVersioningSetup -{ - public static void ConfigureServices(IServiceCollection services) - { - var acceptHeaders = new List - { - "application/json", - "application/xml", - "text/json", - "text/xml", - "text/plain", - "*/*" - }; - - services.AddApiVersioning(o => - { - o.DefaultApiVersion = new ApiVersion(1, 0); - o.AssumeDefaultVersionWhenUnspecified = true; - o.ApiVersionReader = new RestfulApiVersionReader(acceptHeaders, "v"); - }); - } -} -``` +--- +uid: Codebelt.Extensions.Asp.Versioning.RestfulApiVersionReader +example: +- *content +--- +Use `RestfulApiVersionReader` when you need fine-grained control over which Accept media types are eligible for version parsing — for example when configuring `AddApiVersioning` directly rather than using the `AddRestfulApiVersioning` convenience method. The reader filters the incoming `Accept` header collection to only the entries whose media types start with one of the configured `validAcceptHeaders` values before delegating to `MediaTypeApiVersionReader`, which prevents browser-injected entries such as `text/html` or `image/webp` from being misread as version indicators. + +```csharp +using System.Collections.Generic; +using Asp.Versioning; +using Codebelt.Extensions.Asp.Versioning; +using Microsoft.Extensions.DependencyInjection; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class CustomVersioningSetup +{ + public static void ConfigureServices(IServiceCollection services) + { + var acceptHeaders = new List + { + "application/json", + "application/xml", + "text/json", + "text/xml", + "text/plain", + "*/*" + }; + + services.AddApiVersioning(o => + { + o.DefaultApiVersion = new ApiVersion(1, 0); + o.AssumeDefaultVersionWhenUnspecified = true; + o.ApiVersionReader = new RestfulApiVersionReader(acceptHeaders, "v"); + }); + } +} +``` diff --git a/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersioningOptions.md b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersioningOptions.md index ea8436c..e8b2704 100644 --- a/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersioningOptions.md +++ b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.RestfulApiVersioningOptions.md @@ -1,35 +1,35 @@ ---- -uid: Codebelt.Extensions.Asp.Versioning.RestfulApiVersioningOptions -example: -- *content ---- -Inspect or adjust the defaults applied by `AddRestfulApiVersioning` by constructing a `RestfulApiVersioningOptions` instance directly. Defaults include API version 1.0, the parameter name `"v"`, a broad set of accepted media types, `CurrentImplementationApiVersionSelector`, and custom RFC 7807 problem-details handling. The example below trims the accepted media-type list to JSON only and switches the version selector to always route to the lowest implemented version. - -```csharp -using System.Collections.Generic; -using Asp.Versioning; -using Codebelt.Extensions.Asp.Versioning; - -namespace Codebelt.Extensions.Asp.Versioning; - -public class VersioningOptionsSetup -{ - public static RestfulApiVersioningOptions CreateOptions() - { - var options = new RestfulApiVersioningOptions - { - DefaultApiVersion = new ApiVersion(1, 0), - ParameterName = "v", - ReportApiVersions = true, - ValidAcceptHeaders = new List - { - "application/json", - "text/json" - } - }; - options.UseApiVersionSelector(); - return options; - } -} -``` - +--- +uid: Codebelt.Extensions.Asp.Versioning.RestfulApiVersioningOptions +example: +- *content +--- +Inspect or adjust the defaults applied by `AddRestfulApiVersioning` by constructing a `RestfulApiVersioningOptions` instance directly. Defaults include API version 1.0, the parameter name `"v"`, a broad set of accepted media types, `CurrentImplementationApiVersionSelector`, and custom RFC 7807 problem-details handling. The example below trims the accepted media-type list to JSON only and switches the version selector to always route to the lowest implemented version. + +```csharp +using System.Collections.Generic; +using Asp.Versioning; +using Codebelt.Extensions.Asp.Versioning; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class VersioningOptionsSetup +{ + public static RestfulApiVersioningOptions CreateOptions() + { + var options = new RestfulApiVersioningOptions + { + DefaultApiVersion = new ApiVersion(1, 0), + ParameterName = "v", + ReportApiVersions = true, + ValidAcceptHeaders = new List + { + "application/json", + "text/json" + } + }; + options.UseApiVersionSelector(); + return options; + } +} +``` + diff --git a/.docfx/docfx.json b/.docfx/docfx.json index 2ecf27c..a3b9f36 100644 --- a/.docfx/docfx.json +++ b/.docfx/docfx.json @@ -1,92 +1,92 @@ -{ - "metadata": [ - { - "src": [ - { - "files": [ - "Codebelt.Extensions.Asp.Versioning/**.csproj" - ], - "src": "../src" - } - ], - "dest": "api", - "memberLayout": "separatePages", - "filter": "filterConfig.yml", - "properties": { - "TargetFramework": "net10.0" - } - } - ], - "build": { - "sitemap": { - "changefreq": "monthly", - "baseUrl": "https://versioning.codebelt.net/"}, - "xref": [ - "https://docs.cuemon.net/xrefmap.yml", - "https://github.com/dotnet/docfx/raw/main/.xrefmap.json" - ], - "content": [ - { - "files": [ - "api/**/*.yml", - "packages/**/*.md", - "toc.yml", - "*.md" - ], - "exclude": [ - "api/namespaces/**", - "api/types/**", - "bin/**", - "obj/**" - ] - } - ], - "resource": [ - { - "files": [ - "images/**" - ] - } - ], - "globalMetadata": { - "_appTitle": "Extensions for Asp.Versioning by Codebelt", - "_appFooter": "Generated by DocFX. Copyright 2024-2026 Geekle. All rights reserved.", - "_appLogoPath": "images/50x50.png", - "_appFaviconPath": "images/favicon.ico", - "_googleAnalyticsTagId": "G-G15R2J7GBR", - "_enableSearch": false, - "_disableContribution": false, - "_gitContribute": { - "repo": "https://github.com/codebeltnet/asp-versioning", - "branch": "main" - }, - "_gitUrlPattern": "github" - }, - "dest": "wwwroot", - "globalMetadataFiles": [], - "fileMetadataFiles": [], - "template": [ - "default", - "modern", - "templates/ms-style" - ], - "overwrite": [ - { - "files": [ - "api/namespaces/**/*.md", - "api/types/**/*.md" - ], - "exclude": [ - "obj/**", - "wwwroot/**" - ] - } - ], - "postProcessors": [], - "markdownEngineName": "markdig", - "noLangKeyword": false, - "keepFileLink": false, - "cleanupCacheHistory": false, - "disableGitFeatures": false - } -} +{ + "metadata": [ + { + "src": [ + { + "files": [ + "Codebelt.Extensions.Asp.Versioning/**.csproj" + ], + "src": "../src" + } + ], + "dest": "api", + "memberLayout": "separatePages", + "filter": "filterConfig.yml", + "properties": { + "TargetFramework": "net10.0" + } + } + ], + "build": { + "sitemap": { + "changefreq": "monthly", + "baseUrl": "https://asp-versioning.codebelt.net/"}, + "xref": [ + "https://docs.cuemon.net/xrefmap.yml", + "https://github.com/dotnet/docfx/raw/main/.xrefmap.json" + ], + "content": [ + { + "files": [ + "api/**/*.yml", + "packages/**/*.md", + "toc.yml", + "*.md" + ], + "exclude": [ + "api/namespaces/**", + "api/types/**", + "bin/**", + "obj/**" + ] + } + ], + "resource": [ + { + "files": [ + "images/**" + ] + } + ], + "globalMetadata": { + "_appTitle": "Extensions for Asp.Versioning by Codebelt", + "_appFooter": "Generated by DocFX. Copyright 2024-2026 Geekle. All rights reserved.", + "_appLogoPath": "images/50x50.png", + "_appFaviconPath": "images/favicon.ico", + "_googleAnalyticsTagId": "G-G15R2J7GBR", + "_enableSearch": false, + "_disableContribution": false, + "_gitContribute": { + "repo": "https://github.com/codebeltnet/asp-versioning", + "branch": "main" + }, + "_gitUrlPattern": "github" + }, + "dest": "wwwroot", + "globalMetadataFiles": [], + "fileMetadataFiles": [], + "template": [ + "default", + "modern", + "templates/ms-style" + ], + "overwrite": [ + { + "files": [ + "api/namespaces/**/*.md", + "api/types/**/*.md" + ], + "exclude": [ + "obj/**", + "wwwroot/**" + ] + } + ], + "postProcessors": [], + "markdownEngineName": "markdig", + "noLangKeyword": false, + "keepFileLink": false, + "cleanupCacheHistory": false, + "disableGitFeatures": false + } +} diff --git a/.docfx/filterConfig.yml b/.docfx/filterConfig.yml index 3343240..30d5751 100644 --- a/.docfx/filterConfig.yml +++ b/.docfx/filterConfig.yml @@ -1,4 +1,4 @@ -apiRules: -- exclude: - uidRegex: ^System\.Object +apiRules: +- exclude: + uidRegex: ^System\.Object type: Type \ No newline at end of file diff --git a/.docfx/includes/availability-default.md b/.docfx/includes/availability-default.md index fa28d0e..db9549d 100644 --- a/.docfx/includes/availability-default.md +++ b/.docfx/includes/availability-default.md @@ -1 +1 @@ -Availability: .NET 10, .NET 9 and .NET Standard 2.0 \ No newline at end of file +Availability: .NET 11 and .NET 10 \ No newline at end of file diff --git a/.docfx/includes/availability-modern.md b/.docfx/includes/availability-modern.md index af5cf4b..db9549d 100644 --- a/.docfx/includes/availability-modern.md +++ b/.docfx/includes/availability-modern.md @@ -1 +1 @@ -Availability: .NET 10 and .NET 9 \ No newline at end of file +Availability: .NET 11 and .NET 10 \ No newline at end of file diff --git a/.docfx/index.md b/.docfx/index.md index 8b6ec37..6c0da83 100644 --- a/.docfx/index.md +++ b/.docfx/index.md @@ -1,15 +1,15 @@ ---- -uid: frontpage-md -title: Extensions for Asp.Versioning by Codebelt ---- -![Extensions for Asp.Versioning API by Codebelt](/images/128x128.png) - -# Extensions for Asp.Versioning by Codebelt - -Originally, this project was part of **Cuemon for .NET**, but with the forthcoming release of .NET 9, it was split into its own project. - -This project is now part of [Extensions for Asp.Versioning API by Codebelt](https://github.com/codebeltnet/asp-versioning). - -[Cuemon for .NET](https://www.cuemon.net/) will pursue its original purpose; providing an addition/complement/sidecar to Microsoft .NET platform. - -Proceed to the [docs](/api/Codebelt.Extensions.Asp.Versioning.html) to learn more about the capabilities of this project. +--- +uid: frontpage-md +title: Extensions for Asp.Versioning by Codebelt +--- +![Extensions for Asp.Versioning API by Codebelt](/images/128x128.png) + +# Extensions for Asp.Versioning by Codebelt + +Originally, this project was part of **Cuemon for .NET**, but with the forthcoming release of .NET 9, it was split into its own project. + +This project is now part of [Extensions for Asp.Versioning API by Codebelt](https://github.com/codebeltnet/asp-versioning). + +[Cuemon for .NET](https://www.cuemon.net/) will pursue its original purpose; providing an addition/complement/sidecar to Microsoft .NET platform. + +Proceed to the [docs](/api/Codebelt.Extensions.Asp.Versioning.html) to learn more about the capabilities of this project. diff --git a/.docfx/nginx.conf b/.docfx/nginx.conf index 60e3d08..c95d1b1 100644 --- a/.docfx/nginx.conf +++ b/.docfx/nginx.conf @@ -11,7 +11,7 @@ server { # cached permanently because changed content is published under a different URL. location ~ "^/public/.+-[A-Z0-9]{8}(?:-[A-Z0-9]{8})?(?:\.min)?\.(?:(?:css|js)(?:\.map)?|woff2?)$" { try_files $uri =404; - add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Vary "Accept-Encoding" always; } diff --git a/.docfx/packages/index.md b/.docfx/packages/index.md index 0a8fe96..a9f6347 100644 --- a/.docfx/packages/index.md +++ b/.docfx/packages/index.md @@ -1,11 +1,11 @@ -# NuGet Packages - -This is a list of all NuGet packages from **Extensions for Asp.Versioning API by Codebelt** that is publicly available on [NuGet.org](https://www.nuget.org/packages?q=Codebelt.Extensions); the packages here are listed alphabetically and are available in preview-, rc- and production-ready versions. - -### 📦 Standalone Packages - -Provides a focused API for RESTful versioning. - -|Package|vNext|Stable|Downloads| -|:--|:-:|:-:|:-:| -| [Codebelt.Extensions.Asp.Versioning](https://www.nuget.org/packages/Codebelt.Extensions.Asp.Versioning/) | ![vNext](https://img.shields.io/nuget/vpre/Codebelt.Extensions.Asp.Versioning?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Codebelt.Extensions.Asp.Versioning?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Codebelt.Extensions.Asp.Versioning?color=blueviolet&logo=nuget) | +# NuGet Packages + +This is a list of all NuGet packages from **Extensions for Asp.Versioning API by Codebelt** that is publicly available on [NuGet.org](https://www.nuget.org/packages?q=Codebelt.Extensions); the packages here are listed alphabetically and are available in preview-, rc- and production-ready versions. + +### 📦 Standalone Packages + +Provides a focused API for RESTful versioning. + +|Package|vNext|Stable|Downloads| +|:--|:-:|:-:|:-:| +| [Codebelt.Extensions.Asp.Versioning](https://www.nuget.org/packages/Codebelt.Extensions.Asp.Versioning/) | ![vNext](https://img.shields.io/nuget/vpre/Codebelt.Extensions.Asp.Versioning?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Codebelt.Extensions.Asp.Versioning?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Codebelt.Extensions.Asp.Versioning?color=blueviolet&logo=nuget) | diff --git a/.docfx/toc.yml b/.docfx/toc.yml index 61522bb..99ab870 100644 --- a/.docfx/toc.yml +++ b/.docfx/toc.yml @@ -1,4 +1,4 @@ -- name: Asp.Versioning API - href: api/Codebelt.Extensions.Asp.Versioning.yml -- name: NuGet - href: packages/index.md +- name: Asp.Versioning API + href: api/Codebelt.Extensions.Asp.Versioning.yml +- name: NuGet + href: packages/index.md diff --git a/.editorconfig b/.editorconfig index a083db5..886ea15 100644 --- a/.editorconfig +++ b/.editorconfig @@ -37,7 +37,7 @@ dotnet_diagnostic.IDE0063.severity = none dotnet_diagnostic.IDE0071.severity = none # S3267: Loops should be simplified with "LINQ" expressions -# https://rules.sonarsource.com/csharp/RSPEC-3267 +# https://sonarcloud.io/organizations/sonarcloud/rules?open=csharpsquid:S3267&rule_key=csharpsquid:S3267 dotnet_diagnostic.S3267.severity = none # CA1859: Use concrete types when possible for improved performance @@ -57,9 +57,10 @@ indent_size = 4 # IDE0161: Namespace declaration preferences # https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0161 -# Prefer file-scoped namespaces for new files; existing block-scoped files should not be converted unless explicitly asked +# File-scoped namespaces are mandatory for new and existing C# files. [*.cs] -csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_namespace_declarations = file_scoped:error +dotnet_diagnostic.IDE0161.severity = error # Top-level statements: DO NOT USE # https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0210-ide0211 diff --git a/.gitattributes b/.gitattributes index 1ff0c42..0ea8d1b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ ############################################################################### # Set default behavior to automatically normalize line endings. ############################################################################### -* text=auto +* text=auto eol=lf ############################################################################### # Set default behavior for command prompt diff. @@ -17,7 +17,7 @@ # # Merging from the command prompt will add diff markers to the files if there # are conflicts (Merging from VS is not affected by the settings below, in VS -# the diff markers are never inserted). Diff markers may cause the following +# the diff markers are never inserted). Diff markers may cause the following # file extensions to fail to load in VS. An alternative would be to treat # these files as binary and thus will always conflict and require user # intervention with every merge. To do so, just uncomment the entries below @@ -46,9 +46,9 @@ ############################################################################### # diff behavior for common document formats -# +# # Convert binary document formats to text before diffing them. This feature -# is only available from the command line. Turn it on by uncommenting the +# is only available from the command line. Turn it on by uncommenting the # entries below. ############################################################################### #*.doc diff=astextplain diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index e5be83d..9c745a4 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,135 +1,135 @@ -# Contributor Covenant Code of Conduct - -This document is adapted from the Contributor Covenant which is used by many open source projects, -including those under the [.NET Foundation](https://dotnetfoundation.org/code-of-conduct). - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[INSERT CONTACT METHOD]. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available -at [https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq +# Contributor Covenant Code of Conduct + +This document is adapted from the Contributor Covenant which is used by many open source projects, +including those under the [.NET Foundation](https://dotnetfoundation.org/code-of-conduct). + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0e4a661..eae6394 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,79 +1,80 @@ -# Contributing to Extensions for Asp.Versioning API by Codebelt - -This repository is part of the Codebelt .NET library estate. The instructions below describe the current checkout and its CI contract. Please keep changes focused and preserve the shared Codebelt build skeleton unless a deliberate policy change is being made. - -## Before you start - -- Read the repository `README.md` and open an issue before starting a non-trivial feature or behavioral change. -- Use an installed .NET SDK that can build the target frameworks listed below. This repository currently targets: **net10.0, net9.0**. -- The solution is `Codebelt.Extensions.Asp.Versioning.slnx`. Central package versions are maintained in `Directory.Packages.props`. -- The shared build behavior is in `Directory.Build.props` and `Directory.Build.targets`; repository-specific TFMs, package references and metadata remain local to this library. - -## Repository shape - -- `src/` contains production projects. -- `test/` contains xUnit v3 test projects. -- `Codebelt.Extensions.Asp.Versioning.slnx` is the solution used for local development. -- `.github/workflows/ci-pipeline.yml` is the CI workflow and the authority for the test matrix. -- `testenvironments.json` declares the supported `WSL-Ubuntu` and `Docker-Ubuntu` test environments. - -## Build - -Restore and build the solution from the repository root: - -```powershell -dotnet restore "Codebelt.Extensions.Asp.Versioning.slnx" -dotnet build "Codebelt.Extensions.Asp.Versioning.slnx" --configuration Release --no-restore -``` - -CI builds both Debug and Release configurations. A clean build should complete before opening a pull request. - -## Test - -Run tests one project at a time so a failing or hanging project is attributable. This mirrors the CI matrix; it does not silently turn skipped integration tests into passing tests. - -```powershell -$testProjects = Get-ChildItem test -Filter *.csproj -Recurse -foreach ($project in $testProjects) { - dotnet test $project.FullName --configuration Release --no-restore -} -``` - -The CI test plan currently runs **2** project(s) and excludes **0** project(s). The workflow also has an optional macOS test job. - -## Integration and infrastructure - -- `WSL-Ubuntu` — WSL distribution `Ubuntu-24.04`. -- `Docker-Ubuntu` — Docker image `codebeltnet/ubuntu-testrunner:8-9-10-11`. - -This repository has no repository-local `docker-compose.yml` service dependency in the current checkout. -Use the environments declared in `testenvironments.json` when you need the estate test runner. - -## Package and documentation - -Create packages using the same solution and Release configuration: - -```powershell -dotnet pack "Codebelt.Extensions.Asp.Versioning.slnx" --configuration Release --no-restore -``` - -Package-specific release notes live under `.nuget//PackageReleaseNotes.txt` and package README files live beside them. `Directory.Build.targets` imports the release notes during packing. Public API changes also require XML documentation updates; DocFX documentation is built by the repository automation. - -## Pull requests - -1. Create or join an issue before substantial work, then fork the repository and create a branch from `main`. -2. Add or update focused tests and public API documentation where applicable. -3. Run restore, build, and the relevant per-project tests locally. -4. Keep the pull request small, explain the behavior change and validation performed, and wait for the CI checks to pass. - -## Issues - -Include the affected project, target framework, operating system, SDK version, exact command, expected result, actual result, and a minimal reproduction. Identify whether the behavior differs between local Windows/WSL, Docker-Ubuntu and GitHub Actions. - -## Coding guidelines - -Follow the existing style, the Framework Design Guidelines, the repository `.editorconfig`, and the shared Codebelt conventions. Do not make unrelated formatting or infrastructure changes in a feature pull request. - -## License - -By contributing to Extensions for Asp.Versioning API by Codebelt, you agree that your contributions will be licensed under the MIT license. +# Contributing to Extensions for Asp.Versioning API by Codebelt + +This repository is part of the Codebelt .NET library estate. The instructions below describe the current checkout and its CI contract. Please keep changes focused and preserve the shared Codebelt build skeleton unless a deliberate policy change is being made. + +## Before you start + +- Read the repository `README.md` and open an issue before starting a non-trivial feature or behavioral change. +- Use an installed .NET SDK that can build the target frameworks listed below. This repository currently targets: **net10.0, net11.0**. +- The solution is `Codebelt.Extensions.Asp.Versioning.slnx`. Central package versions are maintained in `Directory.Packages.props`. +- The shared build behavior is in `Directory.Build.props` and `Directory.Build.targets`; repository-specific TFMs, package references and metadata remain local to this library. + +## Repository shape + +- `src/` contains production projects. +- `test/` contains xUnit v3 test projects. +- `Codebelt.Extensions.Asp.Versioning.slnx` is the solution used for local development. +- `.github/workflows/ci-pipeline.yml` is the CI workflow and the authority for the test matrix. +- `testenvironments.json` declares the supported `WSL-Ubuntu` and `Docker-Ubuntu` test environments. + +## Build + +Restore and build the solution from the repository root: + +```powershell +dotnet restore "Codebelt.Extensions.Asp.Versioning.slnx" +dotnet build "Codebelt.Extensions.Asp.Versioning.slnx" --configuration Release --no-restore +``` + +CI builds both Debug and Release configurations. A clean build should complete before opening a pull request. + +## Test + +Run tests one project at a time so a failing or hanging project is attributable. This mirrors the CI matrix; it does not silently turn skipped integration tests into passing tests. + +```powershell +$testProjects = Get-ChildItem test -Filter *.csproj -Recurse +foreach ($project in $testProjects) { + dotnet test --project $project.FullName --configuration Release --no-restore +} +``` + +The CI test plan currently runs **2** project(s) and excludes **0** project(s). The workflow also has an optional macOS test job. + +## Integration and infrastructure + +- `WSL-Ubuntu` — WSL distribution `Ubuntu-24.04`. +- `Docker-Ubuntu (net10.0)` — Docker image `codebeltnet/ubuntu-testrunner:10`. +- `Docker-Ubuntu (net11.0)` — Docker image `codebeltnet/ubuntu-testrunner:11`. + +This repository has no repository-local `docker-compose.yml` service dependency in the current checkout. +Use the environments declared in `testenvironments.json` when you need the estate test runner. + +## Package and documentation + +Create packages using the same solution and Release configuration: + +```powershell +dotnet pack "Codebelt.Extensions.Asp.Versioning.slnx" --configuration Release --no-restore +``` + +Package-specific release notes live under `.nuget//PackageReleaseNotes.txt` and package README files live beside them. `Directory.Build.targets` imports the release notes during packing. Public API changes also require XML documentation updates; DocFX documentation is built by the repository automation. + +## Pull requests + +1. Create or join an issue before substantial work, then fork the repository and create a branch from `main`. +2. Add or update focused tests and public API documentation where applicable. +3. Run restore, build, and the relevant per-project tests locally. +4. Keep the pull request small, explain the behavior change and validation performed, and wait for the CI checks to pass. + +## Issues + +Include the affected project, target framework, operating system, SDK version, exact command, expected result, actual result, and a minimal reproduction. Identify whether the behavior differs between local Windows/WSL, Docker-Ubuntu and GitHub Actions. + +## Coding guidelines + +Follow the existing style, the Framework Design Guidelines, the repository `.editorconfig`, and the shared Codebelt conventions. Do not make unrelated formatting or infrastructure changes in a feature pull request. + +## License + +By contributing to Extensions for Asp.Versioning API by Codebelt, you agree that your contributions will be licensed under the MIT license. diff --git a/.github/codecov.yml b/.github/codecov.yml index 2515756..c0a0037 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,2 +1,2 @@ -ignore: +ignore: - "test" \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1fca42a..7fe3850 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,17 +1,17 @@ -version: 2 -updates: - - package-ecosystem: "nuget" - directory: "/src" - schedule: - interval: "weekly" - open-pull-requests-limit: 0 - - package-ecosystem: "nuget" - directory: "/test" - schedule: - interval: "weekly" - open-pull-requests-limit: 0 - - package-ecosystem: "github-actions" - directory: "/.github/workflows" - schedule: - interval: "weekly" - open-pull-requests-limit: 0 +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/src" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + - package-ecosystem: "nuget" + directory: "/test" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + - package-ecosystem: "github-actions" + directory: "/.github/workflows" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 diff --git a/.github/prompts/benchmark.prompt.md b/.github/prompts/benchmark.prompt.md index f137b63..427da20 100644 --- a/.github/prompts/benchmark.prompt.md +++ b/.github/prompts/benchmark.prompt.md @@ -1,164 +1,164 @@ ---- -mode: agent -description: 'Writing Performance Benchmarks' ---- - -# Benchmark Fixture Prompt (Tuning Benchmarks) - -This prompt defines how to generate performance tests (“benchmarks”) for a project/solution using BenchmarkDotNet. -Benchmarks are *not* unit tests — they are micro- or component-level performance measurements that belong under the `tuning/` directory and follow strict conventions. - -Copilot must follow these guidelines when generating benchmark fixtures. - ---- - -## 1. Naming and Placement - -- All benchmark projects live under the `tuning/` folder. - Examples: - - `tuning/.Benchmarks/` - - `tuning/.Console.Benchmarks/` - -- **Namespaces must NOT end with `.Benchmarks`.** - They must mirror the production assembly’s namespace. - - Example: - If benchmarking a type inside `YourProject.Console`, then: - - ```csharp - namespace YourProject.Console - { - public class Sha512256Benchmark { … } - } - ``` - -* **Benchmark class names must end with `Benchmark`.** - Example: `DateSpanBenchmark`, `FowlerNollVoBenchmark`. - -* Benchmark files should be located in the matching benchmark project - (e.g., benchmarks for `YourProject.Console` go in `YourProject.Console.Benchmarks.csproj`). - -* In the `.csproj` for each benchmark project, set the root namespace to the production namespace, for example: - - ```xml - YourProject.Console - ``` - ---- - -## 2. Attributes and Configuration - -Each benchmark class should use: - -```csharp -[MemoryDiagnoser] -[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] -``` - -Optional but strongly recommended where meaningful: - -* `[Params(...)]` — define small, medium, large input sizes. -* `[GlobalSetup]` — deterministic initialization of benchmark data. -* `[Benchmark(Description = "...")]` — always add descriptions. -* `[Benchmark(Baseline = true)]` — when comparing two implementations. - -Avoid complex global configs; prefer explicit attributes inside the class. - ---- - -## 3. Structure and Best Practices - -A benchmark fixture must: - -* Measure a **single logical operation** per benchmark method. -* Avoid I/O, networking, disk access, logging, or side effects. -* Avoid expensive setup inside `[Benchmark]` methods. -* Use deterministic data (e.g., seeded RNG or predefined constants). -* Use `[GlobalSetup]` to allocate buffers, random payloads, or reusable test data only once. -* Avoid shared mutable state unless reset per iteration. - -Use representative input sizes such as: - -```csharp -[Params(8, 256, 4096)] -public int Count { get; set; } -``` - -BenchmarkDotNet will run each benchmark for each parameter value. - ---- - -## 4. Method Naming Conventions - -Use descriptive names that communicate intent: - -* `Parse_Short` -* `Parse_Long` -* `ComputeHash_Small` -* `ComputeHash_Large` -* `Serialize_Optimized` -* `Serialize_Baseline` - -When comparing approaches, always list them clearly and tag one as the baseline. - ---- - -## 5. Example Benchmark Fixture - -```csharp -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; - -namespace YourProject -{ - [MemoryDiagnoser] - [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] - public class SampleOperationBenchmark - { - [Params(8, 256, 4096)] - public int Count { get; set; } - - private byte[] _payload; - - [GlobalSetup] - public void Setup() - { - _payload = new byte[Count]; - // deterministic initialization - } - - [Benchmark(Baseline = true, Description = "Operation - baseline")] - public int Operation_Baseline() => SampleOperation.Process(_payload); - - [Benchmark(Description = "Operation - optimized")] - public int Operation_Optimized() => SampleOperation.ProcessOptimized(_payload); - } -} -``` - ---- - -## 6. Reporting and CI - -* Benchmark projects live exclusively under `tuning/`. They must not affect production builds. -* Heavy BenchmarkDotNet runs should *not* run in CI unless explicitly configured. -* Reports are produced by the benchmark runner and stored under the configured artifacts directory. - ---- - -## 7. Additional Guidelines - -* Keep benchmark fixtures focused and readable. -* Document non-obvious reasoning in short comments. -* Prefer realistic but deterministic data sets. -* When benchmarks reveal regressions or improvements, reference the associated PR or issue in a comment. -* Shared benchmark helpers belong in `tuning/` projects, not in production code. - ---- - -## Final Notes - -* Benchmarks are performance tests, not unit tests. -* Use `[Benchmark]` only for pure performance measurement. -* Avoid `MethodImplOptions.NoInlining` unless absolutely necessary. -* Use small sets of meaningful benchmark scenarios — avoid combinatorial explosion. +--- +mode: agent +description: 'Writing Performance Benchmarks' +--- + +# Benchmark Fixture Prompt (Tuning Benchmarks) + +This prompt defines how to generate performance tests (“benchmarks”) for a project/solution using BenchmarkDotNet. +Benchmarks are *not* unit tests — they are micro- or component-level performance measurements that belong under the `tuning/` directory and follow strict conventions. + +Copilot must follow these guidelines when generating benchmark fixtures. + +--- + +## 1. Naming and Placement + +- All benchmark projects live under the `tuning/` folder. + Examples: + - `tuning/.Benchmarks/` + - `tuning/.Console.Benchmarks/` + +- **Namespaces must NOT end with `.Benchmarks`.** + They must mirror the production assembly’s namespace. + + Example: + If benchmarking a type inside `YourProject.Console`, then: + + ```csharp + namespace YourProject.Console + { + public class Sha512256Benchmark { … } + } + ``` + +* **Benchmark class names must end with `Benchmark`.** + Example: `DateSpanBenchmark`, `FowlerNollVoBenchmark`. + +* Benchmark files should be located in the matching benchmark project + (e.g., benchmarks for `YourProject.Console` go in `YourProject.Console.Benchmarks.csproj`). + +* In the `.csproj` for each benchmark project, set the root namespace to the production namespace, for example: + + ```xml + YourProject.Console + ``` + +--- + +## 2. Attributes and Configuration + +Each benchmark class should use: + +```csharp +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +``` + +Optional but strongly recommended where meaningful: + +* `[Params(...)]` — define small, medium, large input sizes. +* `[GlobalSetup]` — deterministic initialization of benchmark data. +* `[Benchmark(Description = "...")]` — always add descriptions. +* `[Benchmark(Baseline = true)]` — when comparing two implementations. + +Avoid complex global configs; prefer explicit attributes inside the class. + +--- + +## 3. Structure and Best Practices + +A benchmark fixture must: + +* Measure a **single logical operation** per benchmark method. +* Avoid I/O, networking, disk access, logging, or side effects. +* Avoid expensive setup inside `[Benchmark]` methods. +* Use deterministic data (e.g., seeded RNG or predefined constants). +* Use `[GlobalSetup]` to allocate buffers, random payloads, or reusable test data only once. +* Avoid shared mutable state unless reset per iteration. + +Use representative input sizes such as: + +```csharp +[Params(8, 256, 4096)] +public int Count { get; set; } +``` + +BenchmarkDotNet will run each benchmark for each parameter value. + +--- + +## 4. Method Naming Conventions + +Use descriptive names that communicate intent: + +* `Parse_Short` +* `Parse_Long` +* `ComputeHash_Small` +* `ComputeHash_Large` +* `Serialize_Optimized` +* `Serialize_Baseline` + +When comparing approaches, always list them clearly and tag one as the baseline. + +--- + +## 5. Example Benchmark Fixture + +```csharp +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace YourProject +{ + [MemoryDiagnoser] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] + public class SampleOperationBenchmark + { + [Params(8, 256, 4096)] + public int Count { get; set; } + + private byte[] _payload; + + [GlobalSetup] + public void Setup() + { + _payload = new byte[Count]; + // deterministic initialization + } + + [Benchmark(Baseline = true, Description = "Operation - baseline")] + public int Operation_Baseline() => SampleOperation.Process(_payload); + + [Benchmark(Description = "Operation - optimized")] + public int Operation_Optimized() => SampleOperation.ProcessOptimized(_payload); + } +} +``` + +--- + +## 6. Reporting and CI + +* Benchmark projects live exclusively under `tuning/`. They must not affect production builds. +* Heavy BenchmarkDotNet runs should *not* run in CI unless explicitly configured. +* Reports are produced by the benchmark runner and stored under the configured artifacts directory. + +--- + +## 7. Additional Guidelines + +* Keep benchmark fixtures focused and readable. +* Document non-obvious reasoning in short comments. +* Prefer realistic but deterministic data sets. +* When benchmarks reveal regressions or improvements, reference the associated PR or issue in a comment. +* Shared benchmark helpers belong in `tuning/` projects, not in production code. + +--- + +## Final Notes + +* Benchmarks are performance tests, not unit tests. +* Use `[Benchmark]` only for pure performance measurement. +* Avoid `MethodImplOptions.NoInlining` unless absolutely necessary. +* Use small sets of meaningful benchmark scenarios — avoid combinatorial explosion. diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index e80bcb1..4e8e53a 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -1,218 +1,226 @@ -name: Asp.Versioning Ext. CI Pipeline -on: - pull_request: - branches: [main] - workflow_dispatch: - inputs: - configuration: - type: choice - description: The build configuration to use in the deploy stage. - required: true - default: Release - options: - - Debug - - Release - run_mac_tests: - type: boolean - description: Run the macOS test matrix despite the additional cost and runtime. - default: false - -permissions: - contents: read - -jobs: - init: - name: initialize - runs-on: ubuntu-24.04 - outputs: - run-mac-tests: ${{ steps.vars.outputs.run-mac-tests }} - run-privileged-jobs: ${{ steps.vars.outputs.run-privileged-jobs }} - strong-name-key-filename: ${{ steps.vars.outputs.strong-name-key-filename }} - build-switches: ${{ steps.vars.outputs.build-switches }} - steps: - - id: vars - name: calculate workflow variables - shell: bash - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.run_mac_tests }}" == "true" ]]; then - echo "run-mac-tests=true" >> "$GITHUB_OUTPUT" - else - echo "run-mac-tests=false" >> "$GITHUB_OUTPUT" - fi - - if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then - echo "run-privileged-jobs=false" >> "$GITHUB_OUTPUT" - echo "strong-name-key-filename=" >> "$GITHUB_OUTPUT" - echo "build-switches=-p:SkipSignAssembly=true" >> "$GITHUB_OUTPUT" - else - echo "run-privileged-jobs=true" >> "$GITHUB_OUTPUT" - echo "strong-name-key-filename=versioning.snk" >> "$GITHUB_OUTPUT" - echo "build-switches=" >> "$GITHUB_OUTPUT" - fi - - build: - name: call-build - needs: [init] - strategy: - matrix: - arch: [X64, ARM64] - configuration: [Debug, Release] - uses: codebeltnet/jobs-dotnet-build/.github/workflows/default.yml@v3 - with: - configuration: ${{ matrix.configuration }} - strong-name-key-filename: ${{ needs.init.outputs.strong-name-key-filename }} - build-switches: ${{ needs.init.outputs.build-switches }} - runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} - upload-build-artifact-name: build-${{ matrix.configuration }}-${{ matrix.arch }} - secrets: inherit - - pack: - name: call-pack - needs: [build] - strategy: - matrix: - configuration: [Debug, Release] - uses: codebeltnet/jobs-dotnet-pack/.github/workflows/default.yml@v3 - with: - configuration: ${{ matrix.configuration }} - version: ${{ needs.build.outputs.version }} - download-build-artifact-pattern: build-${{ matrix.configuration }}-X64 - - test_linux: - name: call-test-linux - needs: [build] - strategy: - fail-fast: false - matrix: - arch: [X64, ARM64] - configuration: [Debug, Release] - uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 - with: - runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} - configuration: ${{ matrix.configuration }} - build-switches: -p:SkipSignAssembly=true - restore: true - build: true # needed for xunitv3 - download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} - - test_windows: - name: call-test-windows - needs: [build] - strategy: - fail-fast: false - matrix: - configuration: [Debug, Release] - arch: [X64, ARM64] - uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 - with: - runs-on: ${{ matrix.arch == 'ARM64' && 'windows-11-arm' || 'windows-2025' }} - configuration: ${{ matrix.configuration }} - build-switches: -p:SkipSignAssembly=true - build: true # we need to build due to xUnitv3 - restore: true # we need to restore due to xUnitv3 - download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} - - test_mac: - if: ${{ needs.init.outputs.run-mac-tests == 'true' }} - name: call-test-mac - needs: [init, build] - strategy: - fail-fast: false - matrix: - arch: [X64, ARM64] - configuration: [Debug, Release] - uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 - with: - runs-on: ${{ matrix.arch == 'ARM64' && 'macos-26' || 'macos-26-intel' }} - configuration: ${{ matrix.configuration }} - build-switches: -p:SkipSignAssembly=true - restore: true - build: true - download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} - - test_qualitygate: - if: ${{ always() }} - name: test-qualitygate - needs: [init, test_linux, test_windows, test_mac] - runs-on: ubuntu-24.04 - steps: - - name: Evaluate test results - shell: bash - env: - RUN_MAC_TESTS: ${{ needs.init.outputs.run-mac-tests }} - TEST_LINUX_RESULT: ${{ needs.test_linux.result }} - TEST_WINDOWS_RESULT: ${{ needs.test_windows.result }} - TEST_MAC_RESULT: ${{ needs.test_mac.result }} - run: | - require_success() { - local job_name="$1" - local job_result="$2" - - if [[ "$job_result" != "success" ]]; then - echo "::error::$job_name finished with '$job_result'." - exit 1 - fi - } - - require_success_or_skip() { - local job_name="$1" - local job_enabled="$2" - local job_result="$3" - - if [[ "$job_enabled" == "true" ]]; then - require_success "$job_name" "$job_result" - return - fi - - if [[ "$job_result" != "success" && "$job_result" != "skipped" ]]; then - echo "::error::$job_name finished with '$job_result' while disabled." - exit 1 - fi - } - - require_success "test_linux" "$TEST_LINUX_RESULT" - require_success "test_windows" "$TEST_WINDOWS_RESULT" - require_success_or_skip "test_mac" "$RUN_MAC_TESTS" "$TEST_MAC_RESULT" - - sonarcloud: - if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} - name: call-sonarcloud - needs: [init, build, test_qualitygate] - uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@v3 - with: - organization: geekle - projectKey: asp-versioning - version: ${{ needs.build.outputs.version }} - secrets: inherit - - codecov: - if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} - name: call-codecov - needs: [init, build, test_qualitygate] - uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 - with: - repository: codebeltnet/asp-versioning - secrets: inherit - - codeql: - if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} - name: call-codeql - needs: [init, build, test_qualitygate] - uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@v3 - permissions: - security-events: write - - deploy: - # Avoid skipped optional jobs (for example disabled macOS matrix runs) from suppressing deployment. - if: ${{ always() && github.event_name != 'pull_request' && needs.build.result == 'success' && needs.pack.result == 'success' && needs.test_qualitygate.result == 'success' && needs.sonarcloud.result == 'success' && needs.codecov.result == 'success' && needs.codeql.result == 'success' }} - name: call-nuget - needs: [build, pack, test_qualitygate, sonarcloud, codecov, codeql] - uses: codebeltnet/jobs-nuget-push/.github/workflows/default.yml@v3 - with: - version: ${{ needs.build.outputs.version }} - environment: Production - configuration: ${{ inputs.configuration == '' && 'Release' || inputs.configuration }} - permissions: - contents: write - packages: write - secrets: inherit +name: Asp.Versioning Ext. CI Pipeline +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + configuration: + type: choice + description: The build configuration to use in the deploy stage. + required: true + default: Release + options: + - Debug + - Release + run_mac_tests: + type: boolean + description: Run the macOS test matrix despite the additional cost and runtime. + default: false + +permissions: + contents: read + +jobs: + init: + name: initialize + runs-on: ubuntu-26.04 + outputs: + run-mac-tests: ${{ steps.vars.outputs.run-mac-tests }} + run-privileged-jobs: ${{ steps.vars.outputs.run-privileged-jobs }} + strong-name-key-filename: ${{ steps.vars.outputs.strong-name-key-filename }} + build-switches: ${{ steps.vars.outputs.build-switches }} + steps: + - id: vars + name: calculate workflow variables + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.run_mac_tests }}" == "true" ]]; then + echo "run-mac-tests=true" >> "$GITHUB_OUTPUT" + else + echo "run-mac-tests=false" >> "$GITHUB_OUTPUT" + fi + + if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then + echo "run-privileged-jobs=false" >> "$GITHUB_OUTPUT" + echo "strong-name-key-filename=" >> "$GITHUB_OUTPUT" + echo "build-switches=-p:SkipSignAssembly=true" >> "$GITHUB_OUTPUT" + else + echo "run-privileged-jobs=true" >> "$GITHUB_OUTPUT" + echo "strong-name-key-filename=versioning.snk" >> "$GITHUB_OUTPUT" + echo "build-switches=" >> "$GITHUB_OUTPUT" + fi + + build: + name: call-build + needs: [init] + strategy: + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-build/.github/workflows/default.yml@v3 + with: + include-preview: true + configuration: ${{ matrix.configuration }} + strong-name-key-filename: ${{ needs.init.outputs.strong-name-key-filename }} + build-switches: ${{ needs.init.outputs.build-switches }} + runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-26.04-arm' || 'ubuntu-26.04' }} + upload-build-artifact-name: build-${{ matrix.configuration }}-${{ matrix.arch }} + secrets: inherit + + pack: + name: call-pack + needs: [build] + strategy: + matrix: + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-pack/.github/workflows/default.yml@v3 + with: + include-preview: true + configuration: ${{ matrix.configuration }} + version: ${{ needs.build.outputs.version }} + download-build-artifact-pattern: build-${{ matrix.configuration }}-X64 + + test_linux: + name: call-test-linux + needs: [build] + strategy: + fail-fast: false + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + include-preview: true + runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-26.04-arm' || 'ubuntu-26.04' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + restore: true + build: true # needed for xunitv3 + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_windows: + name: call-test-windows + needs: [build] + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] + arch: [X64, ARM64] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + include-preview: true + runs-on: ${{ matrix.arch == 'ARM64' && 'windows-11-arm' || 'windows-2025' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + build: true # we need to build due to xUnitv3 + restore: true # we need to restore due to xUnitv3 + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_mac: + if: ${{ needs.init.outputs.run-mac-tests == 'true' }} + name: call-test-mac + needs: [init, build] + strategy: + fail-fast: false + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + include-preview: true + runs-on: ${{ matrix.arch == 'ARM64' && 'macos-26' || 'macos-26-intel' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + restore: true + build: true + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_qualitygate: + if: ${{ always() }} + name: test-qualitygate + needs: [init, test_linux, test_windows, test_mac] + runs-on: ubuntu-26.04 + steps: + - name: Evaluate test results + shell: bash + env: + RUN_MAC_TESTS: ${{ needs.init.outputs.run-mac-tests }} + TEST_LINUX_RESULT: ${{ needs.test_linux.result }} + TEST_WINDOWS_RESULT: ${{ needs.test_windows.result }} + TEST_MAC_RESULT: ${{ needs.test_mac.result }} + run: | + require_success() { + local job_name="$1" + local job_result="$2" + + if [[ "$job_result" != "success" ]]; then + echo "::error::$job_name finished with '$job_result'." + exit 1 + fi + } + + require_success_or_skip() { + local job_name="$1" + local job_enabled="$2" + local job_result="$3" + + if [[ "$job_enabled" == "true" ]]; then + require_success "$job_name" "$job_result" + return + fi + + if [[ "$job_result" != "success" && "$job_result" != "skipped" ]]; then + echo "::error::$job_name finished with '$job_result' while disabled." + exit 1 + fi + } + + require_success "test_linux" "$TEST_LINUX_RESULT" + require_success "test_windows" "$TEST_WINDOWS_RESULT" + require_success_or_skip "test_mac" "$RUN_MAC_TESTS" "$TEST_MAC_RESULT" + + sonarcloud: + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} + name: call-sonarcloud + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@v3 + with: + include-preview: true + organization: geekle + projectKey: asp-versioning + version: ${{ needs.build.outputs.version }} + secrets: inherit + + codecov: + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} + name: call-codecov + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 + with: + repository: codebeltnet/asp-versioning + secrets: inherit + + codeql: + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} + name: call-codeql + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@v3 + with: + include-preview: true + permissions: + security-events: write + + deploy: + # Avoid skipped optional jobs (for example disabled macOS matrix runs) from suppressing deployment. + if: ${{ always() && github.event_name != 'pull_request' && needs.build.result == 'success' && needs.pack.result == 'success' && needs.test_qualitygate.result == 'success' && needs.sonarcloud.result == 'success' && needs.codecov.result == 'success' && needs.codeql.result == 'success' }} + name: call-nuget + needs: [build, pack, test_qualitygate, sonarcloud, codecov, codeql] + uses: codebeltnet/jobs-nuget-push/.github/workflows/default.yml@v3 + with: + version: ${{ needs.build.outputs.version }} + environment: Production + configuration: ${{ inputs.configuration == '' && 'Release' || inputs.configuration }} + permissions: + contents: write + packages: write + secrets: inherit diff --git a/.github/workflows/service-update.yml b/.github/workflows/service-update.yml index 1f69586..f55bbc4 100644 --- a/.github/workflows/service-update.yml +++ b/.github/workflows/service-update.yml @@ -125,7 +125,7 @@ jobs: NEW="${{ steps.newver.outputs.new }}" for f in .nuget/*/PackageReleaseNotes.txt; do [ -f "$f" ] || continue - TFM=$(grep -m1 "^Availability:" "$f" | sed 's/Availability: //' || echo ".NET 10, .NET 9 and .NET Standard 2.0") + TFM=$(grep -m1 "^Availability:" "$f" | sed 's/Availability: //' || echo ".NET 11 and .NET 10") ENTRY="Version: ${NEW}\nAvailability: ${TFM}\n\n# ALM\n- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs)\n\n" { printf "$ENTRY"; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f" done diff --git a/.gitignore b/.gitignore index 2c4c6f0..2c8ca49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,381 +1,381 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# DocFx -/.docfx/wwwroot -/.docfx/api/**/*.yml -!/.docfx/packages/** -/.docfx/**/*.manifest -/.docfx/.vscode/docfx-assistant - -### VisualStudioCode ### -.vscode/* -*.code-workspace - -# Strong-Name Key -*.snk - -# Bot workspace (local-only AI agent ideation, PRDs, and agentic loop state) -.bot/* +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# DocFx +/.docfx/wwwroot +/.docfx/api/**/*.yml +!/.docfx/packages/** +/.docfx/**/*.manifest +/.docfx/.vscode/docfx-assistant + +### VisualStudioCode ### +.vscode/* +*.code-workspace + +# Strong-Name Key +*.snk + +# Bot workspace (local-only AI agent ideation, PRDs, and agentic loop state) +.bot/* !.bot/README.md \ No newline at end of file diff --git a/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt b/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt index 647cdc6..bc60cf6 100644 --- a/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt @@ -1,220 +1,226 @@ +Version: 11.0.0 +Availability: .NET 11 and .NET 10 + +# ALM +- CHANGED Target frameworks by adding .NET 11 (net11.0) and removing .NET 9 (net9.0), so the package now targets .NET 11 and .NET 10 + Version: 10.2.5 -Availability: .NET 10 and .NET 9 +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.2.4 -Availability: .NET 10 and .NET 9 +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.2.3 -Availability: .NET 10 and .NET 9 +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.2.2 -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.2.1 -Availability: .NET 10 and .NET 9 - -# Improvements -- CHANGED RestfulApiVersioningOptions to include ApiVersionReader property for advanced version reader customization, -- CHANGED Automatic version normalization is enabled by default when the default API version is a SemanticApiVersion; otherwise it retains its previous behavior. - -# Bug Fixes -- FIXED RestfulApiVersionReader to normalize semantically equivalent API version formats (1, 1.0, 1.0.0) to identical values for consistent routing and version matching, - -Version: 10.2.0 -Availability: .NET 10 and .NET 9 - -# New Features -- ADDED ApiVersionAliasParser to resolve friendly API version aliases before delegating to another parser, enabling callers to request versions by shortened or compatibility-oriented tokens, -- ADDED AddApiVersionParser extension method for registering custom IApiVersionParser implementations as the singleton parser for API versioning services. - -# Improvements -- EXTENDED AddRestfulApiVersioning to automatically register semantic version aliases when the default API version is a SemanticApiVersion, enabling alias formats such as major, major.minor, and major.minor.patch without explicit configuration, -- CHANGED Documentation updated to clarify semantic version alias registration behavior and guidance for exposing additional semantic versions. - -Version: 10.1.0 -Availability: .NET 10 and .NET 9 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs). - -# New Features -- ADDED SemanticApiVersion type for Semantic Versioning compliance with major.minor.patch, pre-release, and build metadata support, -- ADDED SemanticApiVersionParser to parse semantic version strings with full SemVer precedence rules, -- ADDED SemanticApiVersionFormatter to render semantic versions with customizable pre-release and build metadata formatting, -- ADDED SemanticApiVersionAttribute and MapToSemanticApiVersionAttribute for API parameter decoration, -- ADDED VersionConversionOptions to control .NET Version to SemanticApiVersion translation with optional Revision inclusion. - -Version: 10.0.9 -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.8 -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.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 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 10.0.5 -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.4 -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.3 -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.2 -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.1 -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.0 -Availability: .NET 10 and .NET 9 - -# ALM -- ADDED Support for TFM .NET 10 (LTS) -- REMOVED Support for TFM .NET 8 (LTS) -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.8 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.7 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.6 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.5 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.4 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.3 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.2 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 9.0.1 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 9.0.0 -Availability: .NET 9 and .NET 8 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs -- REMOVED Support for TFM .NET 6 (LTS) - -Version: 8.4.0 -Availability: .NET 8 and .NET 6 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 8.3.2 -Availability: .NET 8 and .NET 6 - -# ALM -- REMOVED Support for TFM .NET 7 (STS) - -Version: 8.3.0 -Availability: .NET 8, .NET 7 and .NET 6 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 8.2.0 -Availability: .NET 8, .NET 7 and .NET 6 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 8.1.0 -Availability: .NET 8, .NET 7 and .NET 6 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -# Improvements -- CHANGED RestfulApiVersioningOptions class in the Codebelt.Extensions.Asp.Versioning namespace to include non-official MIME-types in the ValidAcceptHeaders property - -Version: 8.0.1 -Availability: .NET 8, .NET 7 and .NET 6 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 8.0.0 -Availability: .NET 8, .NET 7 and .NET 6 - -# ALM -- ADDED TFM for net8.0 -- CHANGED Dependencies to latest and greatest with respect to TFMs +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.2.1 +Availability: .NET 10 and .NET 9 + +# Improvements +- CHANGED RestfulApiVersioningOptions to include ApiVersionReader property for advanced version reader customization, +- CHANGED Automatic version normalization is enabled by default when the default API version is a SemanticApiVersion; otherwise it retains its previous behavior. + +# Bug Fixes +- FIXED RestfulApiVersionReader to normalize semantically equivalent API version formats (1, 1.0, 1.0.0) to identical values for consistent routing and version matching, + +Version: 10.2.0 +Availability: .NET 10 and .NET 9 + +# New Features +- ADDED ApiVersionAliasParser to resolve friendly API version aliases before delegating to another parser, enabling callers to request versions by shortened or compatibility-oriented tokens, +- ADDED AddApiVersionParser extension method for registering custom IApiVersionParser implementations as the singleton parser for API versioning services. + +# Improvements +- EXTENDED AddRestfulApiVersioning to automatically register semantic version aliases when the default API version is a SemanticApiVersion, enabling alias formats such as major, major.minor, and major.minor.patch without explicit configuration, +- CHANGED Documentation updated to clarify semantic version alias registration behavior and guidance for exposing additional semantic versions. + +Version: 10.1.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs). + +# New Features +- ADDED SemanticApiVersion type for Semantic Versioning compliance with major.minor.patch, pre-release, and build metadata support, +- ADDED SemanticApiVersionParser to parse semantic version strings with full SemVer precedence rules, +- ADDED SemanticApiVersionFormatter to render semantic versions with customizable pre-release and build metadata formatting, +- ADDED SemanticApiVersionAttribute and MapToSemanticApiVersionAttribute for API parameter decoration, +- ADDED VersionConversionOptions to control .NET Version to SemanticApiVersion translation with optional Revision inclusion. + +Version: 10.0.9 +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.8 +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.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 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.0.5 +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.4 +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.3 +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.2 +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.1 +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.0 +Availability: .NET 10 and .NET 9 + +# ALM +- ADDED Support for TFM .NET 10 (LTS) +- REMOVED Support for TFM .NET 8 (LTS) +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.8 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.7 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.6 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.5 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.4 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.3 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.2 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 9.0.1 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 9.0.0 +Availability: .NET 9 and .NET 8 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs +- REMOVED Support for TFM .NET 6 (LTS) + +Version: 8.4.0 +Availability: .NET 8 and .NET 6 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 8.3.2 +Availability: .NET 8 and .NET 6 + +# ALM +- REMOVED Support for TFM .NET 7 (STS) + +Version: 8.3.0 +Availability: .NET 8, .NET 7 and .NET 6 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 8.2.0 +Availability: .NET 8, .NET 7 and .NET 6 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 8.1.0 +Availability: .NET 8, .NET 7 and .NET 6 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +# Improvements +- CHANGED RestfulApiVersioningOptions class in the Codebelt.Extensions.Asp.Versioning namespace to include non-official MIME-types in the ValidAcceptHeaders property + +Version: 8.0.1 +Availability: .NET 8, .NET 7 and .NET 6 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 8.0.0 +Availability: .NET 8, .NET 7 and .NET 6 + +# ALM +- ADDED TFM for net8.0 +- CHANGED Dependencies to latest and greatest with respect to TFMs diff --git a/.nuget/Codebelt.Extensions.Asp.Versioning/README.md b/.nuget/Codebelt.Extensions.Asp.Versioning/README.md index a10e9bc..10399ac 100644 --- a/.nuget/Codebelt.Extensions.Asp.Versioning/README.md +++ b/.nuget/Codebelt.Extensions.Asp.Versioning/README.md @@ -1,15 +1,15 @@ -## About - -An open-source project (MIT license) that targets and complements the [asp-versioning](https://github.com/dotnet/aspnet-api-versioning) versioning engine. It aims to provide a uniform and convenient developer experience when working with RESTful API versioning. - -Your versatile Asp.Versioning companion for modern development with `.NET 9` and `.NET 10`. - -It is, by heart, free, flexible and built to extend and boost your agile codebelt. - -## **Codebelt.Extensions.Asp.Versioning** for .NET - -The `Codebelt.Extensions.Asp.Versioning` namespace contains types and extension methods that complements the `Asp.Versioning` namespace by adding new ways of working versioning for Open API 3.x. - -More documentation available at our documentation site: - -- [Codebelt.Extensions.Asp.Versioning](https://versioning.codebelt.net/api/Codebelt.Extensions.Asp.Versioning.html) 🔗 +## About + +An open-source project (MIT license) that targets and complements the [asp-versioning](https://github.com/dotnet/aspnet-api-versioning) versioning engine. It aims to provide a uniform and convenient developer experience when working with RESTful API versioning. + +Your versatile Asp.Versioning companion for modern development with `.NET 10` and `.NET 11`. + +It is, by heart, free, flexible and built to extend and boost your agile codebelt. + +## **Codebelt.Extensions.Asp.Versioning** for .NET + +The `Codebelt.Extensions.Asp.Versioning` namespace contains types and extension methods that complements the `Asp.Versioning` namespace by adding new ways of working versioning for Open API 3.x. + +More documentation available at our documentation site: + +- [Codebelt.Extensions.Asp.Versioning](https://versioning.codebelt.net/api/Codebelt.Extensions.Asp.Versioning.html) 🔗 diff --git a/AGENTS.md b/AGENTS.md index e9a6282..cfdbc29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,748 +1,748 @@ -# Agent Instructions for Codebelt.Extensions.Asp.Versioning - -This document provides guidance for AI agents working in this repository. - -## Project Overview - -Codebelt.Extensions.Asp.Versioning is a .NET library providing uniform, opinionated, and extensible APIs for ASP.NET Core RESTful API versioning. The solution targets .NET 10.0 and .NET 9.0. It includes: - -- **Codebelt.Extensions.Asp.Versioning** — Core library for API versioning with middleware integration, service configuration, and version parsing strategies. - -## Coding Standards - -- **Text encoding:** UTF-8 for text files (enforced via `.editorconfig`) -- **Template rewrites:** Preserve UTF-8 explicitly when scripts or tools rewrite text files; avoid locale-dependent encoding defaults -- **Namespaces:** File-scoped namespaces are required (enforced via `.editorconfig`) -- **Top-level statements:** Not allowed (enforced via `.editorconfig`) -- **Language version:** Always use the latest C# features (`LangVersion=latest`) -- **Nullable:** Enable nullable reference types in all new code -- **XML documentation:** All public APIs must have XML documentation comments -- **Testing:** Use xUnit v3 with Codebelt.Extensions.Xunit.App base classes - -## Project Structure - -- `src/` — Production source code - - `Codebelt.Extensions.Asp.Versioning/` — Core API versioning library with middleware, options, and parsers -- `test/` — Unit tests (project names end with `Tests`) - - `Codebelt.Extensions.Asp.Versioning.Tests/` — Unit tests for core functionality - - `Codebelt.Extensions.Asp.Versioning.FunctionalTests/` — End-to-end functional tests for integration scenarios -- `.nuget/` — Per-package NuGet metadata (icon, README, release notes) -- `.docfx/` — DocFX documentation configuration -- `.github/` — CI/CD workflows and contributing guidelines - -## Test Conventions - -- Test project names must end with `Tests` (e.g., `{PROJECT_NAME}.Tests`) -- Test classes should inherit from the `Test` base class in `Codebelt.Extensions.Xunit` -- Use `Microsoft.Testing.Platform` as the test runner (`UseMicrosoftTestingPlatformRunner=true`) -- All tests are executable (`OutputType=Exe`) -- Test namespaces must match the SUT namespace (System Under Test), without `.Tests` suffix -- See the detailed test writing guidelines in this document - -## Build & CI - -- Centralized package versions via `Directory.Packages.props` -- Resolve new or updated `Directory.Packages.props` versions from NuGet.org and keep them on the latest stable listed releases -- Centralized build configuration via `Directory.Build.props` -- MinVer for semantic versioning from Git tags -- Strong-name signing is enabled in CI environments (`CI=true`) -- Keep `.github/dependabot.yml` enabled at the repo root so central NuGet package management stays current - -## .bot/ Folder - -If a `.bot/` folder exists at the root, it contains **confidential, local-only** working material for AI agents — product requirement documents (PRDs), design proposals, agentic loop state, and brainstorming outputs. This folder is gitignored and never committed. - -When starting creative or design work (new features, architecture decisions, PRD drafts), use the [brainstorming skill](https://skills.sh/obra/superpowers/brainstorming) and save outputs to `.bot/`. Only move finalized, non-confidential instructions into `AGENTS.md`. - -## Git Operations Safeguards - -Agents must never automatically commit code changes or push to remote repositories. Both actions require explicit user approval: - -- **Commits**: Always request confirmation from the user before staging and committing code. Present a clear summary of the changes and wait for approval before executing the commit. -- **Remote Operations**: Do not push, pull, fetch, or interact with `origin` or any remote repository without explicit user instruction. These operations modify repository history and can cause data loss if performed unexpectedly. - -**Rationale:** Automatic commits can clutter history with incomplete work, temporary debugging code, or unintended changes. Unexpected remote operations risk overwriting or losing commits on shared branches. Always require explicit user approval before performing these actions. - - -## DocFX Documentation Maintenance - -When changing public .NET APIs, keep the DocFX documentation current in the same change set. - -Documentation updates must cover public API only. Do not document private or internal types or members. Do not create namespace overview pages for namespaces that contain no public API. - -Public non-abstraction types — including enums, structs, records, plain classes, and static extension containers — are valid documentation targets. Generic public types and generic extension methods are valid documentation targets too. Do not exclude a type solely because it is generic or because reflection reports it as abstract and sealed (that is the IL pattern for a static class). - -For public non-abstraction types, include at least one realistic, copy/paste-ready usage example on the generated type page/overwrite section for that type UID. For example, a public `Class1` requires an example on the `Class1` API page, not only on the namespace page. Prefer deriving examples from existing unit, functional, or integration tests, but convert test code into real-life consumer-oriented usage. - -Missing type examples must be added through per-type DocFX overwrite files under `.docfx/api/types/{TypeUid}.md` in Codebelt repositories. Namespace overview text and `Extension Members` tables are not substitutes for type-page examples. - -Public extension methods must have examples too. Listing an extension method in an `Extension Members` table is required, but it is not enough. - -All added or changed code samples must be deterministic and verified to compile. Do not add pseudo-code, ellipses, hidden test helpers, or examples that rely on unverified behavior. - -Compilation is necessary but not sufficient. Do not present runtime implementation names such as `services.GetType().Name` or `host.GetType().FullName` as the example outcome. Show application behavior, configured state, a resolved domain service, an HTTP response, or another result that explains why a caller uses the API. Application-entry-point examples must not declare an empty local `Program` type merely to compile; show a real entry point or clearly identify the referenced application project. - -Every namespace containing public API must have a DocFX namespace overview page named after the namespace, such as `X.Y.Z.md`, under `.docfx/api/namespaces/`, using DocFX overwrite front matter with the namespace `uid`. - -Namespace pages must identify key entry points from release notes, package documentation, public factories/builders, and strong functional tests, then help readers choose among adjacent workflows. When the package complements a well-known upstream API, compare concrete acquisition, customization, lifecycle, and sharing tradeoffs from current official guidance; do not claim drop-in replacement compatibility without evidence. - -Namespaces exposing public extension methods must document those extension members at namespace level. The namespace page must include an `Extension Members` table listing the extended type, the extension marker, and the public extension methods. Extension members are rendered under the heading `Extension Members`. - -Both namespace overwrite files and type overwrite files are required deliverables in the same run. Generating only namespace pages or only type pages is incomplete. - -`docfx.json` must keep namespace and type overwrite files in separate subdirectories. `build.overwrite` must include both `api/namespaces/**/*.md` (for namespace pages) and `api/types/**/*.md` (for type pages). `build.content` must exclude both `api/namespaces/**` and `api/types/**` to prevent overwrite Markdown from being treated as conceptual content. Do not use `api/**/*.md` under `build.overwrite` or `build.content`. - -Availability must be documented by referencing the appropriate include file when one exists, or by adding explicit availability text when no suitable include exists. Availability must reflect the actual target frameworks, conditional compilation, and project configuration. - -For conditionally compiled APIs, choose the executable test framework from the asset that contains the API. Inspect the preprocessor condition, project TFMs, package `lib/` assets, and resolved consumer asset before changing a sample. For APIs under `NETSTANDARD2_0` or `NETSTANDARD2_0_OR_GREATER`, when modern `lib/netX.0/` assets also exist, use `net48` (or another supported .NET Framework target from `net462` onward) so the consumer selects `lib/netstandard2.0/`. Never use `netstandard*` as an executable target, and never use a modern `netX.0` target when it selects an asset where the API is absent. For other TFM guards, select a runnable consumer TFM that resolves to the containing asset and confirm that selection from restore or build evidence. - -Preserve manual documentation edits. Prefer additive changes, but correct stale or contradictory information so documentation remains accurate. - -Preserve working Markdown links, `Related:` references, and historical URL citations during prose rewrites. Remove or replace a URL only after directly verifying that the current destination returns HTTP 404. Timeouts, 403s, rate limits, DNS failures, and other lookup problems are not removal evidence. - -Interim scratch artifacts do not belong in the repository working tree. Store assessment queues, project manifests, review reports, captured validator output, progress notes, and one-off helper scripts in temp or session storage instead. New working-tree files are only legitimate when they are the managed `AGENTS.md` block, the active `docfx.json`, the deterministic `skip-compile-allowlist.json` waiver file when one is truly required, or DocFX-authored namespace/type Markdown that maps to a real public namespace or type. Everything else is blocking cleanup work, not a documentation deliverable. The validator auto-detects generic-arity type families (such as `MutableTuple`1`..`MutableTuple`N`) and skips redundant sibling examples from the public API surface alone, so no family-skip manifest is ever written into the repository. - -Skip markers are waivers, not fixes. A skip marker only suppresses compilation when it both existed before the current run and matches an entry in `.docfx/skip-compile-allowlist.json`. Each allowlist entry must include `diagnosticCode`, `filePath`, `uid` or `symbol`, `reason`, `approval`, and `lifetime` (`temporary` or `permanent`). Newly introduced or unallowlisted skip markers remain fail-level diagnostics and do not permit a completion claim. - -Do not emit a final report, audit result, completion summary, or handoff while `summary.canClaimCompletion` is false, `summary.remainingWorkItems` is greater than zero, `summary.remainingGates` is non-empty, `summary.fullVerificationRan` is false, fail-level diagnostics remain, `summary.newlyIntroducedSkipMarkers` is non-zero, or `summary.interimArtifacts` is non-zero. Large queues, many changed files, repetitive next steps, long runtimes, context pressure, session length, task size, or a "stable queue" are not valid stop reasons; the next action must be another remediation batch, a validator rerun, a validator/tooling fix, or a true blocker with exact evidence. - -Context pressure is not a completion condition. If the session feels constrained while work remains, continue with a smaller deterministic batch, regenerate deterministic queue state such as `--assessment-queue`, `--project-manifest`, or the active dry-run manifest/review pair, or report a true tooling failure with the exact command, exit code, and output. When naming a queue-state regeneration command, resolve it to a concrete temp/session path instead of leaving `` as a placeholder. Do not stop with phrases like "given context constraints", "best done in a follow-up", "remaining work requires authoring", "this is a massive task", or "I will provide a focused summary". A context-sized handoff while work remains is `FAIL_CONTEXT_HANDOFF_WITH_REMAINING_WORK`; the remediation is to continue with a smaller deterministic batch. - -Before completing documentation work, run the relevant verification commands, normally: - -```bash -dotnet build -dotnet test -dotnet run --file /scripts/docfx.cs -- --repo-root . --build-api-model --validate-samples --verify-docfx-build -``` - -Codebelt repositories are normally strong-name signed with a `.snk` file in the repository root on the main author's codespace. Preserve and copy that root `.snk` file when building a temporary copy. If the repository or temp copy has no root `.snk`, run build and test verification with `-p:SkipSignAssembly=true`, for example `dotnet build -p:SkipSignAssembly=true` and `dotnet test -p:SkipSignAssembly=true`. - -The final DocFX verification must run outside the working tree when possible. The `--verify-docfx-build` option copies the repository to a temp workspace, runs DocFX against the resolved `docfx.json` there, and removes the temp workspace afterward so generated API YAML, manifest files, and site output do not flood git status. Do not call the work complete until the final JSON reports `summary.fullVerificationRan: true`, `summary.canClaimCompletion: true`, `summary.remainingWorkItems: 0`, an empty `summary.remainingGates`, an empty `summary.remainingDiagnosticsByCode`, `summary.newlyIntroducedSkipMarkers: 0`, and `summary.interimArtifacts: 0`. - -If a command cannot be run, report the exact limitation or failure instead of claiming the documentation was verified. - - -**Scope: Writing Unit Tests.** The following guidance applies to paths matching "**/*.{cs,csproj}". - -# Writing Unit Tests -This document provides instructions for writing unit tests for a project/solution. Please follow these guidelines to ensure consistency and maintainability. - -## 1. Base Class - -**Always inherit from the `Test` base class** for all unit test classes. -This ensures consistent setup, teardown, and output handling across all tests. - -> Important: Do NOT add `using Xunit.Abstractions`. xUnit v3 no longer exposes that namespace; including it is incorrect and will cause compilation errors. Use the `Codebelt.Extensions.Xunit` Test base class and `using Xunit;` as shown in the examples below. If you need access to test output, rely on the Test base class (which accepts the appropriate output helper) rather than importing `Xunit.Abstractions`. - -```csharp -using Codebelt.Extensions.Xunit; -using Xunit; - -namespace Your.Namespace -{ - public class YourTestClass : Test - { - public YourTestClass(ITestOutputHelper output) : base(output) - { - } - - // Your tests here - } -} -``` - -## 2. Test Method Attributes - -- Use `[Fact]` for standard unit tests. -- Use `[Theory]` with `[InlineData]` or other data sources for parameterized tests. - -## 3. Naming Conventions - -- **Test classes**: End with `Test` (e.g., `DateSpanTest`). -- **Test methods**: Use descriptive names that state the expected behavior (e.g., `ShouldReturnTrue_WhenConditionIsMet`). - -## 4. Assertions - -- Use `Assert` methods from xUnit for all assertions. -- Prefer explicit and expressive assertions (e.g., `Assert.Equal`, `Assert.NotNull`, `Assert.Contains`). - -## 5. File and Namespace Organization - -- Place test files in the appropriate test project and folder structure. -- Use namespaces that mirror the source code structure. The namespace of a test file MUST match the namespace of the System Under Test (SUT). Do NOT append ".Tests", ".Benchmarks" or similar suffixes to the namespace. Only the assembly/project name should indicate that the file is a test/benchmark (for example: YourProject.Foo.Tests assembly, but namespace YourProject.Foo). - - Example: If the SUT class is declared as: - ```csharp - namespace YourProject.Foo.Bar - { - public class Zoo { /* ... */ } - } - ``` - then the corresponding unit test class must use the exact same namespace: - ```csharp - namespace YourProject.Foo.Bar - { - public class ZooTest : Test { /* ... */ } - } - ``` - - Do NOT use: - ```csharp - namespace YourProject.Foo.Bar.Tests { /* ... */ } // ❌ - namespace YourProject.Foo.Bar.Benchmarks { /* ... */ } // ❌ - ``` - - The unit tests for the YourProject.Foo assembly live in the YourProject.Foo.Tests assembly. - - The functional tests for the YourProject.Foo assembly live in the YourProject.Foo.FunctionalTests assembly. - - Test class names end with Test and live in the same namespace as the class being tested, e.g., the unit tests for the Boo class that resides in the YourProject.Foo assembly would be named BooTest and placed in the YourProject.Foo namespace in the YourProject.Foo.Tests assembly. - - Modify the associated .csproj file to override the root namespace so the compiled namespace matches the SUT. Example: - ```xml - - YourProject.Foo - - ``` -- When generating test scaffolding automatically, resolve the SUT's namespace from the source file (or project/assembly metadata) and use that exact namespace in the test file header. - -- Notes: - - This rule ensures type discovery and XML doc links behave consistently and reduces confusion when reading tests. - - Keep folder structure aligned with the production code layout to make locating SUT <-> test pairs straightforward. - -## 6. Example Test - -```csharp -using System; -using System.Globalization; -using Codebelt.Extensions.Xunit; -using Xunit; - -namespace YourProject -{ - /// - /// Tests for the class. - /// - public class DateSpanTest : Test - { - public DateSpanTest(ITestOutputHelper output) : base(output) - { - } - - [Fact] - public void Parse_ShouldGetOneMonthOfDifference_UsingIso8601String() - { - var start = new DateTime(2021, 3, 5).ToString("O"); - var end = new DateTime(2021, 4, 5).ToString("O"); - - var span = DateSpan.Parse(start, end); - - Assert.Equal("0:01:31:00:00:00.0", span.ToString()); - Assert.Equal(0, span.Years); - Assert.Equal(1, span.Months); - Assert.Equal(31, span.Days); - Assert.Equal(0, span.Hours); - Assert.Equal(0, span.Minutes); - Assert.Equal(0, span.Seconds); - Assert.Equal(0, span.Milliseconds); - - Assert.Equal(0.08493150684931507, span.TotalYears); - Assert.Equal(1, span.TotalMonths); - Assert.Equal(31, span.TotalDays); - Assert.Equal(744, span.TotalHours); - Assert.Equal(44640, span.TotalMinutes); - Assert.Equal(2678400, span.TotalSeconds); - Assert.Equal(2678400000, span.TotalMilliseconds); - - Assert.Equal(6, span.GetWeeks()); - Assert.Equal(-1566296493, span.GetHashCode()); - - TestOutput.WriteLine(span.ToString()); - } - } -} -``` - -## 7. Additional Guidelines - -- Keep tests focused and isolated. -- Do not rely on external systems except for xUnit itself and Codebelt.Extensions.Xunit (and derived from this). -- Ensure tests are deterministic and repeatable. - -## 8. Test Doubles - -- Preferred test doubles include dummies, fakes, stubs and spies if and when the design allows it. -- Under special circumstances, mock can be used (using Moq library). -- Before overriding methods, verify that the method is virtual or abstract; this rule also applies to mocks. -- Never mock IMarshaller; always use a new instance of JsonMarshaller. - -## 9. Avoid `InternalsVisibleTo` in Tests - -- **Do not** use `InternalsVisibleTo` to access internal types or members from test projects. -- Prefer **indirect testing via public APIs** that depend on the internal implementation (public facades, public extension methods, or other public entry points). - -### Preferred Pattern - -**Pattern name:** Public Facade Testing (also referred to as *Public API Proxy Testing*) - -**Description:** -Internal classes and methods must be validated by exercising the public API that consumes them. Tests should assert observable behavior exposed by the public surface rather than targeting internal implementation details directly. - -### Example Mapping - -- **Internal helper:** `DelimitedString` (internal static class) -- **Public API:** `TestOutputHelperExtensions.WriteLines()` (public extension method) -- **Test strategy:** Write tests for `WriteLines()` and verify its public behavior. The internal call to `DelimitedString.Create()` is exercised implicitly. - -### Benefits - -- Avoids exposing internal types to test assemblies. -- Ensures tests reflect real-world usage patterns. -- Maintains strong encapsulation and a clean public API. -- Tests remain resilient to internal refactoring as long as public behavior is preserved. - -### When to Apply - -- Internal logic is fully exercised through existing public APIs. -- Public entry points provide sufficient coverage of internal code paths. -- The internal implementation exists solely as a helper or utility for public-facing functionality. - -## 10. ExcludeFromCodeCoverage Prohibition - -**Do not use `ExcludeFromCodeCoverage` attribute on any code.** This includes: - -- Test classes or test methods -- Production code -- Configuration code -- Any other code path - -### Rationale - -- Excluding code from coverage hides gaps and creates false confidence in test completeness. -- If a code path cannot or should not be tested, refactor the code to eliminate that path rather than hiding it from metrics. -- Every executable line should be covered by tests or be genuinely unreachable (dead code to be removed). - -### Alternative Approaches - -- **Untestable code paths**: Refactor to separate concerns and eliminate the untestable path. -- **External dependencies**: Use test doubles (fakes, stubs, spies) instead of excluding from coverage. -- **Configuration-only code**: Move to configuration files or extract into testable methods. -- **Generated or third-party code**: These should not be in the primary codebase; use NuGet packages or dedicated vendor folders if necessary. - -**Scope: Writing Performance Tests.** The following guidance applies to paths matching "tuning/**, **/*Benchmark*.cs". - -# Writing Performance Tests -This document provides guidance for writing performance tests (benchmarks) for a project/solution using BenchmarkDotNet. Follow these guidelines to keep benchmarks consistent, readable, and comparable. - -## 1. Naming and Placement - -- Place micro- and component-benchmarks under the `tuning/` folder or in projects named `*.Benchmarks`. -- Place benchmark files in the appropriate benchmark project and folder structure. -- Use namespaces that mirror the source code structure, e.g. do not suffix with `Benchmarks`. -Namespace rule: DO NOT append `.Benchmarks` to the namespace. Benchmarks must live in the same namespace as the production assembly. Example: if the production assembly uses `namespace YourProject.Security.Cryptography`, the benchmark file should also use: - ``` - namespace YourProject.Security.Cryptography - { - public class Sha512256Benchmark { /* ... */ } - } - ``` -The class name must end with `Benchmark`, but the namespace must match the assembly (no `.Benchmarks` suffix). -- The benchmarks for the YourProject.Bar assembly live in the YourProject.Bar.Benchmarks assembly. -- Benchmark class names end with Benchmark and live in the same namespace as the class being measured, e.g., the benchmarks for the Zoo class that resides in the YourProject.Bar assembly would be named ZooBenchmark and placed in the YourProject.Bar namespace in the YourProject.Bar.Benchmarks assembly. -- Modify the associated .csproj file to override the root namespace, e.g., YourProject.Bar. - -## 2. Attributes and Configuration - -- Use `BenchmarkDotNet` attributes to express intent and collect relevant metrics: - - `[MemoryDiagnoser]` to capture memory allocations. - - `[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]` to group related benchmarks. - - `[Params]` for input sizes or variations to exercise multiple scenarios. - - `[GlobalSetup]` for one-time initialization that's not part of measured work. - - `[Benchmark]` on methods representing measured operations; consider `Baseline = true` and `Description` to improve report clarity. -- Keep benchmark configuration minimal and explicit; prefer in-class attributes over large shared configs unless re-used widely. - -## 3. Structure and Best Practices - -- Keep benchmarks focused: each `Benchmark` method should measure a single logical operation. -- Avoid doing expensive setup work inside a measured method; use `[GlobalSetup]`, `[IterationSetup]`, or cached fields instead. -- Use `Params` to cover micro, mid and macro input sizes (for example: small, medium, large) and verify performance trends across them. -- Use small, deterministic data sets and avoid external systems (network, disk, DB). If external systems are necessary, mark them clearly and do not include them in CI benchmark runs by default. -- Capture results that are meaningful: time, allocations, and if needed custom counters. Prefer `MemoryDiagnoser` and descriptive `Description` values. - -## 4. Naming Conventions for Methods - -- Method names should be descriptive and indicate the scenario, e.g., `Parse_Short`, `ComputeHash_Large`. -- When comparing implementations, mark one method with `Baseline = true` and use similar names so reports are easy to read. - -## 5. Example Benchmark - -```csharp -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; - -namespace YourProject -{ - [MemoryDiagnoser] - [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] - public class SampleOperationBenchmark - { - [Params(8, 256, 4096)] - public int Count { get; set; } - - private byte[] _payload; - - [GlobalSetup] - public void Setup() - { - _payload = new byte[Count]; - // deterministic initialization - } - - [Benchmark(Baseline = true, Description = "Operation - baseline")] - public int Operation_Baseline() => SampleOperation.Process(_payload); - - [Benchmark(Description = "Operation - optimized")] - public int Operation_Optimized() => SampleOperation.ProcessOptimized(_payload); - } -} -``` - -## 6. Reporting and CI - -- Benchmarks are primarily for local and tuning runs; be cautious about running heavy BenchmarkDotNet workloads in CI. Prefer targeted runs or harnesses for CI where appropriate. -- Keep benchmark projects isolated (e.g., `tuning/*.csproj`) so they don't affect package builds or production artifacts. - -## 7. Additional Guidelines - -- Keep benchmarks readable and well-documented; add comments explaining non-obvious choices. -- If a benchmark exposes regressions or optimizations, add a short note in the benchmark file referencing the relevant issue or PR. -- For any shared helpers for benchmarking, prefer small utility classes inside the `tuning` projects rather than cross-cutting changes to production code. - -For further examples, refer to the benchmark files under the `tuning/` folder. - -**Scope: Writing XML documentation.** The following guidance applies to paths matching "**/*.cs". - -# Writing XML documentation -This document provides instructions for writing XML documentation. - -## 1. Documentation Style - -- Use the same documentation style as found throughout the codebase. -- Add XML doc comments to public and protected classes and methods where appropriate. -- Example: - -```csharp -using System; -using System.Collections.Generic; -using System.IO; -using Cuemon.Collections.Generic; -using Cuemon.Configuration; -using Cuemon.IO; -using Cuemon.Text; - -namespace Cuemon.Security -{ - /// - /// Represents the base class from which all implementations of hash algorithms and checksums should derive. - /// - /// The type of the configured options. - /// - /// - /// - public abstract class Hash : Hash, IConfigurable where TOptions : ConvertibleOptions, new() - { - /// - /// Initializes a new instance of the class. - /// - /// The which may be configured. - protected Hash(Action setup) - { - Options = Patterns.Configure(setup); - } - - /// - /// Gets the configured options of this instance. - /// - /// The configured options of this instance. - public TOptions Options { get; } - - - /// - /// The endian-initializer of this instance. - /// - /// An instance of the configured options. - protected sealed override void EndianInitializer(EndianOptions options) - { - options.ByteOrder = Options.ByteOrder; - } - } - - /// - /// Represents the base class that defines the public facing structure to expose. - /// - /// - public abstract class Hash : IHash - { - /// - /// Initializes a new instance of the class. - /// - protected Hash() - { - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(bool input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(byte input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(char input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(DateTime input) - { - return ComputeHash(Convertible.GetBytes(input)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(DBNull input) - { - return ComputeHash(Convertible.GetBytes(input)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(decimal input) - { - return ComputeHash(Convertible.GetBytes(input)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(double input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(short input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(int input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(long input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(sbyte input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(float input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(ushort input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(uint input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(ulong input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// The which may be configured. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(string input, Action setup = null) - { - return ComputeHash(Convertible.GetBytes(input, setup)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(Enum input) - { - return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(params IConvertible[] input) - { - return ComputeHash(Arguments.ToEnumerableOf(input)); - } - - /// - /// Computes the hash value for the specified sequence of . - /// - /// The sequence of to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(IEnumerable input) - { - return ComputeHash(Convertible.GetBytes(input)); - } - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public abstract HashResult ComputeHash(byte[] input); - - /// - /// Computes the hash value for the specified . - /// - /// The to compute the hash code for. - /// A containing the computed hash code of the specified . - public virtual HashResult ComputeHash(Stream input) - { - return ComputeHash(Patterns.SafeInvoke(() => new MemoryStream(), destination => - { - Decorator.Enclose(input).CopyStream(destination); - return destination; - }).ToArray()); - } - - /// - /// Defines the initializer that must implement. - /// - /// An instance of the configured options. - protected abstract void EndianInitializer(EndianOptions options); - } -} - -namespace Cuemon.Security -{ - /// - /// Configuration options for . - /// - public class FowlerNollVoOptions : ConvertibleOptions - { - /// - /// Initializes a new instance of the class. - /// - /// - /// The following table shows the initial property values for an instance of . - /// - /// - /// Property - /// Initial Value - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - public FowlerNollVoOptions() - { - Algorithm = FowlerNollVoAlgorithm.Fnv1a; - ByteOrder = Endianness.BigEndian; - } - - /// - /// Gets or sets the algorithm of the Fowler-Noll-Vo hash function. - /// - /// The algorithm of the Fowler-Noll-Vo hash function. - public FowlerNollVoAlgorithm Algorithm { get; set; } - } -} -``` +# Agent Instructions for Codebelt.Extensions.Asp.Versioning + +This document provides guidance for AI agents working in this repository. + +## Project Overview + +Codebelt.Extensions.Asp.Versioning is a .NET library providing uniform, opinionated, and extensible APIs for ASP.NET Core RESTful API versioning. The solution targets .NET 11.0 and .NET 10.0. It includes: + +- **Codebelt.Extensions.Asp.Versioning** — Core library for API versioning with middleware integration, service configuration, and version parsing strategies. + +## Coding Standards + +- **Text encoding:** UTF-8 for text files (enforced via `.editorconfig`) +- **Template rewrites:** Preserve UTF-8 explicitly when scripts or tools rewrite text files; avoid locale-dependent encoding defaults +- **Namespaces:** File-scoped namespaces are required (enforced via `.editorconfig`) +- **Top-level statements:** Not allowed (enforced via `.editorconfig`) +- **Language version:** Always use the latest C# features (`LangVersion=latest`) +- **Nullable:** Enable nullable reference types in all new code +- **XML documentation:** All public APIs must have XML documentation comments +- **Testing:** Use xUnit v3 with Codebelt.Extensions.Xunit.App base classes + +## Project Structure + +- `src/` — Production source code + - `Codebelt.Extensions.Asp.Versioning/` — Core API versioning library with middleware, options, and parsers +- `test/` — Unit tests (project names end with `Tests`) + - `Codebelt.Extensions.Asp.Versioning.Tests/` — Unit tests for core functionality + - `Codebelt.Extensions.Asp.Versioning.FunctionalTests/` — End-to-end functional tests for integration scenarios +- `.nuget/` — Per-package NuGet metadata (icon, README, release notes) +- `.docfx/` — DocFX documentation configuration +- `.github/` — CI/CD workflows and contributing guidelines + +## Test Conventions + +- Test project names must end with `Tests` (e.g., `{PROJECT_NAME}.Tests`) +- Test classes should inherit from the `Test` base class in `Codebelt.Extensions.Xunit` +- Use `Microsoft.Testing.Platform` as the test runner (`UseMicrosoftTestingPlatformRunner=true`) +- All tests are executable (`OutputType=Exe`) +- Test namespaces must match the SUT namespace (System Under Test), without `.Tests` suffix +- See the detailed test writing guidelines in this document + +## Build & CI + +- Centralized package versions via `Directory.Packages.props` +- Resolve new or updated `Directory.Packages.props` versions from NuGet.org and keep them on the latest stable listed releases +- Centralized build configuration via `Directory.Build.props` +- MinVer for semantic versioning from Git tags +- Strong-name signing is enabled in CI environments (`CI=true`) +- Keep `.github/dependabot.yml` enabled at the repo root so central NuGet package management stays current + +## .bot/ Folder + +If a `.bot/` folder exists at the root, it contains **confidential, local-only** working material for AI agents — product requirement documents (PRDs), design proposals, agentic loop state, and brainstorming outputs. This folder is gitignored and never committed. + +When starting creative or design work (new features, architecture decisions, PRD drafts), use the [brainstorming skill](https://skills.sh/obra/superpowers/brainstorming) and save outputs to `.bot/`. Only move finalized, non-confidential instructions into `AGENTS.md`. + +## Git Operations Safeguards + +Agents must never automatically commit code changes or push to remote repositories. Both actions require explicit user approval: + +- **Commits**: Always request confirmation from the user before staging and committing code. Present a clear summary of the changes and wait for approval before executing the commit. +- **Remote Operations**: Do not push, pull, fetch, or interact with `origin` or any remote repository without explicit user instruction. These operations modify repository history and can cause data loss if performed unexpectedly. + +**Rationale:** Automatic commits can clutter history with incomplete work, temporary debugging code, or unintended changes. Unexpected remote operations risk overwriting or losing commits on shared branches. Always require explicit user approval before performing these actions. + + +## DocFX Documentation Maintenance + +When changing public .NET APIs, keep the DocFX documentation current in the same change set. + +Documentation updates must cover public API only. Do not document private or internal types or members. Do not create namespace overview pages for namespaces that contain no public API. + +Public non-abstraction types — including enums, structs, records, plain classes, and static extension containers — are valid documentation targets. Generic public types and generic extension methods are valid documentation targets too. Do not exclude a type solely because it is generic or because reflection reports it as abstract and sealed (that is the IL pattern for a static class). + +For public non-abstraction types, include at least one realistic, copy/paste-ready usage example on the generated type page/overwrite section for that type UID. For example, a public `Class1` requires an example on the `Class1` API page, not only on the namespace page. Prefer deriving examples from existing unit, functional, or integration tests, but convert test code into real-life consumer-oriented usage. + +Missing type examples must be added through per-type DocFX overwrite files under `.docfx/api/types/{TypeUid}.md` in Codebelt repositories. Namespace overview text and `Extension Members` tables are not substitutes for type-page examples. + +Public extension methods must have examples too. Listing an extension method in an `Extension Members` table is required, but it is not enough. + +All added or changed code samples must be deterministic and verified to compile. Do not add pseudo-code, ellipses, hidden test helpers, or examples that rely on unverified behavior. + +Compilation is necessary but not sufficient. Do not present runtime implementation names such as `services.GetType().Name` or `host.GetType().FullName` as the example outcome. Show application behavior, configured state, a resolved domain service, an HTTP response, or another result that explains why a caller uses the API. Application-entry-point examples must not declare an empty local `Program` type merely to compile; show a real entry point or clearly identify the referenced application project. + +Every namespace containing public API must have a DocFX namespace overview page named after the namespace, such as `X.Y.Z.md`, under `.docfx/api/namespaces/`, using DocFX overwrite front matter with the namespace `uid`. + +Namespace pages must identify key entry points from release notes, package documentation, public factories/builders, and strong functional tests, then help readers choose among adjacent workflows. When the package complements a well-known upstream API, compare concrete acquisition, customization, lifecycle, and sharing tradeoffs from current official guidance; do not claim drop-in replacement compatibility without evidence. + +Namespaces exposing public extension methods must document those extension members at namespace level. The namespace page must include an `Extension Members` table listing the extended type, the extension marker, and the public extension methods. Extension members are rendered under the heading `Extension Members`. + +Both namespace overwrite files and type overwrite files are required deliverables in the same run. Generating only namespace pages or only type pages is incomplete. + +`docfx.json` must keep namespace and type overwrite files in separate subdirectories. `build.overwrite` must include both `api/namespaces/**/*.md` (for namespace pages) and `api/types/**/*.md` (for type pages). `build.content` must exclude both `api/namespaces/**` and `api/types/**` to prevent overwrite Markdown from being treated as conceptual content. Do not use `api/**/*.md` under `build.overwrite` or `build.content`. + +Availability must be documented by referencing the appropriate include file when one exists, or by adding explicit availability text when no suitable include exists. Availability must reflect the actual target frameworks, conditional compilation, and project configuration. + +For conditionally compiled APIs, choose the executable test framework from the asset that contains the API. Inspect the preprocessor condition, project TFMs, package `lib/` assets, and resolved consumer asset before changing a sample. For APIs under `NETSTANDARD2_0` or `NETSTANDARD2_0_OR_GREATER`, when modern `lib/netX.0/` assets also exist, use `net48` (or another supported .NET Framework target from `net462` onward) so the consumer selects `lib/netstandard2.0/`. Never use `netstandard*` as an executable target, and never use a modern `netX.0` target when it selects an asset where the API is absent. For other TFM guards, select a runnable consumer TFM that resolves to the containing asset and confirm that selection from restore or build evidence. + +Preserve manual documentation edits. Prefer additive changes, but correct stale or contradictory information so documentation remains accurate. + +Preserve working Markdown links, `Related:` references, and historical URL citations during prose rewrites. Remove or replace a URL only after directly verifying that the current destination returns HTTP 404. Timeouts, 403s, rate limits, DNS failures, and other lookup problems are not removal evidence. + +Interim scratch artifacts do not belong in the repository working tree. Store assessment queues, project manifests, review reports, captured validator output, progress notes, and one-off helper scripts in temp or session storage instead. New working-tree files are only legitimate when they are the managed `AGENTS.md` block, the active `docfx.json`, the deterministic `skip-compile-allowlist.json` waiver file when one is truly required, or DocFX-authored namespace/type Markdown that maps to a real public namespace or type. Everything else is blocking cleanup work, not a documentation deliverable. The validator auto-detects generic-arity type families (such as `MutableTuple`1`..`MutableTuple`N`) and skips redundant sibling examples from the public API surface alone, so no family-skip manifest is ever written into the repository. + +Skip markers are waivers, not fixes. A skip marker only suppresses compilation when it both existed before the current run and matches an entry in `.docfx/skip-compile-allowlist.json`. Each allowlist entry must include `diagnosticCode`, `filePath`, `uid` or `symbol`, `reason`, `approval`, and `lifetime` (`temporary` or `permanent`). Newly introduced or unallowlisted skip markers remain fail-level diagnostics and do not permit a completion claim. + +Do not emit a final report, audit result, completion summary, or handoff while `summary.canClaimCompletion` is false, `summary.remainingWorkItems` is greater than zero, `summary.remainingGates` is non-empty, `summary.fullVerificationRan` is false, fail-level diagnostics remain, `summary.newlyIntroducedSkipMarkers` is non-zero, or `summary.interimArtifacts` is non-zero. Large queues, many changed files, repetitive next steps, long runtimes, context pressure, session length, task size, or a "stable queue" are not valid stop reasons; the next action must be another remediation batch, a validator rerun, a validator/tooling fix, or a true blocker with exact evidence. + +Context pressure is not a completion condition. If the session feels constrained while work remains, continue with a smaller deterministic batch, regenerate deterministic queue state such as `--assessment-queue`, `--project-manifest`, or the active dry-run manifest/review pair, or report a true tooling failure with the exact command, exit code, and output. When naming a queue-state regeneration command, resolve it to a concrete temp/session path instead of leaving `` as a placeholder. Do not stop with phrases like "given context constraints", "best done in a follow-up", "remaining work requires authoring", "this is a massive task", or "I will provide a focused summary". A context-sized handoff while work remains is `FAIL_CONTEXT_HANDOFF_WITH_REMAINING_WORK`; the remediation is to continue with a smaller deterministic batch. + +Before completing documentation work, run the relevant verification commands, normally: + +```bash +dotnet build +dotnet test +dotnet run --file /scripts/docfx.cs -- --repo-root . --build-api-model --validate-samples --verify-docfx-build +``` + +Codebelt repositories are normally strong-name signed with a `.snk` file in the repository root on the main author's codespace. Preserve and copy that root `.snk` file when building a temporary copy. If the repository or temp copy has no root `.snk`, run build and test verification with `-p:SkipSignAssembly=true`, for example `dotnet build -p:SkipSignAssembly=true` and `dotnet test -p:SkipSignAssembly=true`. + +The final DocFX verification must run outside the working tree when possible. The `--verify-docfx-build` option copies the repository to a temp workspace, runs DocFX against the resolved `docfx.json` there, and removes the temp workspace afterward so generated API YAML, manifest files, and site output do not flood git status. Do not call the work complete until the final JSON reports `summary.fullVerificationRan: true`, `summary.canClaimCompletion: true`, `summary.remainingWorkItems: 0`, an empty `summary.remainingGates`, an empty `summary.remainingDiagnosticsByCode`, `summary.newlyIntroducedSkipMarkers: 0`, and `summary.interimArtifacts: 0`. + +If a command cannot be run, report the exact limitation or failure instead of claiming the documentation was verified. + + +**Scope: Writing Unit Tests.** The following guidance applies to paths matching "**/*.{cs,csproj}". + +# Writing Unit Tests +This document provides instructions for writing unit tests for a project/solution. Please follow these guidelines to ensure consistency and maintainability. + +## 1. Base Class + +**Always inherit from the `Test` base class** for all unit test classes. +This ensures consistent setup, teardown, and output handling across all tests. + +> Important: Do NOT add `using Xunit.Abstractions`. xUnit v3 no longer exposes that namespace; including it is incorrect and will cause compilation errors. Use the `Codebelt.Extensions.Xunit` Test base class and `using Xunit;` as shown in the examples below. If you need access to test output, rely on the Test base class (which accepts the appropriate output helper) rather than importing `Xunit.Abstractions`. + +```csharp +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Your.Namespace +{ + public class YourTestClass : Test + { + public YourTestClass(ITestOutputHelper output) : base(output) + { + } + + // Your tests here + } +} +``` + +## 2. Test Method Attributes + +- Use `[Fact]` for standard unit tests. +- Use `[Theory]` with `[InlineData]` or other data sources for parameterized tests. + +## 3. Naming Conventions + +- **Test classes**: End with `Test` (e.g., `DateSpanTest`). +- **Test methods**: Use descriptive names that state the expected behavior (e.g., `ShouldReturnTrue_WhenConditionIsMet`). + +## 4. Assertions + +- Use `Assert` methods from xUnit for all assertions. +- Prefer explicit and expressive assertions (e.g., `Assert.Equal`, `Assert.NotNull`, `Assert.Contains`). + +## 5. File and Namespace Organization + +- Place test files in the appropriate test project and folder structure. +- Use namespaces that mirror the source code structure. The namespace of a test file MUST match the namespace of the System Under Test (SUT). Do NOT append ".Tests", ".Benchmarks" or similar suffixes to the namespace. Only the assembly/project name should indicate that the file is a test/benchmark (for example: YourProject.Foo.Tests assembly, but namespace YourProject.Foo). + - Example: If the SUT class is declared as: + ```csharp + namespace YourProject.Foo.Bar + { + public class Zoo { /* ... */ } + } + ``` + then the corresponding unit test class must use the exact same namespace: + ```csharp + namespace YourProject.Foo.Bar + { + public class ZooTest : Test { /* ... */ } + } + ``` + - Do NOT use: + ```csharp + namespace YourProject.Foo.Bar.Tests { /* ... */ } // ❌ + namespace YourProject.Foo.Bar.Benchmarks { /* ... */ } // ❌ + ``` + - The unit tests for the YourProject.Foo assembly live in the YourProject.Foo.Tests assembly. + - The functional tests for the YourProject.Foo assembly live in the YourProject.Foo.FunctionalTests assembly. + - Test class names end with Test and live in the same namespace as the class being tested, e.g., the unit tests for the Boo class that resides in the YourProject.Foo assembly would be named BooTest and placed in the YourProject.Foo namespace in the YourProject.Foo.Tests assembly. + - Modify the associated .csproj file to override the root namespace so the compiled namespace matches the SUT. Example: + ```xml + + YourProject.Foo + + ``` +- When generating test scaffolding automatically, resolve the SUT's namespace from the source file (or project/assembly metadata) and use that exact namespace in the test file header. + +- Notes: + - This rule ensures type discovery and XML doc links behave consistently and reduces confusion when reading tests. + - Keep folder structure aligned with the production code layout to make locating SUT <-> test pairs straightforward. + +## 6. Example Test + +```csharp +using System; +using System.Globalization; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace YourProject +{ + /// + /// Tests for the class. + /// + public class DateSpanTest : Test + { + public DateSpanTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Parse_ShouldGetOneMonthOfDifference_UsingIso8601String() + { + var start = new DateTime(2021, 3, 5).ToString("O"); + var end = new DateTime(2021, 4, 5).ToString("O"); + + var span = DateSpan.Parse(start, end); + + Assert.Equal("0:01:31:00:00:00.0", span.ToString()); + Assert.Equal(0, span.Years); + Assert.Equal(1, span.Months); + Assert.Equal(31, span.Days); + Assert.Equal(0, span.Hours); + Assert.Equal(0, span.Minutes); + Assert.Equal(0, span.Seconds); + Assert.Equal(0, span.Milliseconds); + + Assert.Equal(0.08493150684931507, span.TotalYears); + Assert.Equal(1, span.TotalMonths); + Assert.Equal(31, span.TotalDays); + Assert.Equal(744, span.TotalHours); + Assert.Equal(44640, span.TotalMinutes); + Assert.Equal(2678400, span.TotalSeconds); + Assert.Equal(2678400000, span.TotalMilliseconds); + + Assert.Equal(6, span.GetWeeks()); + Assert.Equal(-1566296493, span.GetHashCode()); + + TestOutput.WriteLine(span.ToString()); + } + } +} +``` + +## 7. Additional Guidelines + +- Keep tests focused and isolated. +- Do not rely on external systems except for xUnit itself and Codebelt.Extensions.Xunit (and derived from this). +- Ensure tests are deterministic and repeatable. + +## 8. Test Doubles + +- Preferred test doubles include dummies, fakes, stubs and spies if and when the design allows it. +- Under special circumstances, mock can be used (using Moq library). +- Before overriding methods, verify that the method is virtual or abstract; this rule also applies to mocks. +- Never mock IMarshaller; always use a new instance of JsonMarshaller. + +## 9. Avoid `InternalsVisibleTo` in Tests + +- **Do not** use `InternalsVisibleTo` to access internal types or members from test projects. +- Prefer **indirect testing via public APIs** that depend on the internal implementation (public facades, public extension methods, or other public entry points). + +### Preferred Pattern + +**Pattern name:** Public Facade Testing (also referred to as *Public API Proxy Testing*) + +**Description:** +Internal classes and methods must be validated by exercising the public API that consumes them. Tests should assert observable behavior exposed by the public surface rather than targeting internal implementation details directly. + +### Example Mapping + +- **Internal helper:** `DelimitedString` (internal static class) +- **Public API:** `TestOutputHelperExtensions.WriteLines()` (public extension method) +- **Test strategy:** Write tests for `WriteLines()` and verify its public behavior. The internal call to `DelimitedString.Create()` is exercised implicitly. + +### Benefits + +- Avoids exposing internal types to test assemblies. +- Ensures tests reflect real-world usage patterns. +- Maintains strong encapsulation and a clean public API. +- Tests remain resilient to internal refactoring as long as public behavior is preserved. + +### When to Apply + +- Internal logic is fully exercised through existing public APIs. +- Public entry points provide sufficient coverage of internal code paths. +- The internal implementation exists solely as a helper or utility for public-facing functionality. + +## 10. ExcludeFromCodeCoverage Prohibition + +**Do not use `ExcludeFromCodeCoverage` attribute on any code.** This includes: + +- Test classes or test methods +- Production code +- Configuration code +- Any other code path + +### Rationale + +- Excluding code from coverage hides gaps and creates false confidence in test completeness. +- If a code path cannot or should not be tested, refactor the code to eliminate that path rather than hiding it from metrics. +- Every executable line should be covered by tests or be genuinely unreachable (dead code to be removed). + +### Alternative Approaches + +- **Untestable code paths**: Refactor to separate concerns and eliminate the untestable path. +- **External dependencies**: Use test doubles (fakes, stubs, spies) instead of excluding from coverage. +- **Configuration-only code**: Move to configuration files or extract into testable methods. +- **Generated or third-party code**: These should not be in the primary codebase; use NuGet packages or dedicated vendor folders if necessary. + +**Scope: Writing Performance Tests.** The following guidance applies to paths matching "tuning/**, **/*Benchmark*.cs". + +# Writing Performance Tests +This document provides guidance for writing performance tests (benchmarks) for a project/solution using BenchmarkDotNet. Follow these guidelines to keep benchmarks consistent, readable, and comparable. + +## 1. Naming and Placement + +- Place micro- and component-benchmarks under the `tuning/` folder or in projects named `*.Benchmarks`. +- Place benchmark files in the appropriate benchmark project and folder structure. +- Use namespaces that mirror the source code structure, e.g. do not suffix with `Benchmarks`. +Namespace rule: DO NOT append `.Benchmarks` to the namespace. Benchmarks must live in the same namespace as the production assembly. Example: if the production assembly uses `namespace YourProject.Security.Cryptography`, the benchmark file should also use: + ``` + namespace YourProject.Security.Cryptography + { + public class Sha512256Benchmark { /* ... */ } + } + ``` +The class name must end with `Benchmark`, but the namespace must match the assembly (no `.Benchmarks` suffix). +- The benchmarks for the YourProject.Bar assembly live in the YourProject.Bar.Benchmarks assembly. +- Benchmark class names end with Benchmark and live in the same namespace as the class being measured, e.g., the benchmarks for the Zoo class that resides in the YourProject.Bar assembly would be named ZooBenchmark and placed in the YourProject.Bar namespace in the YourProject.Bar.Benchmarks assembly. +- Modify the associated .csproj file to override the root namespace, e.g., YourProject.Bar. + +## 2. Attributes and Configuration + +- Use `BenchmarkDotNet` attributes to express intent and collect relevant metrics: + - `[MemoryDiagnoser]` to capture memory allocations. + - `[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]` to group related benchmarks. + - `[Params]` for input sizes or variations to exercise multiple scenarios. + - `[GlobalSetup]` for one-time initialization that's not part of measured work. + - `[Benchmark]` on methods representing measured operations; consider `Baseline = true` and `Description` to improve report clarity. +- Keep benchmark configuration minimal and explicit; prefer in-class attributes over large shared configs unless re-used widely. + +## 3. Structure and Best Practices + +- Keep benchmarks focused: each `Benchmark` method should measure a single logical operation. +- Avoid doing expensive setup work inside a measured method; use `[GlobalSetup]`, `[IterationSetup]`, or cached fields instead. +- Use `Params` to cover micro, mid and macro input sizes (for example: small, medium, large) and verify performance trends across them. +- Use small, deterministic data sets and avoid external systems (network, disk, DB). If external systems are necessary, mark them clearly and do not include them in CI benchmark runs by default. +- Capture results that are meaningful: time, allocations, and if needed custom counters. Prefer `MemoryDiagnoser` and descriptive `Description` values. + +## 4. Naming Conventions for Methods + +- Method names should be descriptive and indicate the scenario, e.g., `Parse_Short`, `ComputeHash_Large`. +- When comparing implementations, mark one method with `Baseline = true` and use similar names so reports are easy to read. + +## 5. Example Benchmark + +```csharp +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace YourProject +{ + [MemoryDiagnoser] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] + public class SampleOperationBenchmark + { + [Params(8, 256, 4096)] + public int Count { get; set; } + + private byte[] _payload; + + [GlobalSetup] + public void Setup() + { + _payload = new byte[Count]; + // deterministic initialization + } + + [Benchmark(Baseline = true, Description = "Operation - baseline")] + public int Operation_Baseline() => SampleOperation.Process(_payload); + + [Benchmark(Description = "Operation - optimized")] + public int Operation_Optimized() => SampleOperation.ProcessOptimized(_payload); + } +} +``` + +## 6. Reporting and CI + +- Benchmarks are primarily for local and tuning runs; be cautious about running heavy BenchmarkDotNet workloads in CI. Prefer targeted runs or harnesses for CI where appropriate. +- Keep benchmark projects isolated (e.g., `tuning/*.csproj`) so they don't affect package builds or production artifacts. + +## 7. Additional Guidelines + +- Keep benchmarks readable and well-documented; add comments explaining non-obvious choices. +- If a benchmark exposes regressions or optimizations, add a short note in the benchmark file referencing the relevant issue or PR. +- For any shared helpers for benchmarking, prefer small utility classes inside the `tuning` projects rather than cross-cutting changes to production code. + +For further examples, refer to the benchmark files under the `tuning/` folder. + +**Scope: Writing XML documentation.** The following guidance applies to paths matching "**/*.cs". + +# Writing XML documentation +This document provides instructions for writing XML documentation. + +## 1. Documentation Style + +- Use the same documentation style as found throughout the codebase. +- Add XML doc comments to public and protected classes and methods where appropriate. +- Example: + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using Cuemon.Collections.Generic; +using Cuemon.Configuration; +using Cuemon.IO; +using Cuemon.Text; + +namespace Cuemon.Security +{ + /// + /// Represents the base class from which all implementations of hash algorithms and checksums should derive. + /// + /// The type of the configured options. + /// + /// + /// + public abstract class Hash : Hash, IConfigurable where TOptions : ConvertibleOptions, new() + { + /// + /// Initializes a new instance of the class. + /// + /// The which may be configured. + protected Hash(Action setup) + { + Options = Patterns.Configure(setup); + } + + /// + /// Gets the configured options of this instance. + /// + /// The configured options of this instance. + public TOptions Options { get; } + + + /// + /// The endian-initializer of this instance. + /// + /// An instance of the configured options. + protected sealed override void EndianInitializer(EndianOptions options) + { + options.ByteOrder = Options.ByteOrder; + } + } + + /// + /// Represents the base class that defines the public facing structure to expose. + /// + /// + public abstract class Hash : IHash + { + /// + /// Initializes a new instance of the class. + /// + protected Hash() + { + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(bool input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(byte input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(char input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(DateTime input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(DBNull input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(decimal input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(double input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(short input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(int input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(long input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(sbyte input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(float input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(ushort input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(uint input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(ulong input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// The which may be configured. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(string input, Action setup = null) + { + return ComputeHash(Convertible.GetBytes(input, setup)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(Enum input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(params IConvertible[] input) + { + return ComputeHash(Arguments.ToEnumerableOf(input)); + } + + /// + /// Computes the hash value for the specified sequence of . + /// + /// The sequence of to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(IEnumerable input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public abstract HashResult ComputeHash(byte[] input); + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(Stream input) + { + return ComputeHash(Patterns.SafeInvoke(() => new MemoryStream(), destination => + { + Decorator.Enclose(input).CopyStream(destination); + return destination; + }).ToArray()); + } + + /// + /// Defines the initializer that must implement. + /// + /// An instance of the configured options. + protected abstract void EndianInitializer(EndianOptions options); + } +} + +namespace Cuemon.Security +{ + /// + /// Configuration options for . + /// + public class FowlerNollVoOptions : ConvertibleOptions + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The following table shows the initial property values for an instance of . + /// + /// + /// Property + /// Initial Value + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public FowlerNollVoOptions() + { + Algorithm = FowlerNollVoAlgorithm.Fnv1a; + ByteOrder = Endianness.BigEndian; + } + + /// + /// Gets or sets the algorithm of the Fowler-Noll-Vo hash function. + /// + /// The algorithm of the Fowler-Noll-Vo hash function. + public FowlerNollVoAlgorithm Algorithm { get; set; } + } +} +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c496d..2323045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), For more details, please refer to `PackageReleaseNotes.txt` on a per assembly basis in the `.nuget` folder. -> [!NOTE] +> [!NOTE] > Changelog entries prior to version 8.4.0 was migrated from previous versions of Cuemon.Extensions.Asp.Versioning. +## [11.0.0] - 2026-11-10 + +This is a major release moving the supported baseline to .NET 11 and .NET 10. .NET 9 consumers must remain on the 10.x package line or upgrade their application framework. + +### Added + +- Added SonarAnalyzer.CSharp 10.35.0.4138 as a private production build dependency. + +### Changed + +- Replaced the .NET 9 target with .NET 11 for the library and both test projects, retaining .NET 10 and the compatible stable Asp.Versioning 10.x and Cuemon 10.8.2 dependencies, +- Enabled prerelease SDK provisioning for build, pack, tests, SonarCloud, and CodeQL using the existing reusable-workflow contracts, +- Updated Codebelt.Coverlet.MTP from 10.1.0 to 10.1.1, Microsoft.Testing.Extensions.HangDump from 2.5.1 to 2.5.2, and Cuemon.AspNetCore, Cuemon.Extensions.AspNetCore.Mvc, Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json, and Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml from 10.8.1 to 10.8.2, +- Updated the YAML error response test to expect the ASP.NET Core 11 generated stack-frame name while retaining the complete response assertion, +- Applied file-scoped namespaces and required namespace diagnostics, refreshed Sonar rule links, and normalized tracked text to LF without a UTF-8 BOM, +- Configured Docker test environments for .NET 10 and .NET 11 and refreshed contributor instructions, +- Updated DocFX sitemap hosting and nginx configuration, +- Aligned current framework availability, package documentation, contributing guidance, and the service-update availability fallback with .NET 11 and .NET 10. + ## [10.2.5] - 2026-10-17 This is a patch release updating dependencies, documentation hosting, and repository agent guidance. @@ -272,6 +291,7 @@ 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 +[11.0.0]: https://github.com/codebeltnet/asp-versioning/compare/v10.2.5...v11.0.0 [10.2.5]: https://github.com/codebeltnet/asp-versioning/compare/v10.2.4...v10.2.5 [10.2.4]: https://github.com/codebeltnet/asp-versioning/compare/v10.2.3...v10.2.4 [10.2.3]: https://github.com/codebeltnet/asp-versioning/compare/v10.2.2...v10.2.3 diff --git a/Codebelt.Extensions.Asp.Versioning.slnx b/Codebelt.Extensions.Asp.Versioning.slnx index 38bb9fa..dbec60f 100644 --- a/Codebelt.Extensions.Asp.Versioning.slnx +++ b/Codebelt.Extensions.Asp.Versioning.slnx @@ -1,9 +1,9 @@ - - - - - - - - - + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 41fec04..65c426d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,87 +1,88 @@ - - - - $(MSBuildProjectName.EndsWith('Tests')) - $([MSBuild]::IsOSPlatform('Linux')) - $([MSBuild]::IsOSPlatform('Windows')) - true - false - latest - ..\..\.nuget\$(MSBuildProjectName)\PackageReleaseNotes.txt - - - - true - true - - - - net10.0;net9.0 - Copyright © Geekle 2024-2026. All rights reserved. - gimlichael - Geekle - Extensions for Asp.Versioning API by Codebelt - icon.png - README.md - https://versioning.codebelt.net/ - MIT - https://github.com/codebeltnet/asp-versioning - git - en-US - true - true - true - snupkg - true - true - $(MSBuildThisFileDirectory)versioning.snk - true - latest - Recommended - 7035,CA2260,S6618 - v - true - - - - - - - - - - - - - - net10.0;net9.0 - - - - Exe - false - false - false - false - true - 0 - none - NU1701,NETSDK1206 - false - true - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + $(MSBuildProjectName.EndsWith('Tests')) + $([MSBuild]::IsOSPlatform('Linux')) + $([MSBuild]::IsOSPlatform('Windows')) + true + false + latest + ..\..\.nuget\$(MSBuildProjectName)\PackageReleaseNotes.txt + + + + true + true + + + + net11.0;net10.0 + Copyright © Geekle 2024-2026. All rights reserved. + gimlichael + Geekle + Extensions for Asp.Versioning API by Codebelt + icon.png + README.md + https://versioning.codebelt.net/ + MIT + https://github.com/codebeltnet/asp-versioning + git + en-US + true + true + true + snupkg + true + true + $(MSBuildThisFileDirectory)versioning.snk + true + latest + Recommended + 7035,CA2260,S6618 + v + true + + + + + + + + + + + + + + + net11.0;net10.0 + + + + Exe + false + false + false + false + true + 0 + none + NU1701,NETSDK1206 + false + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets index 5f9df1e..f6a6a00 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,14 +1,14 @@ - - - - $([System.IO.File]::ReadAllText('$(PackageReleaseNotesFile)')) - - - - - - 0 - $(MinVerMajor).$(MinVerMinor).$(MinVerPatch).$(GITHUB_RUN_NUMBER) - - - + + + + $([System.IO.File]::ReadAllText('$(PackageReleaseNotesFile)')) + + + + + + 0 + $(MinVerMajor).$(MinVerMinor).$(MinVerPatch).$(GITHUB_RUN_NUMBER) + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 0112a82..3dec846 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,25 +6,20 @@ - - - - + + + + - - + + + - - - - - - - + diff --git a/README.md b/README.md index 9ee365a..a80aa5a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ An open-source project (MIT license) that targets and complements the [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) versioning engine. It aims to provide a uniform and convenient developer experience when working with RESTful API versioning. -Your versatile Asp.Versioning companion for modern development with `.NET 9` and `.NET 10`. +Your versatile Asp.Versioning companion for modern development with `.NET 10` and `.NET 11`. It is, by heart, free, flexible and built to extend and boost your agile codebelt. diff --git a/src/Codebelt.Extensions.Asp.Versioning/ApplicationBuilderExtensions.cs b/src/Codebelt.Extensions.Asp.Versioning/ApplicationBuilderExtensions.cs index a6ba9f2..6724a1c 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/ApplicationBuilderExtensions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/ApplicationBuilderExtensions.cs @@ -1,42 +1,41 @@ -using System; -using System.Threading.Tasks; -using Cuemon; -using Cuemon.AspNetCore.Http; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -namespace Codebelt.Extensions.Asp.Versioning -{ - /// - /// Extension methods for the interface. - /// - public static class ApplicationBuilderExtensions - { - /// - /// Adds a middleware to the pipeline that will intercept status codes written directly to the response and throw an appropriate that can then be translated and re-written in a consistent way. - /// - /// The type that provides the mechanisms to configure an application’s request pipeline. - /// The function delegate that will resolve and throw a proper from status codes written directly to the response. - /// A reference to this instance after the operation has completed. - /// - /// cannot be null. - /// - /// This method was introduced because of the design decisions made of the author of Asp.Versioning; for more information have a read at https://github.com/dotnet/aspnet-api-versioning/issues/886 - public static IApplicationBuilder UseRestfulApiVersioning(this IApplicationBuilder builder, Func statusCodeExceptionFactory = null) - { - Validator.ThrowIfNull(builder); - statusCodeExceptionFactory ??= context => - { - if (HttpStatusCodeException.TryParse(context.Response.StatusCode, out var statusCodeEquivalentException)) - { - return statusCodeEquivalentException; - } - return new InternalServerErrorException(); - }; - return builder.UseStatusCodePages(app => - { - app.Use((HttpContext context, Func _) => throw statusCodeExceptionFactory(context)); - }); - } - } -} +using System; +using System.Threading.Tasks; +using Cuemon; +using Cuemon.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Codebelt.Extensions.Asp.Versioning; + +/// +/// Extension methods for the interface. +/// +public static class ApplicationBuilderExtensions +{ + /// + /// Adds a middleware to the pipeline that will intercept status codes written directly to the response and throw an appropriate that can then be translated and re-written in a consistent way. + /// + /// The type that provides the mechanisms to configure an application’s request pipeline. + /// The function delegate that will resolve and throw a proper from status codes written directly to the response. + /// A reference to this instance after the operation has completed. + /// + /// cannot be null. + /// + /// This method was introduced because of the design decisions made of the author of Asp.Versioning; for more information have a read at https://github.com/dotnet/aspnet-api-versioning/issues/886 + public static IApplicationBuilder UseRestfulApiVersioning(this IApplicationBuilder builder, Func statusCodeExceptionFactory = null) + { + Validator.ThrowIfNull(builder); + statusCodeExceptionFactory ??= context => + { + if (HttpStatusCodeException.TryParse(context.Response.StatusCode, out var statusCodeEquivalentException)) + { + return statusCodeEquivalentException; + } + return new InternalServerErrorException(); + }; + return builder.UseStatusCodePages(app => + { + app.Use((HttpContext context, Func _) => throw statusCodeExceptionFactory(context)); + }); + } +} diff --git a/src/Codebelt.Extensions.Asp.Versioning/Codebelt.Extensions.Asp.Versioning.csproj b/src/Codebelt.Extensions.Asp.Versioning/Codebelt.Extensions.Asp.Versioning.csproj index 2dfb343..d9251c8 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/Codebelt.Extensions.Asp.Versioning.csproj +++ b/src/Codebelt.Extensions.Asp.Versioning/Codebelt.Extensions.Asp.Versioning.csproj @@ -1,20 +1,20 @@ - - - - 17df67ca-a395-45e7-b339-68ea22aeb26b - - - - The Codebelt.Extensions.Asp.Versioning namespace contains types and extension methods that complements the Asp.Versioning namespace by adding new ways of working with versioning for Open API 3.x. - extension-methods extensions restful-api-versioning versioning - - - - - - - - - - - + + + + 17df67ca-a395-45e7-b339-68ea22aeb26b + + + + The Codebelt.Extensions.Asp.Versioning namespace contains types and extension methods that complements the Asp.Versioning namespace by adding new ways of working with versioning for Open API 3.x. + extension-methods extensions restful-api-versioning versioning + + + + + + + + + + + diff --git a/src/Codebelt.Extensions.Asp.Versioning/Properties/AssemblyInfo.cs b/src/Codebelt.Extensions.Asp.Versioning/Properties/AssemblyInfo.cs index 3176c8a..cc3aab0 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/Properties/AssemblyInfo.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/Properties/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Runtime.InteropServices; - -[assembly: ComVisible(false)] +using System.Runtime.InteropServices; + +[assembly: ComVisible(false)] [assembly: Guid("f4dec451-bdbb-469d-a94e-7c9a86aeece6")] \ No newline at end of file diff --git a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs index 72e51fe..064fe25 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs @@ -1,87 +1,86 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Asp.Versioning; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Net.Http.Headers; - -namespace Codebelt.Extensions.Asp.Versioning -{ - /// - /// Represents a RESTful API version reader that reads the value from a filtered list of headers in the request. - /// - /// This class was introduced to have an inclusive filter on what MIME types to consider valid when parsing HTTP Accept headers; for more information have a read at https://github.com/dotnet/aspnet-api-versioning/issues/887 - public class RestfulApiVersionReader : MediaTypeApiVersionReader - { - /// - /// Initializes a new instance of the class. - /// - /// The valid accept headers to filter the raw collection of headers. - /// The name of the version parameter. - public RestfulApiVersionReader(IEnumerable validAcceptHeaders, string parameterName) : base(parameterName) - { - ValidAcceptHeaders = new List(validAcceptHeaders ?? Enumerable.Empty()); - } - - /// - /// Gets the valid accept headers that will filter by. - /// - /// The valid accept headers that will filter by. - public IList ValidAcceptHeaders { get; } - - internal bool PreviousBehavior { get; set; } - - /// - /// Reads the requested API version from the HTTP request. - /// - /// The HTTP request to read from. - /// The requested API versions. - public override IReadOnlyList Read(HttpRequest request) - { - var versions = base.Read(request); - if (PreviousBehavior || versions.Count <= 1) - { - return versions; - } - - var parser = request.HttpContext.RequestServices.GetService(); - if (parser == null) - { - return versions; - } - - var normalizedVersions = new List(versions.Count); - var seenVersions = new HashSet(StringComparer.Ordinal); - foreach (var version in versions) - { - if (!parser.TryParse(version, out var apiVersion) || apiVersion == null) - { - return versions; - } - - var normalizedVersion = apiVersion.ToString(); - if (seenVersions.Add(normalizedVersion)) - { - normalizedVersions.Add(normalizedVersion); - } - } - - return normalizedVersions.Count == versions.Count ? versions : normalizedVersions; - } - - /// - /// Reads the requested API version from the HTTP Accept header. - /// - /// The of Accept headers to read from. - /// The API version read or null. - /// This implementation will, when has values, filter to only include valid headers. - protected override string ReadAcceptHeader(ICollection accept) - { - var filteredAcceptHeaders = new List(ValidAcceptHeaders != null && ValidAcceptHeaders.Count > 0 - ? accept.Where(ah => ValidAcceptHeaders.Any(vah => ah.MediaType.Value != null && ah.MediaType.Value.StartsWith(vah, StringComparison.OrdinalIgnoreCase))) - : accept); - return base.ReadAcceptHeader(filteredAcceptHeaders); - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; + +namespace Codebelt.Extensions.Asp.Versioning; + +/// +/// Represents a RESTful API version reader that reads the value from a filtered list of headers in the request. +/// +/// This class was introduced to have an inclusive filter on what MIME types to consider valid when parsing HTTP Accept headers; for more information have a read at https://github.com/dotnet/aspnet-api-versioning/issues/887 +public class RestfulApiVersionReader : MediaTypeApiVersionReader +{ + /// + /// Initializes a new instance of the class. + /// + /// The valid accept headers to filter the raw collection of headers. + /// The name of the version parameter. + public RestfulApiVersionReader(IEnumerable validAcceptHeaders, string parameterName) : base(parameterName) + { + ValidAcceptHeaders = new List(validAcceptHeaders ?? Enumerable.Empty()); + } + + /// + /// Gets the valid accept headers that will filter by. + /// + /// The valid accept headers that will filter by. + public IList ValidAcceptHeaders { get; } + + internal bool PreviousBehavior { get; set; } + + /// + /// Reads the requested API version from the HTTP request. + /// + /// The HTTP request to read from. + /// The requested API versions. + public override IReadOnlyList Read(HttpRequest request) + { + var versions = base.Read(request); + if (PreviousBehavior || versions.Count <= 1) + { + return versions; + } + + var parser = request.HttpContext.RequestServices.GetService(); + if (parser == null) + { + return versions; + } + + var normalizedVersions = new List(versions.Count); + var seenVersions = new HashSet(StringComparer.Ordinal); + foreach (var version in versions) + { + if (!parser.TryParse(version, out var apiVersion) || apiVersion == null) + { + return versions; + } + + var normalizedVersion = apiVersion.ToString(); + if (seenVersions.Add(normalizedVersion)) + { + normalizedVersions.Add(normalizedVersion); + } + } + + return normalizedVersions.Count == versions.Count ? versions : normalizedVersions; + } + + /// + /// Reads the requested API version from the HTTP Accept header. + /// + /// The of Accept headers to read from. + /// The API version read or null. + /// This implementation will, when has values, filter to only include valid headers. + protected override string ReadAcceptHeader(ICollection accept) + { + var filteredAcceptHeaders = new List(ValidAcceptHeaders != null && ValidAcceptHeaders.Count > 0 + ? accept.Where(ah => ValidAcceptHeaders.Any(vah => ah.MediaType.Value != null && ah.MediaType.Value.StartsWith(vah, StringComparison.OrdinalIgnoreCase))) + : accept); + return base.ReadAcceptHeader(filteredAcceptHeaders); + } +} diff --git a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs index 15d2533..104a4c8 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs @@ -1,155 +1,154 @@ -using System; -using System.Collections.Generic; -using Asp.Versioning; -using Asp.Versioning.Conventions; -using Cuemon; -using Cuemon.Configuration; - -namespace Codebelt.Extensions.Asp.Versioning -{ - /// - /// Provides programmatic configuration for the method. - /// - /// - public class RestfulApiVersioningOptions : IValidatableParameterObject - { - /// - /// Initializes a new instance of the class. - /// - /// - /// The following table shows the initial property values for an instance of . - /// - /// - /// Property - /// Initial Value - /// - /// - /// - /// typeof(CurrentImplementationApiVersionSelector) - /// - /// - /// - /// new ApiVersionConventionBuilder() - /// - /// - /// - /// ApiVersion.Default - /// - /// - /// - /// v - /// - /// - /// - /// false - /// - /// - /// - /// false - /// - /// - /// - /// application/json, application/xml, application/yaml, application/vnd, text/json, text/xml, text/plain, text/yaml, */* - /// - /// - /// - /// null - /// - /// - /// - public RestfulApiVersioningOptions() - { - ApiVersionSelectorType = typeof(CurrentImplementationApiVersionSelector); - Conventions = new ApiVersionConventionBuilder(); - DefaultApiVersion = ApiVersion.Default; - ParameterName = "v"; - ValidAcceptHeaders = new List() - { - "application/json", - "application/xml", - "application/yaml", - "application/vnd", - "text/json", - "text/xml", - "text/plain", - "text/yaml", - "*/*" - }; - } - - /// - /// Gets or sets a value indicating whether responses will be based on the built in support for RFC 7807. - /// - /// true if the responses will be based on the built in support for RFC 7807; otherwise, false. - public bool UseBuiltInRfc7807 { get; set; } - - /// - /// Gets or sets the valid accept headers used as a filter by . - /// - /// The valid accept headers used as a filter by . - /// This option was introduced to have an inclusive filter on what MIME types to consider valid when parsing HTTP Accept headers; for more information have a read at https://github.com/dotnet/aspnet-api-versioning/issues/887 - public IList ValidAcceptHeaders { get; set; } - - /// - /// Specify what to set on the . Default is . - /// - /// The type that implements the interface. - /// A reference to this instance so that additional calls can be chained. - public RestfulApiVersioningOptions UseApiVersionSelector() where T : class, IApiVersionSelector - { - ApiVersionSelectorType = typeof(T); - return this; - } - - /// - /// Gets or sets the API version reader used to read the API version from the request. - /// - /// The API version reader used to read the API version from the request. - public IApiVersionReader ApiVersionReader { get; set; } - - /// - /// Gets the concrete implementation type of a type that implements the interface. - /// - /// The concrete implementation type of a type that implements the interface. - /// Default is . This can be changed by calling the . - public Type ApiVersionSelectorType { get; private set; } - - /// - /// Gets or sets the builder used to define API version conventions. - /// - /// The builder used to define API version conventions. - public IApiVersionConventionBuilder Conventions { get; set; } - - /// - /// Gets or sets the default API version applied to services that do not have explicit versions. - /// - /// The default API version applied to services that do not have explicit versions. - public ApiVersion DefaultApiVersion { get; set; } - - /// - /// Gets or sets the name of the media type parameter to read the service API version from. - /// - /// The name of the media type parameter to read the service API version from. - public string ParameterName { get; set; } - - /// - /// Gets or sets a value indicating whether requests report the API version compatibility information in responses. - /// - /// true if the responses contain API version compatibility information; otherwise, false. - public bool ReportApiVersions { get; set; } - - /// - /// Determines whether the public read-write properties of this instance are in a valid state. - /// - /// - /// cannot be null, empty or consist only of white-space characters - or - - /// cannot be null. - /// - /// This method is expected to throw exceptions when one or more conditions fails to be in a valid state. - public void ValidateOptions() - { - Validator.ThrowIfInvalidState(Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)); - Validator.ThrowIfInvalidState(ValidAcceptHeaders == null); - } - } -} +using System; +using System.Collections.Generic; +using Asp.Versioning; +using Asp.Versioning.Conventions; +using Cuemon; +using Cuemon.Configuration; + +namespace Codebelt.Extensions.Asp.Versioning; + +/// +/// Provides programmatic configuration for the method. +/// +/// +public class RestfulApiVersioningOptions : IValidatableParameterObject +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The following table shows the initial property values for an instance of . + /// + /// + /// Property + /// Initial Value + /// + /// + /// + /// typeof(CurrentImplementationApiVersionSelector) + /// + /// + /// + /// new ApiVersionConventionBuilder() + /// + /// + /// + /// ApiVersion.Default + /// + /// + /// + /// v + /// + /// + /// + /// false + /// + /// + /// + /// false + /// + /// + /// + /// application/json, application/xml, application/yaml, application/vnd, text/json, text/xml, text/plain, text/yaml, */* + /// + /// + /// + /// null + /// + /// + /// + public RestfulApiVersioningOptions() + { + ApiVersionSelectorType = typeof(CurrentImplementationApiVersionSelector); + Conventions = new ApiVersionConventionBuilder(); + DefaultApiVersion = ApiVersion.Default; + ParameterName = "v"; + ValidAcceptHeaders = new List() + { + "application/json", + "application/xml", + "application/yaml", + "application/vnd", + "text/json", + "text/xml", + "text/plain", + "text/yaml", + "*/*" + }; + } + + /// + /// Gets or sets a value indicating whether responses will be based on the built in support for RFC 7807. + /// + /// true if the responses will be based on the built in support for RFC 7807; otherwise, false. + public bool UseBuiltInRfc7807 { get; set; } + + /// + /// Gets or sets the valid accept headers used as a filter by . + /// + /// The valid accept headers used as a filter by . + /// This option was introduced to have an inclusive filter on what MIME types to consider valid when parsing HTTP Accept headers; for more information have a read at https://github.com/dotnet/aspnet-api-versioning/issues/887 + public IList ValidAcceptHeaders { get; set; } + + /// + /// Specify what to set on the . Default is . + /// + /// The type that implements the interface. + /// A reference to this instance so that additional calls can be chained. + public RestfulApiVersioningOptions UseApiVersionSelector() where T : class, IApiVersionSelector + { + ApiVersionSelectorType = typeof(T); + return this; + } + + /// + /// Gets or sets the API version reader used to read the API version from the request. + /// + /// The API version reader used to read the API version from the request. + public IApiVersionReader ApiVersionReader { get; set; } + + /// + /// Gets the concrete implementation type of a type that implements the interface. + /// + /// The concrete implementation type of a type that implements the interface. + /// Default is . This can be changed by calling the . + public Type ApiVersionSelectorType { get; private set; } + + /// + /// Gets or sets the builder used to define API version conventions. + /// + /// The builder used to define API version conventions. + public IApiVersionConventionBuilder Conventions { get; set; } + + /// + /// Gets or sets the default API version applied to services that do not have explicit versions. + /// + /// The default API version applied to services that do not have explicit versions. + public ApiVersion DefaultApiVersion { get; set; } + + /// + /// Gets or sets the name of the media type parameter to read the service API version from. + /// + /// The name of the media type parameter to read the service API version from. + public string ParameterName { get; set; } + + /// + /// Gets or sets a value indicating whether requests report the API version compatibility information in responses. + /// + /// true if the responses contain API version compatibility information; otherwise, false. + public bool ReportApiVersions { get; set; } + + /// + /// Determines whether the public read-write properties of this instance are in a valid state. + /// + /// + /// cannot be null, empty or consist only of white-space characters - or - + /// cannot be null. + /// + /// This method is expected to throw exceptions when one or more conditions fails to be in a valid state. + public void ValidateOptions() + { + Validator.ThrowIfInvalidState(Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)); + Validator.ThrowIfInvalidState(ValidAcceptHeaders == null); + } +} diff --git a/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs b/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs index d8bc6fa..37e7178 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs @@ -1,99 +1,98 @@ -using System; +using System; using Asp.Versioning; using Cuemon; using Microsoft.Extensions.DependencyInjection; using Cuemon.AspNetCore.Http; -namespace Codebelt.Extensions.Asp.Versioning +namespace Codebelt.Extensions.Asp.Versioning; + +/// +/// Extension methods for the interface. +/// +public static class ServiceCollectionExtensions { /// - /// Extension methods for the interface. + /// Adds a compound service API versioning to the specified collection that is optimized for RESTful APIs. /// - public static class ServiceCollectionExtensions + /// The to extend. + /// The that may be configured. + /// A reference to so that additional calls can be chained. + /// + /// This is a convenient method to add API versioning to your ASP.NET Core WebApi. Call AddApiVersioning, AddMvc and AddApiExplorer. Configuration, which is optimized for RESTful APIs, are done through . + /// When is a , semantic aliases are automatically registered only for that default version. Applications that expose additional semantic versions and need aliases such as 2 or 2.0 should call with . + /// + public static IServiceCollection AddRestfulApiVersioning(this IServiceCollection services, Action setup = null) { - /// - /// Adds a compound service API versioning to the specified collection that is optimized for RESTful APIs. - /// - /// The to extend. - /// The that may be configured. - /// A reference to so that additional calls can be chained. - /// - /// This is a convenient method to add API versioning to your ASP.NET Core WebApi. Call AddApiVersioning, AddMvc and AddApiExplorer. Configuration, which is optimized for RESTful APIs, are done through . - /// When is a , semantic aliases are automatically registered only for that default version. Applications that expose additional semantic versions and need aliases such as 2 or 2.0 should call with . - /// - public static IServiceCollection AddRestfulApiVersioning(this IServiceCollection services, Action setup = null) - { - Validator.ThrowIfNull(services); - Validator.ThrowIfInvalidConfigurator(setup, out var options); + Validator.ThrowIfNull(services); + Validator.ThrowIfInvalidConfigurator(setup, out var options); - var defaultSemanticApiVersion = options.DefaultApiVersion as SemanticApiVersion; - var preferSemVerBehavior = defaultSemanticApiVersion != null; + var defaultSemanticApiVersion = options.DefaultApiVersion as SemanticApiVersion; + var preferSemVerBehavior = defaultSemanticApiVersion != null; - services.AddApiVersioning(o => - { - o.DefaultApiVersion = options.DefaultApiVersion; - o.ReportApiVersions = options.ReportApiVersions; - o.AssumeDefaultVersionWhenUnspecified = true; - o.ApiVersionReader = options.ApiVersionReader ?? new RestfulApiVersionReader(options.ValidAcceptHeaders, options.ParameterName) - { - PreviousBehavior = !preferSemVerBehavior - }; - o.ApiVersionSelector = (Activator.CreateInstance(options.ApiVersionSelectorType, o) as IApiVersionSelector)!; - }).AddMvc(o => - { - o.Conventions = options.Conventions; - }).AddApiExplorer(o => + services.AddApiVersioning(o => + { + o.DefaultApiVersion = options.DefaultApiVersion; + o.ReportApiVersions = options.ReportApiVersions; + o.AssumeDefaultVersionWhenUnspecified = true; + o.ApiVersionReader = options.ApiVersionReader ?? new RestfulApiVersionReader(options.ValidAcceptHeaders, options.ParameterName) { - o.GroupNameFormat = $"'{options.ParameterName}'VVV"; - o.DefaultApiVersion = options.DefaultApiVersion; - o.SubstituteApiVersionInUrl = true; - }); + PreviousBehavior = !preferSemVerBehavior + }; + o.ApiVersionSelector = (Activator.CreateInstance(options.ApiVersionSelectorType, o) as IApiVersionSelector)!; + }).AddMvc(o => + { + o.Conventions = options.Conventions; + }).AddApiExplorer(o => + { + o.GroupNameFormat = $"'{options.ParameterName}'VVV"; + o.DefaultApiVersion = options.DefaultApiVersion; + o.SubstituteApiVersionInUrl = true; + }); - if (options.UseBuiltInRfc7807) - { - services.AddProblemDetails(); - } - else + if (options.UseBuiltInRfc7807) + { + services.AddProblemDetails(); + } + else + { + services.AddProblemDetails(o => o.CustomizeProblemDetails = context => { - services.AddProblemDetails(o => o.CustomizeProblemDetails = context => + var pd = context.ProblemDetails; + if (HttpStatusCodeException.TryParse(pd.Status ?? 500, pd.Detail ?? pd.Title, null, out var statusCodeEquivalentException)) { - var pd = context.ProblemDetails; - if (HttpStatusCodeException.TryParse(pd.Status ?? 500, pd.Detail ?? pd.Title, null, out var statusCodeEquivalentException)) - { - throw statusCodeEquivalentException; - } - - throw new InternalServerErrorException(pd.Detail); - }); - } - - if (defaultSemanticApiVersion != null) - { - services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias(defaultSemanticApiVersion)); - } + throw statusCodeEquivalentException; + } - return services; + throw new InternalServerErrorException(pd.Detail); + }); } - /// - /// Adds the specified API version parser as the singleton parser used by API versioning services. - /// - /// The concrete type to register. - /// The to extend. - /// The parser instance that should resolve API version values for the application. - /// A reference to so that additional calls can be chained. - /// - /// Register a custom parser when the application accepts version formats that differ from the default parser, such as semantic version aliases or compatibility tokens. - /// The supplied is registered as the singleton. - /// - /// - /// or is null. - /// - public static IServiceCollection AddApiVersionParser(this IServiceCollection services, T parser) where T : IApiVersionParser + if (defaultSemanticApiVersion != null) { - Validator.ThrowIfNull(services); - Validator.ThrowIfNull(parser); - return services.AddSingleton(parser); + services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias(defaultSemanticApiVersion)); } + + return services; + } + + /// + /// Adds the specified API version parser as the singleton parser used by API versioning services. + /// + /// The concrete type to register. + /// The to extend. + /// The parser instance that should resolve API version values for the application. + /// A reference to so that additional calls can be chained. + /// + /// Register a custom parser when the application accepts version formats that differ from the default parser, such as semantic version aliases or compatibility tokens. + /// The supplied is registered as the singleton. + /// + /// + /// or is null. + /// + public static IServiceCollection AddApiVersionParser(this IServiceCollection services, T parser) where T : IApiVersionParser + { + Validator.ThrowIfNull(services); + Validator.ThrowIfNull(parser); + return services.AddSingleton(parser); } } diff --git a/src/Codebelt.Extensions.Asp.Versioning/VersionConversionOptions.cs b/src/Codebelt.Extensions.Asp.Versioning/VersionConversionOptions.cs index 87c5059..4368d4c 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/VersionConversionOptions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/VersionConversionOptions.cs @@ -2,62 +2,62 @@ using Cuemon; using Cuemon.Configuration; -namespace Codebelt.Extensions.Asp.Versioning; - -/// -/// Provides programmatic configuration for version conversion operations. -/// -/// -/// This class is used to configure how .NET objects are converted to semantic API versions. -/// -/// -public class VersionConversionOptions : IValidatableParameterObject -{ - /// - /// Initializes a new instance of the class. - /// - /// - /// The following table shows the initial property values for an instance of . - /// - /// - /// Property - /// Initial Value - /// - /// - /// - /// false - /// - /// - /// - /// revision - /// - /// - /// - public VersionConversionOptions() - { - RevisionIdentifier = nameof(Version.Revision).ToLowerInvariant(); - } - - /// - /// Gets or sets a value indicating whether the component should be included in the converted semantic version. - /// - /// true if the revision component should be included; otherwise, false. - public bool IncludeRevision { get; set; } - - /// - /// Gets or sets the identifier to use for the revision component when included in the semantic version build metadata. - /// - /// The identifier used for the revision build metadata. The default value is revision. - public string RevisionIdentifier { get; set; } - - /// - /// Validates the current state of the options. - /// - /// Thrown when is true and is null, empty, or whitespace. - /// Thrown when is not a valid semantic version build metadata identifier. - public void ValidateOptions() - { - Validator.ThrowIfInvalidState(IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)); - Validator.ThrowIfInvalidState(IncludeRevision && !SemanticApiVersionParser.IsValidBuildMetadata(RevisionIdentifier.AsSpan())); - } +namespace Codebelt.Extensions.Asp.Versioning; + +/// +/// Provides programmatic configuration for version conversion operations. +/// +/// +/// This class is used to configure how .NET objects are converted to semantic API versions. +/// +/// +public class VersionConversionOptions : IValidatableParameterObject +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The following table shows the initial property values for an instance of . + /// + /// + /// Property + /// Initial Value + /// + /// + /// + /// false + /// + /// + /// + /// revision + /// + /// + /// + public VersionConversionOptions() + { + RevisionIdentifier = nameof(Version.Revision).ToLowerInvariant(); + } + + /// + /// Gets or sets a value indicating whether the component should be included in the converted semantic version. + /// + /// true if the revision component should be included; otherwise, false. + public bool IncludeRevision { get; set; } + + /// + /// Gets or sets the identifier to use for the revision component when included in the semantic version build metadata. + /// + /// The identifier used for the revision build metadata. The default value is revision. + public string RevisionIdentifier { get; set; } + + /// + /// Validates the current state of the options. + /// + /// Thrown when is true and is null, empty, or whitespace. + /// Thrown when is not a valid semantic version build metadata identifier. + public void ValidateOptions() + { + Validator.ThrowIfInvalidState(IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)); + Validator.ThrowIfInvalidState(IncludeRevision && !SemanticApiVersionParser.IsValidBuildMetadata(RevisionIdentifier.AsSpan())); + } } diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj index fe3b47e..a76066e 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj @@ -1,12 +1,12 @@ - - - - Codebelt.Extensions.Asp.Versioning - enable - - - - - - - + + + + Codebelt.Extensions.Asp.Versioning + enable + + + + + + + diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs index 3c8236a..53f986c 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs @@ -1,152 +1,152 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Threading.Tasks; -using Asp.Versioning; -using Codebelt.Extensions.Xunit.Hosting.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Xunit; - -namespace Codebelt.Extensions.Asp.Versioning; - -public class SemanticApiVersionCompatibilityTest : MinimalWebHostTest -{ - private const string RequestedVersionHeaderName = "X-Requested-Version"; - private static readonly ApiVersion DefaultVersion = new(1, 0); - private static readonly ApiVersion AlternateVersion = new(2, 0); - - public SemanticApiVersionCompatibilityTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionCompatibilityTest)) - { - } - - public static TheoryData SupportedVersionFormats => new() - { - { null, AlternateVersion }, - { "1", DefaultVersion }, - { "1.0", DefaultVersion }, - { "1.0.0", DefaultVersion }, - { "2", AlternateVersion }, - { "2.0", AlternateVersion }, - { "2.0.0", AlternateVersion } - }; - - protected override void ConfigureHost(IHostApplicationBuilder hb) - { - var services = hb.Services; - services.AddRouting(); - services.AddRestfulApiVersioning(options => - { - options.DefaultApiVersion = DefaultVersion; - options.UseBuiltInRfc7807 = true; - }); - services.AddSingleton(new ApiVersionAliasParser(new Dictionary(StringComparer.Ordinal) - { - ["1"] = DefaultVersion, - ["1.0"] = DefaultVersion, - ["1.0.0"] = DefaultVersion, - ["2"] = AlternateVersion, - ["2.0"] = AlternateVersion, - ["2.0.0"] = AlternateVersion - }, ApiVersionParser.Default)); - } - - public override void ConfigureApplication(IApplicationBuilder app) - { - app.UseRouting(); - app.UseEndpoints(endpoints => - { - var versionSet = endpoints.NewApiVersionSet() - .HasApiVersion(DefaultVersion) - .HasApiVersion(AlternateVersion) - .Build(); - - endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, DefaultVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(DefaultVersion); - - endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, AlternateVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(AlternateVersion); - }); - } - - [Theory] - [MemberData(nameof(SupportedVersionFormats))] - public async Task PostRequest_ShouldRouteWhenVersionIsMissingOrUsesShortSemanticFormat(string? version, ApiVersion expectedVersion) - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(version)); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine(version is null ? "v=" : $"v={version}"); - TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); - if (!string.IsNullOrWhiteSpace(payload)) - { - TestOutput.WriteLine(payload); - } - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(expectedVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); - } - - [Fact] - public async Task PostRequest_ShouldFailWhenPlainApiVersionUsesDifferentAliasTextAcrossAcceptAndContentType() - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest("1", "1.0.0")); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine("Accept v=1; Content-Type v=1.0.0"); - TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); - if (!string.IsNullOrWhiteSpace(payload)) - { - TestOutput.WriteLine(payload); - } - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); - } - - private static IResult WriteResponse(HttpContext context, ApiVersion version) - { - context.Response.Headers[RequestedVersionHeaderName] = version.ToString(); - return Results.NoContent(); - } - - private static HttpRequestMessage CreateRequest(string? version) - { - return CreateRequest(version, version); - } - - private static HttpRequestMessage CreateRequest(string? acceptVersion, string? contentTypeVersion) - { - var request = new HttpRequestMessage(HttpMethod.Post, "/requests") - { - Content = new StringContent("""{"payload":"request"}""", Encoding.UTF8, "application/json") - }; - - request.Headers.Add("Accept", acceptVersion is null ? "application/json" : $"application/json;v={acceptVersion}"); - if (contentTypeVersion is not null) - { - request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", contentTypeVersion)); - } - - return request; - } - - private static string GetHeader(HttpResponseHeaders headers, string headerName) - { - return headers.TryGetValues(headerName, out IEnumerable? values) - ? Assert.Single(values) - : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); - } -} +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Asp.Versioning; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class SemanticApiVersionCompatibilityTest : MinimalWebHostTest +{ + private const string RequestedVersionHeaderName = "X-Requested-Version"; + private static readonly ApiVersion DefaultVersion = new(1, 0); + private static readonly ApiVersion AlternateVersion = new(2, 0); + + public SemanticApiVersionCompatibilityTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionCompatibilityTest)) + { + } + + public static TheoryData SupportedVersionFormats => new() + { + { null, AlternateVersion }, + { "1", DefaultVersion }, + { "1.0", DefaultVersion }, + { "1.0.0", DefaultVersion }, + { "2", AlternateVersion }, + { "2.0", AlternateVersion }, + { "2.0.0", AlternateVersion } + }; + + protected override void ConfigureHost(IHostApplicationBuilder hb) + { + var services = hb.Services; + services.AddRouting(); + services.AddRestfulApiVersioning(options => + { + options.DefaultApiVersion = DefaultVersion; + options.UseBuiltInRfc7807 = true; + }); + services.AddSingleton(new ApiVersionAliasParser(new Dictionary(StringComparer.Ordinal) + { + ["1"] = DefaultVersion, + ["1.0"] = DefaultVersion, + ["1.0.0"] = DefaultVersion, + ["2"] = AlternateVersion, + ["2.0"] = AlternateVersion, + ["2.0.0"] = AlternateVersion + }, ApiVersionParser.Default)); + } + + public override void ConfigureApplication(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + var versionSet = endpoints.NewApiVersionSet() + .HasApiVersion(DefaultVersion) + .HasApiVersion(AlternateVersion) + .Build(); + + endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, DefaultVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(DefaultVersion); + + endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, AlternateVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(AlternateVersion); + }); + } + + [Theory] + [MemberData(nameof(SupportedVersionFormats))] + public async Task PostRequest_ShouldRouteWhenVersionIsMissingOrUsesShortSemanticFormat(string? version, ApiVersion expectedVersion) + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(version)); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(version is null ? "v=" : $"v={version}"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(expectedVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); + } + + [Fact] + public async Task PostRequest_ShouldFailWhenPlainApiVersionUsesDifferentAliasTextAcrossAcceptAndContentType() + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest("1", "1.0.0")); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine("Accept v=1; Content-Type v=1.0.0"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); + } + + private static IResult WriteResponse(HttpContext context, ApiVersion version) + { + context.Response.Headers[RequestedVersionHeaderName] = version.ToString(); + return Results.NoContent(); + } + + private static HttpRequestMessage CreateRequest(string? version) + { + return CreateRequest(version, version); + } + + private static HttpRequestMessage CreateRequest(string? acceptVersion, string? contentTypeVersion) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/requests") + { + Content = new StringContent("""{"payload":"request"}""", Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Accept", acceptVersion is null ? "application/json" : $"application/json;v={acceptVersion}"); + if (contentTypeVersion is not null) + { + request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", contentTypeVersion)); + } + + return request; + } + + private static string GetHeader(HttpResponseHeaders headers, string headerName) + { + return headers.TryGetValues(headerName, out IEnumerable? values) + ? Assert.Single(values) + : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs index 728c63f..e5007ad 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs @@ -1,192 +1,192 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Threading.Tasks; -using Asp.Versioning; -using Codebelt.Extensions.Xunit.Hosting.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Xunit; - -namespace Codebelt.Extensions.Asp.Versioning; - -public class SemanticApiVersionDefaultCompatibilityTest : MinimalWebHostTest -{ - private const string RequestedVersionHeaderName = "X-Requested-Version"; - private static readonly SemanticApiVersion DefaultVersion = new(1, 0, 0); - private static readonly SemanticApiVersion AlternateVersion = new(2, 0, 0); - +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Asp.Versioning; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class SemanticApiVersionDefaultCompatibilityTest : MinimalWebHostTest +{ + private const string RequestedVersionHeaderName = "X-Requested-Version"; + private static readonly SemanticApiVersion DefaultVersion = new(1, 0, 0); + private static readonly SemanticApiVersion AlternateVersion = new(2, 0, 0); + public SemanticApiVersionDefaultCompatibilityTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionDefaultCompatibilityTest)) - { - } - - public static TheoryData SupportedVersionFormats => new() - { - { null, AlternateVersion }, - { "1", DefaultVersion }, - { "1.0", DefaultVersion }, - { "1.0.0", DefaultVersion }, - { "2", AlternateVersion }, - { "2.0", AlternateVersion }, - { "2.0.0", AlternateVersion } - }; - - protected override void ConfigureHost(IHostApplicationBuilder hb) - { - var services = hb.Services; - services.AddRouting(); - services.AddRestfulApiVersioning(options => - { - options.DefaultApiVersion = DefaultVersion; - options.UseBuiltInRfc7807 = true; - }); - - services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias([DefaultVersion, AlternateVersion])); - } - - public override void ConfigureApplication(IApplicationBuilder app) - { - app.UseRouting(); - app.UseEndpoints(endpoints => - { - var versionSet = endpoints.NewApiVersionSet() - .HasApiVersion(DefaultVersion) - .HasApiVersion(AlternateVersion) - .Build(); - - endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, DefaultVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(DefaultVersion); - - endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, AlternateVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(AlternateVersion); - }); - } - - [Theory] - [MemberData(nameof(SupportedVersionFormats))] - public async Task PostRequest_ShouldRouteWhenVersionIsMissingOrUsesShortSemanticFormat(string? version, ApiVersion expectedVersion) - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(version)); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine(version is null ? "v=" : $"v={version}"); - TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); - if (!string.IsNullOrWhiteSpace(payload)) - { - TestOutput.WriteLine(payload); - } - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(expectedVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); - } - - [Theory] - [InlineData("1", "1")] - [InlineData("1", "1.0")] - [InlineData("1", "1.0.0")] - [InlineData("1.0", "1")] - [InlineData("1.0", "1.0")] - [InlineData("1.0", "1.0.0")] - [InlineData("1.0.0", "1")] - [InlineData("1.0.0", "1.0")] - [InlineData("1.0.0", "1.0.0")] - public async Task PostRequest_ShouldRouteWhenAcceptVersionAliasAndContentTypeVersionAreEquivalent(string acceptVersion, string contentTypeVersion) - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(acceptVersion, contentTypeVersion)); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine($"Accept v={acceptVersion}; Content-Type v={contentTypeVersion}"); - TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); - if (!string.IsNullOrWhiteSpace(payload)) - { - TestOutput.WriteLine(payload); - } - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); - } - - [Fact] - public async Task PostRequest_ShouldFailWhenAcceptVersionAndContentTypeVersionAreDifferent() - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest("1", "2.0.0")); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine("Accept v=1; Content-Type v=2.0.0"); - TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); - if (!string.IsNullOrWhiteSpace(payload)) - { - TestOutput.WriteLine(payload); - } - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); - } - - [Fact] - public async Task PostRequest_ShouldFailWhenRequestedVersionIsNotARegisteredSemanticAlias() - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest("1.1")); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine("Accept v=1.1; Content-Type v=1.1"); - TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); - if (!string.IsNullOrWhiteSpace(payload)) - { - TestOutput.WriteLine(payload); - } - - Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); - Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); - } - - private static IResult WriteResponse(HttpContext context, ApiVersion version) - { - context.Response.Headers[RequestedVersionHeaderName] = version.ToString(); - return Results.NoContent(); - } - - private static HttpRequestMessage CreateRequest(string? version) - { - return CreateRequest(version, version); - } - - private static HttpRequestMessage CreateRequest(string? acceptVersion, string? contentTypeVersion) - { - var request = new HttpRequestMessage(HttpMethod.Post, "/requests") - { - Content = new StringContent("""{"payload":"request"}""", Encoding.UTF8, "application/json") - }; - - request.Headers.Add("Accept", acceptVersion is null ? "application/json" : $"application/json;v={acceptVersion}"); - if (contentTypeVersion is not null) - { - request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", contentTypeVersion)); - } - - return request; - } - - private static string GetHeader(HttpResponseHeaders headers, string headerName) - { - return headers.TryGetValues(headerName, out IEnumerable? values) - ? Assert.Single(values) - : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); - } -} + { + } + + public static TheoryData SupportedVersionFormats => new() + { + { null, AlternateVersion }, + { "1", DefaultVersion }, + { "1.0", DefaultVersion }, + { "1.0.0", DefaultVersion }, + { "2", AlternateVersion }, + { "2.0", AlternateVersion }, + { "2.0.0", AlternateVersion } + }; + + protected override void ConfigureHost(IHostApplicationBuilder hb) + { + var services = hb.Services; + services.AddRouting(); + services.AddRestfulApiVersioning(options => + { + options.DefaultApiVersion = DefaultVersion; + options.UseBuiltInRfc7807 = true; + }); + + services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias([DefaultVersion, AlternateVersion])); + } + + public override void ConfigureApplication(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + var versionSet = endpoints.NewApiVersionSet() + .HasApiVersion(DefaultVersion) + .HasApiVersion(AlternateVersion) + .Build(); + + endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, DefaultVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(DefaultVersion); + + endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, AlternateVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(AlternateVersion); + }); + } + + [Theory] + [MemberData(nameof(SupportedVersionFormats))] + public async Task PostRequest_ShouldRouteWhenVersionIsMissingOrUsesShortSemanticFormat(string? version, ApiVersion expectedVersion) + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(version)); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(version is null ? "v=" : $"v={version}"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(expectedVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); + } + + [Theory] + [InlineData("1", "1")] + [InlineData("1", "1.0")] + [InlineData("1", "1.0.0")] + [InlineData("1.0", "1")] + [InlineData("1.0", "1.0")] + [InlineData("1.0", "1.0.0")] + [InlineData("1.0.0", "1")] + [InlineData("1.0.0", "1.0")] + [InlineData("1.0.0", "1.0.0")] + public async Task PostRequest_ShouldRouteWhenAcceptVersionAliasAndContentTypeVersionAreEquivalent(string acceptVersion, string contentTypeVersion) + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(acceptVersion, contentTypeVersion)); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine($"Accept v={acceptVersion}; Content-Type v={contentTypeVersion}"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); + } + + [Fact] + public async Task PostRequest_ShouldFailWhenAcceptVersionAndContentTypeVersionAreDifferent() + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest("1", "2.0.0")); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine("Accept v=1; Content-Type v=2.0.0"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); + } + + [Fact] + public async Task PostRequest_ShouldFailWhenRequestedVersionIsNotARegisteredSemanticAlias() + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest("1.1")); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine("Accept v=1.1; Content-Type v=1.1"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); + } + + private static IResult WriteResponse(HttpContext context, ApiVersion version) + { + context.Response.Headers[RequestedVersionHeaderName] = version.ToString(); + return Results.NoContent(); + } + + private static HttpRequestMessage CreateRequest(string? version) + { + return CreateRequest(version, version); + } + + private static HttpRequestMessage CreateRequest(string? acceptVersion, string? contentTypeVersion) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/requests") + { + Content = new StringContent("""{"payload":"request"}""", Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Accept", acceptVersion is null ? "application/json" : $"application/json;v={acceptVersion}"); + if (contentTypeVersion is not null) + { + request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", contentTypeVersion)); + } + + return request; + } + + private static string GetHeader(HttpResponseHeaders headers, string headerName) + { + return headers.TryGetValues(headerName, out IEnumerable? values) + ? Assert.Single(values) + : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProductionTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProductionTest.cs index db3b07b..af580c1 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProductionTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProductionTest.cs @@ -1,185 +1,185 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Threading.Tasks; -using Asp.Versioning; -using Codebelt.Extensions.Xunit.Hosting.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Xunit; - -namespace Codebelt.Extensions.Asp.Versioning; - -public class SemanticApiVersionNonProductionTest : MinimalWebHostTest -{ - private const string SemanticVersionHeaderName = "X-Semantic-Version"; - private const string HttpMethodHeaderName = "X-Http-Method"; - private static readonly SemanticApiVersion DefaultVersion = new(1, 2, 3, "alpha", "build.5"); - private static readonly SemanticApiVersion AlternateVersion = new(1, 2, 3, buildMetadata: "build.7"); - private static readonly string[] RoutedMethods = - [ - HttpMethods.Get, - HttpMethods.Head, - HttpMethods.Options, - HttpMethods.Post, - HttpMethods.Put, - HttpMethods.Patch, - HttpMethods.Delete - ]; - - public SemanticApiVersionNonProductionTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionNonProductionTest)) - { - } - - public static TheoryData VersionedHttpMethods => new() - { - { HttpMethods.Get, HttpStatusCode.NotAcceptable, false }, - { HttpMethods.Head, HttpStatusCode.NotAcceptable, false }, - { HttpMethods.Options, HttpStatusCode.NotAcceptable, false }, - { HttpMethods.Post, HttpStatusCode.UnsupportedMediaType, true }, - { HttpMethods.Put, HttpStatusCode.UnsupportedMediaType, true }, - { HttpMethods.Patch, HttpStatusCode.UnsupportedMediaType, true }, - { HttpMethods.Delete, HttpStatusCode.NotAcceptable, false } - }; - - protected override void ConfigureHost(IHostApplicationBuilder hb) - { - var services = hb.Services; - services.AddRouting(); - services.AddRestfulApiVersioning(options => - { - options.DefaultApiVersion = DefaultVersion; - options.UseBuiltInRfc7807 = true; - }); - services.AddSingleton(SemanticApiVersionParser.Default); - } - - public override void ConfigureApplication(IApplicationBuilder app) - { - app.UseRouting(); - app.UseEndpoints(endpoints => - { - var versionSet = endpoints.NewApiVersionSet() - .HasApiVersion(DefaultVersion) - .HasApiVersion(AlternateVersion) - .Build(); - - foreach (var method in RoutedMethods) - { - var route = GetRoute(method); - endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, DefaultVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(DefaultVersion); - - endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, AlternateVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(AlternateVersion); - } - }); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task ExplicitSemanticVersion_ShouldRouteToDefaultSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) - { - _ = unsupportedStatusCode; - - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, DefaultVersion.ToString(), includeRequestBody)); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); - Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task ExplicitSemanticVersion_ShouldRouteToReleaseSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) - { - _ = unsupportedStatusCode; - - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, AlternateVersion.ToString(), includeRequestBody)); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); - Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task UnspecifiedSemanticVersion_ShouldUseCurrentImplementationSelectorAcrossHttpMethods(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) - { - _ = unsupportedStatusCode; - - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, version: null, includeRequestBody)); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); - Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task UnknownSemanticVersion_ShouldReturnExpectedNegotiationStatusAcrossHttpMethods(string method, HttpStatusCode expectedStatusCode, bool includeRequestBody) - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, "1.2.4-alpha+build.9", includeRequestBody)); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(payload); - - Assert.Equal(expectedStatusCode, response.StatusCode); - Assert.False(response.Headers.Contains(SemanticVersionHeaderName)); - } - - private static IResult WriteResponse(HttpContext context, string method, SemanticApiVersion version) - { - context.Response.Headers[SemanticVersionHeaderName] = version.ToString(); - context.Response.Headers[HttpMethodHeaderName] = method; - return Results.NoContent(); - } - - private static HttpRequestMessage CreateRequest(string method, string? version, bool includeRequestBody) - { - var request = new HttpRequestMessage(new HttpMethod(method), GetRoute(method)); - request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}"); - if (includeRequestBody) - { - request.Content = new StringContent("""{"payload":"semantic"}""", Encoding.UTF8, "application/json"); - } - - return request; - } - - private static string GetHeader(HttpResponseHeaders headers, string headerName) - { - return headers.TryGetValues(headerName, out IEnumerable? values) - ? Assert.Single(values) - : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); - } - - private static string GetRoute(string method) - { - return $"/semantic/{method.ToLowerInvariant()}"; - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Asp.Versioning; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class SemanticApiVersionNonProductionTest : MinimalWebHostTest +{ + private const string SemanticVersionHeaderName = "X-Semantic-Version"; + private const string HttpMethodHeaderName = "X-Http-Method"; + private static readonly SemanticApiVersion DefaultVersion = new(1, 2, 3, "alpha", "build.5"); + private static readonly SemanticApiVersion AlternateVersion = new(1, 2, 3, buildMetadata: "build.7"); + private static readonly string[] RoutedMethods = + [ + HttpMethods.Get, + HttpMethods.Head, + HttpMethods.Options, + HttpMethods.Post, + HttpMethods.Put, + HttpMethods.Patch, + HttpMethods.Delete + ]; + + public SemanticApiVersionNonProductionTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionNonProductionTest)) + { + } + + public static TheoryData VersionedHttpMethods => new() + { + { HttpMethods.Get, HttpStatusCode.NotAcceptable, false }, + { HttpMethods.Head, HttpStatusCode.NotAcceptable, false }, + { HttpMethods.Options, HttpStatusCode.NotAcceptable, false }, + { HttpMethods.Post, HttpStatusCode.UnsupportedMediaType, true }, + { HttpMethods.Put, HttpStatusCode.UnsupportedMediaType, true }, + { HttpMethods.Patch, HttpStatusCode.UnsupportedMediaType, true }, + { HttpMethods.Delete, HttpStatusCode.NotAcceptable, false } + }; + + protected override void ConfigureHost(IHostApplicationBuilder hb) + { + var services = hb.Services; + services.AddRouting(); + services.AddRestfulApiVersioning(options => + { + options.DefaultApiVersion = DefaultVersion; + options.UseBuiltInRfc7807 = true; + }); + services.AddSingleton(SemanticApiVersionParser.Default); + } + + public override void ConfigureApplication(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + var versionSet = endpoints.NewApiVersionSet() + .HasApiVersion(DefaultVersion) + .HasApiVersion(AlternateVersion) + .Build(); + + foreach (var method in RoutedMethods) + { + var route = GetRoute(method); + endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, DefaultVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(DefaultVersion); + + endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, AlternateVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(AlternateVersion); + } + }); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task ExplicitSemanticVersion_ShouldRouteToDefaultSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) + { + _ = unsupportedStatusCode; + + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, DefaultVersion.ToString(), includeRequestBody)); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); + Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task ExplicitSemanticVersion_ShouldRouteToReleaseSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) + { + _ = unsupportedStatusCode; + + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, AlternateVersion.ToString(), includeRequestBody)); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); + Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task UnspecifiedSemanticVersion_ShouldUseCurrentImplementationSelectorAcrossHttpMethods(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) + { + _ = unsupportedStatusCode; + + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, version: null, includeRequestBody)); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); + Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task UnknownSemanticVersion_ShouldReturnExpectedNegotiationStatusAcrossHttpMethods(string method, HttpStatusCode expectedStatusCode, bool includeRequestBody) + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, "1.2.4-alpha+build.9", includeRequestBody)); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(payload); + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.False(response.Headers.Contains(SemanticVersionHeaderName)); + } + + private static IResult WriteResponse(HttpContext context, string method, SemanticApiVersion version) + { + context.Response.Headers[SemanticVersionHeaderName] = version.ToString(); + context.Response.Headers[HttpMethodHeaderName] = method; + return Results.NoContent(); + } + + private static HttpRequestMessage CreateRequest(string method, string? version, bool includeRequestBody) + { + var request = new HttpRequestMessage(new HttpMethod(method), GetRoute(method)); + request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}"); + if (includeRequestBody) + { + request.Content = new StringContent("""{"payload":"semantic"}""", Encoding.UTF8, "application/json"); + } + + return request; + } + + private static string GetHeader(HttpResponseHeaders headers, string headerName) + { + return headers.TryGetValues(headerName, out IEnumerable? values) + ? Assert.Single(values) + : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); + } + + private static string GetRoute(string method) + { + return $"/semantic/{method.ToLowerInvariant()}"; + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProductionTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProductionTest.cs index c271649..6fad95a 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProductionTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProductionTest.cs @@ -1,185 +1,185 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Threading.Tasks; -using Asp.Versioning; -using Codebelt.Extensions.Xunit.Hosting.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Xunit; - -namespace Codebelt.Extensions.Asp.Versioning; - -public class SemanticApiVersionProductionTest : MinimalWebHostTest -{ - private const string SemanticVersionHeaderName = "X-Semantic-Version"; - private const string HttpMethodHeaderName = "X-Http-Method"; - private static readonly SemanticApiVersion DefaultVersion = new(1, 2, 3); - private static readonly SemanticApiVersion AlternateVersion = new(2, 0, 0); - private static readonly string[] RoutedMethods = - [ - HttpMethods.Get, - HttpMethods.Head, - HttpMethods.Options, - HttpMethods.Post, - HttpMethods.Put, - HttpMethods.Patch, - HttpMethods.Delete - ]; - - public SemanticApiVersionProductionTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionProductionTest)) - { - } - - public static TheoryData VersionedHttpMethods => new() - { - { HttpMethods.Get, HttpStatusCode.NotAcceptable, false }, - { HttpMethods.Head, HttpStatusCode.NotAcceptable, false }, - { HttpMethods.Options, HttpStatusCode.NotAcceptable, false }, - { HttpMethods.Post, HttpStatusCode.UnsupportedMediaType, true }, - { HttpMethods.Put, HttpStatusCode.UnsupportedMediaType, true }, - { HttpMethods.Patch, HttpStatusCode.UnsupportedMediaType, true }, - { HttpMethods.Delete, HttpStatusCode.NotAcceptable, false } - }; - - protected override void ConfigureHost(IHostApplicationBuilder hb) - { - var services = hb.Services; - services.AddRouting(); - services.AddRestfulApiVersioning(options => - { - options.DefaultApiVersion = DefaultVersion; - options.UseBuiltInRfc7807 = true; - }); - services.AddSingleton(SemanticApiVersionParser.Default); - } - - public override void ConfigureApplication(IApplicationBuilder app) - { - app.UseRouting(); - app.UseEndpoints(endpoints => - { - var versionSet = endpoints.NewApiVersionSet() - .HasApiVersion(DefaultVersion) - .HasApiVersion(AlternateVersion) - .Build(); - - foreach (var method in RoutedMethods) - { - var route = GetRoute(method); - endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, DefaultVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(DefaultVersion); - - endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, AlternateVersion)) - .WithApiVersionSet(versionSet) - .MapToApiVersion(AlternateVersion); - } - }); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task ExplicitSemanticVersion_ShouldRouteToDefaultSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) - { - _ = unsupportedStatusCode; - - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, DefaultVersion.ToString(), includeRequestBody)); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); - Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task ExplicitSemanticVersion_ShouldRouteToReleaseSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) - { - _ = unsupportedStatusCode; - - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, AlternateVersion.ToString(), includeRequestBody)); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); - Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task UnspecifiedSemanticVersion_ShouldUseCurrentImplementationSelectorAcrossHttpMethods(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) - { - _ = unsupportedStatusCode; - - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, version: null, includeRequestBody)); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); - Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); - } - - [Theory] - [MemberData(nameof(VersionedHttpMethods))] - public async Task UnknownSemanticVersion_ShouldReturnExpectedNegotiationStatusAcrossHttpMethods(string method, HttpStatusCode expectedStatusCode, bool includeRequestBody) - { - using var client = Host.GetTestClient(); - using var response = await client.SendAsync(CreateRequest(method, "1.2.4", includeRequestBody)); - - var payload = await response.Content.ReadAsStringAsync(); - - TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); - TestOutput.WriteLine(payload); - - Assert.Equal(expectedStatusCode, response.StatusCode); - Assert.False(response.Headers.Contains(SemanticVersionHeaderName)); - } - - private static IResult WriteResponse(HttpContext context, string method, SemanticApiVersion version) - { - context.Response.Headers[SemanticVersionHeaderName] = version.ToString(); - context.Response.Headers[HttpMethodHeaderName] = method; - return Results.NoContent(); - } - - private static HttpRequestMessage CreateRequest(string method, string? version, bool includeRequestBody) - { - var request = new HttpRequestMessage(new HttpMethod(method), GetRoute(method)); - request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}"); - if (includeRequestBody) - { - request.Content = new StringContent("""{"payload":"semantic"}""", Encoding.UTF8, "application/json"); - } - - return request; - } - - private static string GetHeader(HttpResponseHeaders headers, string headerName) - { - return headers.TryGetValues(headerName, out IEnumerable? values) - ? Assert.Single(values) - : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); - } - - private static string GetRoute(string method) - { - return $"/semantic/{method.ToLowerInvariant()}"; - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Asp.Versioning; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class SemanticApiVersionProductionTest : MinimalWebHostTest +{ + private const string SemanticVersionHeaderName = "X-Semantic-Version"; + private const string HttpMethodHeaderName = "X-Http-Method"; + private static readonly SemanticApiVersion DefaultVersion = new(1, 2, 3); + private static readonly SemanticApiVersion AlternateVersion = new(2, 0, 0); + private static readonly string[] RoutedMethods = + [ + HttpMethods.Get, + HttpMethods.Head, + HttpMethods.Options, + HttpMethods.Post, + HttpMethods.Put, + HttpMethods.Patch, + HttpMethods.Delete + ]; + + public SemanticApiVersionProductionTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionProductionTest)) + { + } + + public static TheoryData VersionedHttpMethods => new() + { + { HttpMethods.Get, HttpStatusCode.NotAcceptable, false }, + { HttpMethods.Head, HttpStatusCode.NotAcceptable, false }, + { HttpMethods.Options, HttpStatusCode.NotAcceptable, false }, + { HttpMethods.Post, HttpStatusCode.UnsupportedMediaType, true }, + { HttpMethods.Put, HttpStatusCode.UnsupportedMediaType, true }, + { HttpMethods.Patch, HttpStatusCode.UnsupportedMediaType, true }, + { HttpMethods.Delete, HttpStatusCode.NotAcceptable, false } + }; + + protected override void ConfigureHost(IHostApplicationBuilder hb) + { + var services = hb.Services; + services.AddRouting(); + services.AddRestfulApiVersioning(options => + { + options.DefaultApiVersion = DefaultVersion; + options.UseBuiltInRfc7807 = true; + }); + services.AddSingleton(SemanticApiVersionParser.Default); + } + + public override void ConfigureApplication(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + var versionSet = endpoints.NewApiVersionSet() + .HasApiVersion(DefaultVersion) + .HasApiVersion(AlternateVersion) + .Build(); + + foreach (var method in RoutedMethods) + { + var route = GetRoute(method); + endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, DefaultVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(DefaultVersion); + + endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, AlternateVersion)) + .WithApiVersionSet(versionSet) + .MapToApiVersion(AlternateVersion); + } + }); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task ExplicitSemanticVersion_ShouldRouteToDefaultSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) + { + _ = unsupportedStatusCode; + + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, DefaultVersion.ToString(), includeRequestBody)); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); + Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task ExplicitSemanticVersion_ShouldRouteToReleaseSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) + { + _ = unsupportedStatusCode; + + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, AlternateVersion.ToString(), includeRequestBody)); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); + Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task UnspecifiedSemanticVersion_ShouldUseCurrentImplementationSelectorAcrossHttpMethods(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody) + { + _ = unsupportedStatusCode; + + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, version: null, includeRequestBody)); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}"))); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName)); + Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName)); + } + + [Theory] + [MemberData(nameof(VersionedHttpMethods))] + public async Task UnknownSemanticVersion_ShouldReturnExpectedNegotiationStatusAcrossHttpMethods(string method, HttpStatusCode expectedStatusCode, bool includeRequestBody) + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(method, "1.2.4", includeRequestBody)); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}"); + TestOutput.WriteLine(payload); + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.False(response.Headers.Contains(SemanticVersionHeaderName)); + } + + private static IResult WriteResponse(HttpContext context, string method, SemanticApiVersion version) + { + context.Response.Headers[SemanticVersionHeaderName] = version.ToString(); + context.Response.Headers[HttpMethodHeaderName] = method; + return Results.NoContent(); + } + + private static HttpRequestMessage CreateRequest(string method, string? version, bool includeRequestBody) + { + var request = new HttpRequestMessage(new HttpMethod(method), GetRoute(method)); + request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}"); + if (includeRequestBody) + { + request.Content = new StringContent("""{"payload":"semantic"}""", Encoding.UTF8, "application/json"); + } + + return request; + } + + private static string GetHeader(HttpResponseHeaders headers, string headerName) + { + return headers.TryGetValues(headerName, out IEnumerable? values) + ? Assert.Single(values) + : throw new InvalidOperationException($"Expected response header '{headerName}' was not found."); + } + + private static string GetRoute(string method) + { + return $"/semantic/{method.ToLowerInvariant()}"; + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/ApiVersionReaderTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/ApiVersionReaderTest.cs index 91d338b..45b3140 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/ApiVersionReaderTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/ApiVersionReaderTest.cs @@ -1,363 +1,362 @@ -using System.Net; -using System.Net.Http; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Codebelt.Extensions.Asp.Versioning.Assets; -using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Text.Yaml; -using Codebelt.Extensions.Xunit; -using Codebelt.Extensions.Xunit.Hosting.AspNetCore; -using Cuemon; -using Cuemon.AspNetCore.Http; -using Cuemon.Diagnostics; -using Cuemon.Extensions.AspNetCore.Diagnostics; -using Cuemon.Extensions.AspNetCore.Mvc.Filters; -using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json; -using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json; -using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using YamlDotNet.Serialization.NamingConventions; - -namespace Codebelt.Extensions.Asp.Versioning -{ - public class ApiVersionReaderTest : Test - { - public ApiVersionReaderTest(ITestOutputHelper output) : base(output) - { - } - - [Fact] - public async Task GetRequest_ShouldReturnOkay_AsWeAreFilteringAwayInvalidAcceptHeaders() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly) - .AddJsonFormatters(); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;v=1.0"); - var sut = await client.GetAsync("/fake/"); - - TestOutput.WriteLine(sut.Content.Headers.ContentType.ToString()); - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.OK, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith("application/json; charset=utf-8; v=1.0", sut.Content.Headers.ContentType.ToString()); - Assert.Equal(@"""Unit Test""", await sut.Content.ReadAsStringAsync(), ignoreLineEndingDifferences: true); - } - } - - [Fact] - public async Task GetRequest_ShouldFailWitNotAcceptable_AsWeAreSpecifyingV2_WhenOnlyV1Exists() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRestfulApiVersioning(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;v=2.0"); - var sut = await client.GetAsync("/fake/"); - - var stringResult = await sut.Content.ReadAsStringAsync(); - - TestOutput.WriteLine(stringResult); - - Assert.Equal(HttpStatusCode.NotAcceptable, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.Equal("The HTTP resource that matches the request URI 'http://localhost/fake/' does not support the API version '2.0'.", stringResult, ignoreLineEndingDifferences: true); - } - } - - [Fact] - public async Task PostRequest_ShouldFailWitUnsupportedMediaType_AsWeAreSpecifyingV2_WhenOnlyV1Exists() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRestfulApiVersioning(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;v=2.0"); - var sut = await client.PostAsync("/fake/", new StringContent(Generate.RandomString(100))); - - var stringResult = await sut.Content.ReadAsStringAsync(); - - TestOutput.WriteLine(stringResult); - - Assert.Equal(HttpStatusCode.UnsupportedMediaType, sut.StatusCode); - Assert.Equal(HttpMethod.Post, sut.RequestMessage.Method); - Assert.Equal("The HTTP resource that matches the request URI 'http://localhost/fake/' does not support the API version '2.0'.", stringResult, ignoreLineEndingDifferences: true); - } - } - - [Theory] - [InlineData("application/json")] - [InlineData("text/json")] - public async Task GetRequest_ShouldFailWithBadRequestFormattedAsJsonResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnJsonAccept(string jsonAccept) - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly) - .AddJsonFormatters(o => o.Settings.Encoder = JavaScriptEncoder.Default); - services.AddHttpContextAccessor(); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRestfulApiVersioning(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", $"text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,{jsonAccept};q=10.0;v=d3"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith(jsonAccept, sut.Content.Headers.ContentType.ToString()); - Assert.True(Match(@"{ - ""error"": { - ""instance"": ""http://localhost/fake/throw"", - ""status"": 400, - ""code"": ""BadRequest"", - ""message"": ""The HTTP resource that matches the request URI \u0027http://localhost/fake/throw\u0027 does not support the API version \u0027d3\u0027."" - }, - ""traceId"": ""*"" -}".ReplaceLineEndings(), (await sut.Content.ReadAsStringAsync()).ReplaceLineEndings())); - } - } - - [Theory] - [InlineData("application/xml")] - [InlineData("text/xml")] - public async Task GetRequest_ShouldFailWithBadRequestFormattedAsXmlResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnXmlAccept(string xmlAccept) - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly) - .AddJsonFormatters(o => o.Settings.Encoder = JavaScriptEncoder.Default) - .AddXmlFormatters(); - services.AddHttpContextAccessor(); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRestfulApiVersioning(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", $"application/json,text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,{xmlAccept};q=10.0;v=d3"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith(xmlAccept, sut.Content.Headers.ContentType.ToString()); - Assert.True(Match(@"http://localhost/fake/throw400BadRequestThe HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'd3'.*", await sut.Content.ReadAsStringAsync())); - } - } - - [Theory] - [InlineData("text/plain")] - [InlineData("text/yaml")] - [InlineData("application/yaml")] - [InlineData("*/*")] - public async Task GetRequest_ShouldFailWithBadRequestFormattedAsXmlResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnYamlAccept(string yamlAccept) - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly) - .AddJsonFormatters() - .AddXmlFormatters() - .AddYamlFormatters(o => o.Settings.NamingConvention = PascalCaseNamingConvention.Instance); - services.AddHttpContextAccessor(); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRestfulApiVersioning(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", $"application/json,text/html,application/xhtml+xml,image/avif,image/webp,image/apng,application/signed-exchange;v=b3;q=0.9,{yamlAccept};q=10.0;v=d3"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith(yamlAccept, sut.Content.Headers.ContentType.ToString()); - Assert.True(Match(""" - Error: - Instance: http://localhost/fake/throw - Status: 400 - Code: BadRequest - Message: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'd3'. - TraceId: * - """.ReplaceLineEndings(), await sut.Content.ReadAsStringAsync(), o => o.ThrowOnNoMatch = true)); - } - } - - [Theory] - [InlineData("application/json")] - [InlineData("text/json")] - public async Task GetRequest_ShouldFailWithBadRequestFormattedAsNewtonsoftJsonResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnJsonAccept(string jsonAccept) - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(o => o.SensitivityDetails = FaultSensitivityDetails.Evidence); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly) - .AddNewtonsoftJsonFormatters(o => o.SensitivityDetails = FaultSensitivityDetails.Evidence); - services.AddHttpContextAccessor(); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRestfulApiVersioning(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", $"text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,{jsonAccept};q=10.0;v=d3"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith(jsonAccept, sut.Content.Headers.ContentType.ToString()); - Assert.True(Match($$""" - { - "error": { - "instance": "http://localhost/fake/throw", - "status": 400, - "code": "BadRequest", - "message": "The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'd3'." - }, - "evidence": { - "request": { - "location": "http://localhost/fake/throw", - "method": "GET", - "headers": { - "accept": [ - "text/html", - "application/xhtml+xml", - "image/avif", - "image/webp", - "image/apng", - "*/*; q=0.8", - "application/signed-exchange; v=b3; q=0.9", - "{{jsonAccept}}; q=10.0; v=d3" - ], - "host": "localhost" - }, - "query": [], - "cookies": [], - "body": "" - } - }, - "traceId": "*" - } - """.ReplaceLineEndings(), (await sut.Content.ReadAsStringAsync()).ReplaceLineEndings())); - } - } - - [Fact] - public async Task GetRequest_ShouldThrowNotAcceptableException_As_2dot0_IsAnUnknownVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers() - .AddApplicationPart(typeof(FakeController).Assembly); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseRestfulApiVersioning(); - - app.UseRouting(); - - app.UseEndpoints(routes => - { - routes.MapControllers(); - }); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/xml;v=2.0"); - - await Assert.ThrowsAsync(() => client.GetAsync("/fake/")); - } - } - - [Fact] - public async Task PostRequest_ShouldThrowUnsupportedMediaTypeException_As_2dot0_IsAnUnknownVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers() - .AddApplicationPart(typeof(FakeController).Assembly); - services.AddRestfulApiVersioning(); - }, app => - { - app.UseRestfulApiVersioning(); - - app.UseRouting(); - - app.UseEndpoints(routes => - { - routes.MapControllers(); - }); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/xml;v=2.0"); - - await Assert.ThrowsAsync(() => client.PostAsync("/fake/", new StringContent(Generate.RandomString(100)))); - } - } - } -} +using System.Net; +using System.Net.Http; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Codebelt.Extensions.Asp.Versioning.Assets; +using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Text.Yaml; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon; +using Cuemon.AspNetCore.Http; +using Cuemon.Diagnostics; +using Cuemon.Extensions.AspNetCore.Diagnostics; +using Cuemon.Extensions.AspNetCore.Mvc.Filters; +using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json; +using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json; +using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using YamlDotNet.Serialization.NamingConventions; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class ApiVersionReaderTest : Test +{ + public ApiVersionReaderTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task GetRequest_ShouldReturnOkay_AsWeAreFilteringAwayInvalidAcceptHeaders() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters(); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;v=1.0"); + var sut = await client.GetAsync("/fake/"); + + TestOutput.WriteLine(sut.Content.Headers.ContentType.ToString()); + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith("application/json; charset=utf-8; v=1.0", sut.Content.Headers.ContentType.ToString()); + Assert.Equal(@"""Unit Test""", await sut.Content.ReadAsStringAsync(), ignoreLineEndingDifferences: true); + } + } + + [Fact] + public async Task GetRequest_ShouldFailWitNotAcceptable_AsWeAreSpecifyingV2_WhenOnlyV1Exists() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRestfulApiVersioning(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;v=2.0"); + var sut = await client.GetAsync("/fake/"); + + var stringResult = await sut.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(stringResult); + + Assert.Equal(HttpStatusCode.NotAcceptable, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.Equal("The HTTP resource that matches the request URI 'http://localhost/fake/' does not support the API version '2.0'.", stringResult, ignoreLineEndingDifferences: true); + } + } + + [Fact] + public async Task PostRequest_ShouldFailWitUnsupportedMediaType_AsWeAreSpecifyingV2_WhenOnlyV1Exists() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRestfulApiVersioning(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;v=2.0"); + var sut = await client.PostAsync("/fake/", new StringContent(Generate.RandomString(100))); + + var stringResult = await sut.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(stringResult); + + Assert.Equal(HttpStatusCode.UnsupportedMediaType, sut.StatusCode); + Assert.Equal(HttpMethod.Post, sut.RequestMessage.Method); + Assert.Equal("The HTTP resource that matches the request URI 'http://localhost/fake/' does not support the API version '2.0'.", stringResult, ignoreLineEndingDifferences: true); + } + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task GetRequest_ShouldFailWithBadRequestFormattedAsJsonResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnJsonAccept(string jsonAccept) + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters(o => o.Settings.Encoder = JavaScriptEncoder.Default); + services.AddHttpContextAccessor(); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRestfulApiVersioning(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", $"text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,{jsonAccept};q=10.0;v=d3"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith(jsonAccept, sut.Content.Headers.ContentType.ToString()); + Assert.True(Match(@"{ + ""error"": { + ""instance"": ""http://localhost/fake/throw"", + ""status"": 400, + ""code"": ""BadRequest"", + ""message"": ""The HTTP resource that matches the request URI \u0027http://localhost/fake/throw\u0027 does not support the API version \u0027d3\u0027."" + }, + ""traceId"": ""*"" +}".ReplaceLineEndings(), (await sut.Content.ReadAsStringAsync()).ReplaceLineEndings())); + } + } + + [Theory] + [InlineData("application/xml")] + [InlineData("text/xml")] + public async Task GetRequest_ShouldFailWithBadRequestFormattedAsXmlResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnXmlAccept(string xmlAccept) + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters(o => o.Settings.Encoder = JavaScriptEncoder.Default) + .AddXmlFormatters(); + services.AddHttpContextAccessor(); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRestfulApiVersioning(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", $"application/json,text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,{xmlAccept};q=10.0;v=d3"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith(xmlAccept, sut.Content.Headers.ContentType.ToString()); + Assert.True(Match(@"http://localhost/fake/throw400BadRequestThe HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'd3'.*", await sut.Content.ReadAsStringAsync())); + } + } + + [Theory] + [InlineData("text/plain")] + [InlineData("text/yaml")] + [InlineData("application/yaml")] + [InlineData("*/*")] + public async Task GetRequest_ShouldFailWithBadRequestFormattedAsXmlResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnYamlAccept(string yamlAccept) + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters() + .AddXmlFormatters() + .AddYamlFormatters(o => o.Settings.NamingConvention = PascalCaseNamingConvention.Instance); + services.AddHttpContextAccessor(); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRestfulApiVersioning(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", $"application/json,text/html,application/xhtml+xml,image/avif,image/webp,image/apng,application/signed-exchange;v=b3;q=0.9,{yamlAccept};q=10.0;v=d3"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith(yamlAccept, sut.Content.Headers.ContentType.ToString()); + Assert.True(Match(""" + Error: + Instance: http://localhost/fake/throw + Status: 400 + Code: BadRequest + Message: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'd3'. + TraceId: * + """.ReplaceLineEndings(), await sut.Content.ReadAsStringAsync(), o => o.ThrowOnNoMatch = true)); + } + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task GetRequest_ShouldFailWithBadRequestFormattedAsNewtonsoftJsonResponse_As_d3_IsAnUnknownVersion_CorrectlySetOnJsonAccept(string jsonAccept) + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(o => o.SensitivityDetails = FaultSensitivityDetails.Evidence); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly) + .AddNewtonsoftJsonFormatters(o => o.SensitivityDetails = FaultSensitivityDetails.Evidence); + services.AddHttpContextAccessor(); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRestfulApiVersioning(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", $"text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,{jsonAccept};q=10.0;v=d3"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith(jsonAccept, sut.Content.Headers.ContentType.ToString()); + Assert.True(Match($$""" + { + "error": { + "instance": "http://localhost/fake/throw", + "status": 400, + "code": "BadRequest", + "message": "The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'd3'." + }, + "evidence": { + "request": { + "location": "http://localhost/fake/throw", + "method": "GET", + "headers": { + "accept": [ + "text/html", + "application/xhtml+xml", + "image/avif", + "image/webp", + "image/apng", + "*/*; q=0.8", + "application/signed-exchange; v=b3; q=0.9", + "{{jsonAccept}}; q=10.0; v=d3" + ], + "host": "localhost" + }, + "query": [], + "cookies": [], + "body": "" + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), (await sut.Content.ReadAsStringAsync()).ReplaceLineEndings())); + } + } + + [Fact] + public async Task GetRequest_ShouldThrowNotAcceptableException_As_2dot0_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers() + .AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseRestfulApiVersioning(); + + app.UseRouting(); + + app.UseEndpoints(routes => + { + routes.MapControllers(); + }); + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/xml;v=2.0"); + + await Assert.ThrowsAsync(() => client.GetAsync("/fake/")); + } + } + + [Fact] + public async Task PostRequest_ShouldThrowUnsupportedMediaTypeException_As_2dot0_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers() + .AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(); + }, app => + { + app.UseRestfulApiVersioning(); + + app.UseRouting(); + + app.UseEndpoints(routes => + { + routes.MapControllers(); + }); + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/xml;v=2.0"); + + await Assert.ThrowsAsync(() => client.PostAsync("/fake/", new StringContent(Generate.RandomString(100)))); + } + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/ApplicationBuilderExtensionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/ApplicationBuilderExtensionsTest.cs index c8572ec..816d62e 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/ApplicationBuilderExtensionsTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/ApplicationBuilderExtensionsTest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using Codebelt.Extensions.Asp.Versioning.Assets; using Codebelt.Extensions.Xunit; @@ -10,104 +10,103 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace Codebelt.Extensions.Asp.Versioning +namespace Codebelt.Extensions.Asp.Versioning; + +public class ApplicationBuilderExtensionsTest : Test { - 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) { - public ApplicationBuilderExtensionsTest(ITestOutputHelper output) : base(output) + if (factory != null) { + app.UseRestfulApiVersioning(factory); } - - private static void ConfigureServices(IServiceCollection services) + else { - // 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); + app.UseRestfulApiVersioning(); } - private static void ConfigureApp(IApplicationBuilder app, Func factory = null) + app.UseRouting(); + app.UseEndpoints(endpoints => { - if (factory != null) - { - app.UseRestfulApiVersioning(factory); - } - else + // 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 => { - app.UseRestfulApiVersioning(); - } - - app.UseRouting(); - app.UseEndpoints(endpoints => + ctx.Response.StatusCode = StatusCodes.Status406NotAcceptable; + return Task.CompletedTask; + }); + endpoints.MapGet("/test/teapot", ctx => { - // 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; - }); + ctx.Response.StatusCode = StatusCodes.Status418ImATeapot; + return Task.CompletedTask; }); - } + }); + } - [Fact] - public async Task UseRestfulApiVersioning_WithCustomFactory_ShouldInvokeProvidedFactory() - { - var customFactoryInvoked = false; + [Fact] + public async Task UseRestfulApiVersioning_WithCustomFactory_ShouldInvokeProvidedFactory() + { + var customFactoryInvoked = false; - using (var app = WebHostTestFactory.Create(ConfigureServices, appBuilder => + using (var app = WebHostTestFactory.Create(ConfigureServices, appBuilder => + { + ConfigureApp(appBuilder, context => { - ConfigureApp(appBuilder, context => - { - customFactoryInvoked = true; - return new NotAcceptableException("Custom factory: version not acceptable"); - }); - })) - { - var client = app.Host.GetTestClient(); + 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")); + // /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); - } + Assert.True(customFactoryInvoked); + Assert.StartsWith("Custom factory:", ex.Message); } + } - [Fact] - public async Task UseRestfulApiVersioning_DefaultFactory_WithMappedStatusCode_ShouldThrowCorrespondingException() + [Fact] + public async Task UseRestfulApiVersioning_DefaultFactory_WithMappedStatusCode_ShouldThrowCorrespondingException() + { + using (var app = WebHostTestFactory.Create(ConfigureServices, app => ConfigureApp(app))) { - using (var app = WebHostTestFactory.Create(ConfigureServices, app => ConfigureApp(app))) - { - var client = app.Host.GetTestClient(); + 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")); - } + // /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() + [Fact] + public async Task UseRestfulApiVersioning_DefaultFactory_WithUnmappedStatusCode_ShouldThrowInternalServerErrorException() + { + using (var app = WebHostTestFactory.Create(ConfigureServices, app => ConfigureApp(app))) { - using (var app = WebHostTestFactory.Create(ConfigureServices, app => ConfigureApp(app))) - { - var client = app.Host.GetTestClient(); + 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")); - } + // /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/DelegatingServiceProvider.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/DelegatingServiceProvider.cs index 9d9fdcc..dfba657 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/DelegatingServiceProvider.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/DelegatingServiceProvider.cs @@ -1,19 +1,18 @@ using System; -namespace Codebelt.Extensions.Asp.Versioning.Assets +namespace Codebelt.Extensions.Asp.Versioning.Assets; + +internal sealed class DelegatingServiceProvider : IServiceProvider { - internal sealed class DelegatingServiceProvider : IServiceProvider - { - private readonly IServiceProvider _serviceProvider; + private readonly IServiceProvider _serviceProvider; - public DelegatingServiceProvider(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } + public DelegatingServiceProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } - public object GetService(Type serviceType) - { - return _serviceProvider.GetService(serviceType); - } + public object GetService(Type serviceType) + { + return _serviceProvider.GetService(serviceType); } } diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/EndpointBuilderFinalizer.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/EndpointBuilderFinalizer.cs new file mode 100644 index 0000000..ca83f23 --- /dev/null +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/EndpointBuilderFinalizer.cs @@ -0,0 +1,21 @@ +using System; + +namespace Asp.Versioning.Builder; + +internal static class EndpointBuilderFinalizer +{ + internal sealed class InjectApiVersion : IServiceProvider + { + private readonly IServiceProvider _serviceProvider; + + public InjectApiVersion(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public object GetService(Type serviceType) + { + return _serviceProvider.GetService(serviceType); + } + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs index 766113c..12ecc80 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/Assets/FakeController.cs @@ -1,71 +1,70 @@ -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 -{ - [ApiController] - [Route("[controller]")] - public class FakeController : ControllerBase - { - private readonly MvcFaultDescriptorOptions _options; - - public FakeController(IOptions setup) - { - _options = setup.Value; - } - - [HttpGet] - public IActionResult Get() - { - return Ok("Unit Test"); - } - - [HttpPost] - public IActionResult Post(string random) - { - return Ok("Unit Test"); - } - - [HttpGet] - [Route("throw")] - 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(); - } - } -} +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; + +[ApiController] +[Route("[controller]")] +public class FakeController : ControllerBase +{ + private readonly MvcFaultDescriptorOptions _options; + + public FakeController(IOptions setup) + { + _options = setup.Value; + } + + [HttpGet] + public IActionResult Get() + { + return Ok("Unit Test"); + } + + [HttpPost] + public IActionResult Post(string random) + { + return Ok("Unit Test"); + } + + [HttpGet] + [Route("throw")] + 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/Codebelt.Extensions.Asp.Versioning.Tests.csproj b/test/Codebelt.Extensions.Asp.Versioning.Tests/Codebelt.Extensions.Asp.Versioning.Tests.csproj index 7e83221..b936a2c 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/Codebelt.Extensions.Asp.Versioning.Tests.csproj +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/Codebelt.Extensions.Asp.Versioning.Tests.csproj @@ -1,19 +1,19 @@ - - - - Codebelt.Extensions.Asp.Versioning - - - - - - - - - - - - - - - + + + + Codebelt.Extensions.Asp.Versioning + + + + + + + + + + + + + + + diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/ProblemDetailsFactoryTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/ProblemDetailsFactoryTest.cs index 9e1bc64..b4326d9 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/ProblemDetailsFactoryTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/ProblemDetailsFactoryTest.cs @@ -1,346 +1,351 @@ -using System.Net; -using System.Net.Http; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Codebelt.Extensions.Asp.Versioning.Assets; -using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Text.Yaml; -using Codebelt.Extensions.Xunit; -using Codebelt.Extensions.Xunit.Hosting.AspNetCore; -using Cuemon.AspNetCore.Diagnostics; -using Cuemon.AspNetCore.Http; -using Cuemon.Diagnostics; -using Cuemon.Extensions.AspNetCore.Diagnostics; -using Cuemon.Extensions.AspNetCore.Mvc.Filters; -using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json; -using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml; -using Cuemon.Extensions.AspNetCore.Text.Json.Formatters; -using Cuemon.Extensions.DependencyInjection; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using YamlDotNet.Serialization.NamingConventions; - -namespace Codebelt.Extensions.Asp.Versioning -{ - public class ProblemDetailsFactoryTest : Test - { - public ProblemDetailsFactoryTest(ITestOutputHelper output) : base(output) - { - } - - [Fact] - public async Task UseFaultDescriptorExceptionHandler_ShouldFormatException_WhenRequestServicesIsWrappedByAspVersioningInjectApiVersion() +using System.Net; +using System.Net.Http; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Codebelt.Extensions.Asp.Versioning.Assets; +using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Text.Yaml; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.AspNetCore.Diagnostics; +using Cuemon.AspNetCore.Http; +using Cuemon.Diagnostics; +using Cuemon.Extensions.AspNetCore.Diagnostics; +using Cuemon.Extensions.AspNetCore.Mvc.Filters; +using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json; +using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml; +using Cuemon.Extensions.AspNetCore.Text.Json.Formatters; +using Cuemon.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using YamlDotNet.Serialization.NamingConventions; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class ProblemDetailsFactoryTest : Test +{ + public ProblemDetailsFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task UseFaultDescriptorExceptionHandler_ShouldFormatException_WhenRequestServicesIsWrappedByAspVersioningInjectApiVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.ProblemDetails); + services.AddJsonExceptionResponseFormatter(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.Use((System.Func, Task>)((context, _) => + { + context.RequestServices = new global::Asp.Versioning.Builder.EndpointBuilderFinalizer.InjectApiVersion(context.RequestServices); + throw new NotFoundException(); + })); + })) { - using (var app = WebHostTestFactory.Create(services => + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + var sut = await client.GetAsync("/"); + var body = await sut.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(body); + + Assert.Equal(HttpStatusCode.NotFound, sut.StatusCode); + Assert.Equal("application/json", sut.Content.Headers.ContentType.MediaType); + Assert.Contains("\"title\": \"NotFound\"", body, System.StringComparison.Ordinal); + Assert.Contains("\"status\": 404", body, System.StringComparison.Ordinal); + } + } + + [Fact] + public async Task UseFaultDescriptorExceptionHandler_ShouldFormatException_WhenRequestServicesIsWrappedByDelegatingServiceProvider() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.ProblemDetails); + services.AddJsonExceptionResponseFormatter(); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.Use((System.Func, Task>)((context, _) => { - services.AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.ProblemDetails); - services.AddJsonExceptionResponseFormatter(); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.Use((System.Func, Task>)((context, _) => - { - context.RequestServices = new global::Asp.Versioning.Builder.EndpointBuilderFinalizer.InjectApiVersion(context.RequestServices); - throw new NotFoundException(); - })); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - var sut = await client.GetAsync("/"); - var body = await sut.Content.ReadAsStringAsync(); - - TestOutput.WriteLine(body); - - Assert.Equal(HttpStatusCode.NotFound, sut.StatusCode); - Assert.Equal("application/json", sut.Content.Headers.ContentType.MediaType); - Assert.Contains("\"title\": \"NotFound\"", body, System.StringComparison.Ordinal); - Assert.Contains("\"status\": 404", body, System.StringComparison.Ordinal); - } + context.RequestServices = new DelegatingServiceProvider(context.RequestServices); + throw new NotFoundException(); + })); + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + var sut = await client.GetAsync("/"); + var body = await sut.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(body); + + Assert.Equal(HttpStatusCode.NotFound, sut.StatusCode); + Assert.Equal("application/json", sut.Content.Headers.ContentType.MediaType); + Assert.Contains("\"title\": \"NotFound\"", body, System.StringComparison.Ordinal); + Assert.Contains("\"status\": 404", body, System.StringComparison.Ordinal); } + } - [Fact] - public async Task UseFaultDescriptorExceptionHandler_ShouldFormatException_WhenRequestServicesIsWrappedByDelegatingServiceProvider() + [Fact] + public async Task GetRequest_ShouldFailWithBadRequest_FormattedAsRfc7807_As_b3_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters(); + services.AddRestfulApiVersioning(o => + { + o.UseBuiltInRfc7807 = true; + o.ValidAcceptHeaders.Clear(); + }); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + })) { - using (var app = WebHostTestFactory.Create(services => + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"); + var sut = await client.GetAsync("/fake/"); + + TestOutput.WriteLine(sut.Content.Headers.ContentType.ToString()); + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith("application/problem+json", sut.Content.Headers.ContentType.ToString()); + + + var expected = """{"type":"https://docs.api-versioning.org/problems#invalid","title":"Invalid API version","status":400,"detail":"The HTTP resource that matches the request URI 'http://localhost/fake/' does not support the API version 'b3'.","code":"InvalidApiVersion","traceId":"*"}"""; + Assert.True(Match(expected, await sut.Content.ReadAsStringAsync())); + + // sadly Microsoft does not use the formatter we feed into the pipeline .. they use their own horrid WriteJsonAsync implementation .. + } + } + + [Fact] + public async Task GetRequest_ShouldFailWithBadRequestFormattedAsXmlResponse_As_b3_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters() + .AddXmlFormatters(); + services.AddHttpContextAccessor(); + services.AddRestfulApiVersioning(o => { - services.AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.ProblemDetails); - services.AddJsonExceptionResponseFormatter(); - }, app => + o.ValidAcceptHeaders.Clear(); + }); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRestfulApiVersioning(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/xml;q=0.9"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith("application/xml", sut.Content.Headers.ContentType.ToString()); + Assert.True(Match(@"http://localhost/fake/throw400BadRequestThe HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'.*", await sut.Content.ReadAsStringAsync())); + } + } + + [Fact] + public async Task GetRequest_ShouldFailWithBadRequestFormattedAsJsonResponse_As_b3_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters(o => o.Settings.Encoder = JavaScriptEncoder.Default); + services.AddHttpContextAccessor(); + services.AddRestfulApiVersioning(o => { - app.UseFaultDescriptorExceptionHandler(); - app.Use((System.Func, Task>)((context, _) => + o.ValidAcceptHeaders.Clear(); + }); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;q=10.0"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith("application/json", sut.Content.Headers.ContentType.ToString()); + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/fake/throw", + "status": 400, + "code": "BadRequest", + "message": "The HTTP resource that matches the request URI \u0027http://localhost/fake/throw\u0027 does not support the API version \u0027b3\u0027." + }, + "traceId": "*" + } + """.ReplaceLineEndings(), (await sut.Content.ReadAsStringAsync()))); + } + } + + [Fact] + public async Task GetRequest_ShouldFailWithBadRequestFormattedAsYamlResponse_As_b3_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly) + .AddYamlFormatters(o => o.Settings.NamingConvention = PascalCaseNamingConvention.Instance); + services.AddHttpContextAccessor() + .AddRestfulApiVersioning(o => { - context.RequestServices = new DelegatingServiceProvider(context.RequestServices); - throw new NotFoundException(); - })); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - var sut = await client.GetAsync("/"); - var body = await sut.Content.ReadAsStringAsync(); - - TestOutput.WriteLine(body); - - Assert.Equal(HttpStatusCode.NotFound, sut.StatusCode); - Assert.Equal("application/json", sut.Content.Headers.ContentType.MediaType); - Assert.Contains("\"title\": \"NotFound\"", body, System.StringComparison.Ordinal); - Assert.Contains("\"status\": 404", body, System.StringComparison.Ordinal); - } + o.ValidAcceptHeaders.Clear(); + }) + .PostConfigureAllOf(o => o.SensitivityDetails = FaultSensitivityDetails.All); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;q=10.0"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith("*/*", sut.Content.Headers.ContentType.ToString()); + // ASP.NET Core 11 changed the compiler-generated endpoint middleware frame name. +#if NET11_0_OR_GREATER + const string endpointMiddlewareFrame = "6_0"; +#else + const string endpointMiddlewareFrame = "7_0"; +#endif + Assert.True(Match($$""" + Error: + Instance: http://localhost/fake/throw + Status: 400 + Code: BadRequest + Message: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'. + Failure: + Type: Cuemon.AspNetCore.Http.BadRequestException + Source: Codebelt.Extensions.Asp.Versioning + Message: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'. + Stack: + - at Codebelt.Extensions.Asp.Versioning.ServiceCollectionExtensions.<>c.* + - at Microsoft.AspNetCore.Http.DefaultProblemDetailsWriter.WriteAsync(ProblemDetailsContext context) + - at Microsoft.AspNetCore.Http.ProblemDetailsService.TryWriteAsync(ProblemDetailsContext context) + - at Microsoft.AspNetCore.Routing.EndpointMiddleware.g__AwaitRequestTask|{{endpointMiddlewareFrame}}(Endpoint endpoint, Task requestTask, ILogger logger) + - at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.g__Awaited|10_0(ExceptionHandlerMiddlewareImpl middleware, HttpContext context, Task task) + StatusCode: 400 + ReasonPhrase: Bad Request + Evidence: + Request: + Location: http://localhost/fake/throw + Method: GET + Headers: + Accept: + - text/html + - application/xhtml+xml + - image/avif + - image/webp + - image/apng + - '*/*; q=0.8' + - application/signed-exchange; v=b3; q=0.9 + - application/json; q=10.0 + Host: + - localhost + Query: [] + Cookies: [] + Body: '' + TraceId: * + """.ReplaceLineEndings(), await sut.Content.ReadAsStringAsync(), o => o.ThrowOnNoMatch = true)); } + } + + [Fact] + public async Task GetRequest_ShouldFailWithBadRequestFormattedWithFallbackResponse_As_b3_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddFaultDescriptorOptions(o => o.SensitivityDetails = FaultSensitivityDetails.All); + services.AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(FakeController).Assembly); + services.AddHttpContextAccessor(); + services.AddRestfulApiVersioning(o => o.ValidAcceptHeaders.Clear()); + }, app => + { + app.UseFaultDescriptorExceptionHandler(); + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); - [Fact] - public async Task GetRequest_ShouldFailWithBadRequest_FormattedAsRfc7807_As_b3_IsAnUnknownVersion() + })) { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly) - .AddJsonFormatters(); - services.AddRestfulApiVersioning(o => - { - o.UseBuiltInRfc7807 = true; - o.ValidAcceptHeaders.Clear(); - }); - }, app => - { - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"); - var sut = await client.GetAsync("/fake/"); - - TestOutput.WriteLine(sut.Content.Headers.ContentType.ToString()); - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith("application/problem+json", sut.Content.Headers.ContentType.ToString()); - - - var expected = """{"type":"https://docs.api-versioning.org/problems#invalid","title":"Invalid API version","status":400,"detail":"The HTTP resource that matches the request URI 'http://localhost/fake/' does not support the API version 'b3'.","code":"InvalidApiVersion","traceId":"*"}"""; - Assert.True(Match(expected, await sut.Content.ReadAsStringAsync())); - - // sadly Microsoft does not use the formatter we feed into the pipeline .. they use their own horrid WriteJsonAsync implementation .. - } - } - - [Fact] - public async Task GetRequest_ShouldFailWithBadRequestFormattedAsXmlResponse_As_b3_IsAnUnknownVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly) - .AddJsonFormatters() - .AddXmlFormatters(); - services.AddHttpContextAccessor(); - services.AddRestfulApiVersioning(o => - { - o.ValidAcceptHeaders.Clear(); - }); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRestfulApiVersioning(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/xml;q=0.9"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith("application/xml", sut.Content.Headers.ContentType.ToString()); - Assert.True(Match(@"http://localhost/fake/throw400BadRequestThe HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'.*", await sut.Content.ReadAsStringAsync())); - } - } - - [Fact] - public async Task GetRequest_ShouldFailWithBadRequestFormattedAsJsonResponse_As_b3_IsAnUnknownVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly) - .AddJsonFormatters(o => o.Settings.Encoder = JavaScriptEncoder.Default); - services.AddHttpContextAccessor(); - services.AddRestfulApiVersioning(o => - { - o.ValidAcceptHeaders.Clear(); - }); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;q=10.0"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith("application/json", sut.Content.Headers.ContentType.ToString()); - Assert.True(Match(""" - { - "error": { - "instance": "http://localhost/fake/throw", - "status": 400, - "code": "BadRequest", - "message": "The HTTP resource that matches the request URI \u0027http://localhost/fake/throw\u0027 does not support the API version \u0027b3\u0027." - }, - "traceId": "*" - } - """.ReplaceLineEndings(), (await sut.Content.ReadAsStringAsync()))); - } - } - - [Fact] - public async Task GetRequest_ShouldFailWithBadRequestFormattedAsYamlResponse_As_b3_IsAnUnknownVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly) - .AddYamlFormatters(o => o.Settings.NamingConvention = PascalCaseNamingConvention.Instance); - services.AddHttpContextAccessor() - .AddRestfulApiVersioning(o => - { - o.ValidAcceptHeaders.Clear(); - }) - .PostConfigureAllOf(o => o.SensitivityDetails = FaultSensitivityDetails.All); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;q=10.0"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith("*/*", sut.Content.Headers.ContentType.ToString()); - Assert.True(Match(""" - Error: - Instance: http://localhost/fake/throw - Status: 400 - Code: BadRequest - Message: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'. - Failure: - Type: Cuemon.AspNetCore.Http.BadRequestException - Source: Codebelt.Extensions.Asp.Versioning - Message: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'. - Stack: - - at Codebelt.Extensions.Asp.Versioning.ServiceCollectionExtensions.<>c.* - - at Microsoft.AspNetCore.Http.DefaultProblemDetailsWriter.WriteAsync(ProblemDetailsContext context) - - at Microsoft.AspNetCore.Http.ProblemDetailsService.TryWriteAsync(ProblemDetailsContext context) - - at Microsoft.AspNetCore.Routing.EndpointMiddleware.g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger) - - at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.g__Awaited|10_0(ExceptionHandlerMiddlewareImpl middleware, HttpContext context, Task task) - StatusCode: 400 - ReasonPhrase: Bad Request - Evidence: - Request: - Location: http://localhost/fake/throw - Method: GET - Headers: - Accept: - - text/html - - application/xhtml+xml - - image/avif - - image/webp - - image/apng - - '*/*; q=0.8' - - application/signed-exchange; v=b3; q=0.9 - - application/json; q=10.0 - Host: - - localhost - Query: [] - Cookies: [] - Body: '' - TraceId: * - """.ReplaceLineEndings(), await sut.Content.ReadAsStringAsync(), o => o.ThrowOnNoMatch = true)); - } - } - - [Fact] - public async Task GetRequest_ShouldFailWithBadRequestFormattedWithFallbackResponse_As_b3_IsAnUnknownVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddFaultDescriptorOptions(o => o.SensitivityDetails = FaultSensitivityDetails.All); - services.AddControllers(o => o.Filters.AddFaultDescriptor()) - .AddApplicationPart(typeof(FakeController).Assembly); - services.AddHttpContextAccessor(); - services.AddRestfulApiVersioning(o => o.ValidAcceptHeaders.Clear()); - }, app => - { - app.UseFaultDescriptorExceptionHandler(); - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;q=10.0"); - var sut = await client.GetAsync("/fake/throw"); - - TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); - - Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); - Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); - Assert.EndsWith("text/plain", sut.Content.Headers.ContentType.ToString()); - Assert.StartsWith("Cuemon.AspNetCore.Http.BadRequestException: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'.".ReplaceLineEndings(), await sut.Content.ReadAsStringAsync()); - Assert.Contains(@"Additional Information: - Headers: Microsoft.AspNetCore.Http.HeaderDictionary - StatusCode: 400 - ReasonPhrase: Bad Request".ReplaceLineEndings(), await sut.Content.ReadAsStringAsync()); - } - } - - [Fact] - public async Task GetRequest_ShouldThrowABadRequestException_As_b3_IsAnUnknownVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); - services.AddRestfulApiVersioning(o => - { - o.ValidAcceptHeaders.Clear(); - }); - }, app => - { - app.UseRouting(); - app.UseEndpoints(routes => { routes.MapControllers(); }); - })) - { - var client = app.Host.GetTestClient(); - client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"); - - await Assert.ThrowsAsync(() => client.GetAsync("/fake/")); - } - } - } -} + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9,application/json;q=10.0"); + var sut = await client.GetAsync("/fake/throw"); + + TestOutput.WriteLine(await sut.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.BadRequest, sut.StatusCode); + Assert.Equal(HttpMethod.Get, sut.RequestMessage.Method); + Assert.EndsWith("text/plain", sut.Content.Headers.ContentType.ToString()); + Assert.StartsWith("Cuemon.AspNetCore.Http.BadRequestException: The HTTP resource that matches the request URI 'http://localhost/fake/throw' does not support the API version 'b3'.".ReplaceLineEndings(), await sut.Content.ReadAsStringAsync()); + Assert.Contains(@"Additional Information: + Headers: Microsoft.AspNetCore.Http.HeaderDictionary + StatusCode: 400 + ReasonPhrase: Bad Request".ReplaceLineEndings(), await sut.Content.ReadAsStringAsync()); + } + } + + [Fact] + public async Task GetRequest_ShouldThrowABadRequestException_As_b3_IsAnUnknownVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(o => + { + o.ValidAcceptHeaders.Clear(); + }); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + })) + { + var client = app.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"); + + await Assert.ThrowsAsync(() => client.GetAsync("/fake/")); + } + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs index 83b1be0..a7ac15e 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/RestfulApiVersioningOptionsTest.cs @@ -1,98 +1,97 @@ -using System; -using Asp.Versioning; -using Codebelt.Extensions.Xunit; -using Cuemon; -using Xunit; - -namespace Codebelt.Extensions.Asp.Versioning -{ - public class RestfulApiVersioningOptionsTest : Test - { - public RestfulApiVersioningOptionsTest(ITestOutputHelper output) : base(output) - { - } - - [Fact] - public void RestfulApiVersioningOptions_ParameterNameIsWithWhitespaceOnly_ShouldThrowInvalidOperationException() - { - var sut1 = new RestfulApiVersioningOptions - { - ParameterName = " " - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)')", sut2.Message); - Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - Assert.IsType(sut3.InnerException); - } - - [Fact] - public void RestfulApiVersioningOptions_ParameterNameIsEmpty_ShouldThrowInvalidOperationException() - { - var sut1 = new RestfulApiVersioningOptions - { - ParameterName = string.Empty - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)')", sut2.Message); - Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - Assert.IsType(sut3.InnerException); - } - - [Fact] - public void RestfulApiVersioningOptions_ParameterNameIsNull_ShouldThrowInvalidOperationException() - { - var sut1 = new RestfulApiVersioningOptions - { - ParameterName = null - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)')", sut2.Message); - Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - Assert.IsType(sut3.InnerException); - } - - [Fact] - public void RestfulApiVersioningOptions_ValidAcceptHeadersIsNull_ShouldThrowInvalidOperationException() - { - var sut1 = new RestfulApiVersioningOptions - { - ValidAcceptHeaders = null - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'ValidAcceptHeaders == null')", sut2.Message); - Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - 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() - { - var sut = new RestfulApiVersioningOptions(); - - Assert.NotNull(sut.ValidAcceptHeaders); - Assert.True(typeof(CurrentImplementationApiVersionSelector) == sut.ApiVersionSelectorType, "typeof(CurrentImplementationApiVersionSelector) == sut.ApiVersionSelectorType"); - //Assert.True(typeof(RestfulProblemDetailsFactory) == sut.ProblemDetailsFactoryType, "typeof(RestfulProblemDetailsFactory) == sut.ProblemDetailsFactoryType"); - Assert.Equal(ApiVersion.Default, sut.DefaultApiVersion); - Assert.Equal("v", sut.ParameterName); - Assert.NotNull(sut.Conventions); - } - } -} +using System; +using Asp.Versioning; +using Codebelt.Extensions.Xunit; +using Cuemon; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class RestfulApiVersioningOptionsTest : Test +{ + public RestfulApiVersioningOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void RestfulApiVersioningOptions_ParameterNameIsWithWhitespaceOnly_ShouldThrowInvalidOperationException() + { + var sut1 = new RestfulApiVersioningOptions + { + ParameterName = " " + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)')", sut2.Message); + Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void RestfulApiVersioningOptions_ParameterNameIsEmpty_ShouldThrowInvalidOperationException() + { + var sut1 = new RestfulApiVersioningOptions + { + ParameterName = string.Empty + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)')", sut2.Message); + Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void RestfulApiVersioningOptions_ParameterNameIsNull_ShouldThrowInvalidOperationException() + { + var sut1 = new RestfulApiVersioningOptions + { + ParameterName = null + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'Condition.IsNull(ParameterName) || Condition.IsEmpty(ParameterName) || Condition.IsWhiteSpace(ParameterName)')", sut2.Message); + Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void RestfulApiVersioningOptions_ValidAcceptHeadersIsNull_ShouldThrowInvalidOperationException() + { + var sut1 = new RestfulApiVersioningOptions + { + ValidAcceptHeaders = null + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'ValidAcceptHeaders == null')", sut2.Message); + Assert.Equal("RestfulApiVersioningOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + 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() + { + var sut = new RestfulApiVersioningOptions(); + + Assert.NotNull(sut.ValidAcceptHeaders); + Assert.True(typeof(CurrentImplementationApiVersionSelector) == sut.ApiVersionSelectorType, "typeof(CurrentImplementationApiVersionSelector) == sut.ApiVersionSelectorType"); + //Assert.True(typeof(RestfulProblemDetailsFactory) == sut.ProblemDetailsFactoryType, "typeof(RestfulProblemDetailsFactory) == sut.ProblemDetailsFactoryType"); + Assert.Equal(ApiVersion.Default, sut.DefaultApiVersion); + Assert.Equal("v", sut.ParameterName); + Assert.NotNull(sut.Conventions); + } +} diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs index ff1430a..fbac055 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs @@ -5,529 +5,528 @@ using Codebelt.Extensions.Xunit; using Xunit; -namespace Codebelt.Extensions.Asp.Versioning +namespace Codebelt.Extensions.Asp.Versioning; + +public class SemanticApiVersionTest : Test { - public class SemanticApiVersionTest : Test + public SemanticApiVersionTest(ITestOutputHelper output) : base(output) { - public SemanticApiVersionTest(ITestOutputHelper output) : base(output) - { - } + } - [Fact] - public void SemanticApiVersion_ShouldExposeSemanticVersionParts() - { - var sut = new SemanticApiVersion(1, 2, 3, "alpha.1", "build.5"); - - Assert.Equal(1, sut.MajorVersion); - Assert.Equal(2, sut.MinorVersion); - Assert.Equal(3, sut.PatchVersion); - Assert.Equal("alpha.1", sut.Prerelease); - Assert.Equal("build.5", sut.BuildMetadata); - Assert.Equal("1.2.3-alpha.1+build.5", sut.ToString()); - } + [Fact] + public void SemanticApiVersion_ShouldExposeSemanticVersionParts() + { + var sut = new SemanticApiVersion(1, 2, 3, "alpha.1", "build.5"); + + Assert.Equal(1, sut.MajorVersion); + Assert.Equal(2, sut.MinorVersion); + Assert.Equal(3, sut.PatchVersion); + Assert.Equal("alpha.1", sut.Prerelease); + Assert.Equal("build.5", sut.BuildMetadata); + Assert.Equal("1.2.3-alpha.1+build.5", sut.ToString()); + } - [Theory] - [InlineData("1.2.3+build.1", "1.2.3+build.2")] - [InlineData("1.2.3-alpha+build.1", "1.2.3-alpha+build.2")] - public void CompareTo_ShouldReturnZero_WhenOnlyBuildMetadataDiffers(string left, string right) - { - var sut1 = SemanticApiVersionParser.Default.Parse(left); - var sut2 = SemanticApiVersionParser.Default.Parse(right); + [Theory] + [InlineData("1.2.3+build.1", "1.2.3+build.2")] + [InlineData("1.2.3-alpha+build.1", "1.2.3-alpha+build.2")] + public void CompareTo_ShouldReturnZero_WhenOnlyBuildMetadataDiffers(string left, string right) + { + var sut1 = SemanticApiVersionParser.Default.Parse(left); + var sut2 = SemanticApiVersionParser.Default.Parse(right); - Assert.Equal(0, sut1.CompareTo(sut2)); - Assert.Equal(0, sut2.CompareTo(sut1)); - } + Assert.Equal(0, sut1.CompareTo(sut2)); + Assert.Equal(0, sut2.CompareTo(sut1)); + } - [Fact] - public void Equals_ShouldReturnFalse_WhenOnlyBuildMetadataDiffers() - { - var sut1 = SemanticApiVersionParser.Default.Parse("1.2.3+build.1"); - var sut2 = SemanticApiVersionParser.Default.Parse("1.2.3+build.2"); + [Fact] + public void Equals_ShouldReturnFalse_WhenOnlyBuildMetadataDiffers() + { + var sut1 = SemanticApiVersionParser.Default.Parse("1.2.3+build.1"); + var sut2 = SemanticApiVersionParser.Default.Parse("1.2.3+build.2"); - Assert.NotEqual(sut1, sut2); - Assert.NotEqual(sut1.GetHashCode(), sut2.GetHashCode()); - } + Assert.NotEqual(sut1, sut2); + Assert.NotEqual(sut1.GetHashCode(), sut2.GetHashCode()); + } - [Fact] - public void Equals_ShouldReturnExpectedResult_WhenComparingDifferentObjectTypes() - { - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); + [Fact] + public void Equals_ShouldReturnExpectedResult_WhenComparingDifferentObjectTypes() + { + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); - Assert.True(sut.Equals(new SemanticApiVersion(1, 2, 3, "alpha", "build.5"))); - Assert.False(sut.Equals(new ApiVersion(1, 2))); - Assert.False(sut.Equals("1.2.3-alpha+build.5")); - Assert.False(sut.Equals(null)); - } + Assert.True(sut.Equals(new SemanticApiVersion(1, 2, 3, "alpha", "build.5"))); + Assert.False(sut.Equals(new ApiVersion(1, 2))); + Assert.False(sut.Equals("1.2.3-alpha+build.5")); + Assert.False(sut.Equals(null)); + } - [Fact] - public void Equals_ShouldReturnFalse_WhenComparingPatchZeroReleaseSemanticVersionToApiVersion() - { - var semantic = new SemanticApiVersion(1, 0, 0); - var standard = new ApiVersion(1, 0); + [Fact] + public void Equals_ShouldReturnFalse_WhenComparingPatchZeroReleaseSemanticVersionToApiVersion() + { + var semantic = new SemanticApiVersion(1, 0, 0); + var standard = new ApiVersion(1, 0); - Assert.False(semantic.Equals(standard)); - Assert.False(standard.Equals(semantic)); - } + Assert.False(semantic.Equals(standard)); + Assert.False(standard.Equals(semantic)); + } - [Fact] - public void Equals_ShouldReturnFalse_WhenComparingNonZeroPatchSemanticVersionToApiVersion() - { - var semantic = new SemanticApiVersion(1, 2, 3); - var standard = new ApiVersion(1, 2); + [Fact] + public void Equals_ShouldReturnFalse_WhenComparingNonZeroPatchSemanticVersionToApiVersion() + { + var semantic = new SemanticApiVersion(1, 2, 3); + var standard = new ApiVersion(1, 2); - Assert.False(semantic.Equals(standard)); - Assert.False(standard.Equals(semantic)); - } + Assert.False(semantic.Equals(standard)); + Assert.False(standard.Equals(semantic)); + } - [Fact] - public void GetHashCode_ShouldReturnCachedHashCode_WhenCalledMoreThanOnce() - { - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); + [Fact] + public void GetHashCode_ShouldReturnCachedHashCode_WhenCalledMoreThanOnce() + { + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); - var hashCode = sut.GetHashCode(); + var hashCode = sut.GetHashCode(); - Assert.Equal(hashCode, sut.GetHashCode()); - } + Assert.Equal(hashCode, sut.GetHashCode()); + } - [Fact] - public void GetHashCode_ShouldReturnCachedZeroHashCode_WhenZeroValueHasBeenComputed() - { - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); - var hashCodeField = typeof(SemanticApiVersion).GetField("_hashCode", BindingFlags.Instance | BindingFlags.NonPublic); - var hashCodeComputedField = typeof(SemanticApiVersion).GetField("_hashCodeComputed", BindingFlags.Instance | BindingFlags.NonPublic); + [Fact] + public void GetHashCode_ShouldReturnCachedZeroHashCode_WhenZeroValueHasBeenComputed() + { + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); + var hashCodeField = typeof(SemanticApiVersion).GetField("_hashCode", BindingFlags.Instance | BindingFlags.NonPublic); + var hashCodeComputedField = typeof(SemanticApiVersion).GetField("_hashCodeComputed", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(hashCodeField); - Assert.NotNull(hashCodeComputedField); + Assert.NotNull(hashCodeField); + Assert.NotNull(hashCodeComputedField); - hashCodeField!.SetValue(sut, 0); - hashCodeComputedField!.SetValue(sut, true); + hashCodeField!.SetValue(sut, 0); + hashCodeComputedField!.SetValue(sut, true); - Assert.Equal(0, sut.GetHashCode()); - } + Assert.Equal(0, sut.GetHashCode()); + } - [Fact] - public void CompareTo_ShouldReturnOne_WhenOtherVersionIsNull() - { - var sut = new SemanticApiVersion(1, 2, 3); + [Fact] + public void CompareTo_ShouldReturnOne_WhenOtherVersionIsNull() + { + var sut = new SemanticApiVersion(1, 2, 3); - Assert.Equal(1, sut.CompareTo(null)); - } + Assert.Equal(1, sut.CompareTo(null)); + } - [Fact] - public void CompareTo_ShouldUseBaseComparison_WhenOtherVersionIsNotSemanticApiVersion() - { - var sut = new SemanticApiVersion(2, 0, 0); + [Fact] + public void CompareTo_ShouldUseBaseComparison_WhenOtherVersionIsNotSemanticApiVersion() + { + var sut = new SemanticApiVersion(2, 0, 0); - Assert.True(sut.CompareTo(new ApiVersion(1, 0)) > 0); - } + Assert.True(sut.CompareTo(new ApiVersion(1, 0)) > 0); + } - [Theory] - [InlineData("1.2.3", "2.0.0")] - [InlineData("1.2.3", "1.3.0")] - [InlineData("1.2.3", "1.2.4")] - [InlineData("1.2.3-alpha", "1.2.3-alpha.1")] - [InlineData("1.2.3-alpha.beta", "1.2.3-beta")] - [InlineData("1.2.3-1", "1.2.3-alpha")] - public void CompareTo_ShouldApplySemanticVersionPrecedence(string lower, string higher) - { - var sut1 = SemanticApiVersionParser.Default.Parse(lower); - var sut2 = SemanticApiVersionParser.Default.Parse(higher); + [Theory] + [InlineData("1.2.3", "2.0.0")] + [InlineData("1.2.3", "1.3.0")] + [InlineData("1.2.3", "1.2.4")] + [InlineData("1.2.3-alpha", "1.2.3-alpha.1")] + [InlineData("1.2.3-alpha.beta", "1.2.3-beta")] + [InlineData("1.2.3-1", "1.2.3-alpha")] + public void CompareTo_ShouldApplySemanticVersionPrecedence(string lower, string higher) + { + var sut1 = SemanticApiVersionParser.Default.Parse(lower); + var sut2 = SemanticApiVersionParser.Default.Parse(higher); - Assert.True(sut1.CompareTo(sut2) < 0); - Assert.True(sut2.CompareTo(sut1) > 0); - } + Assert.True(sut1.CompareTo(sut2) < 0); + Assert.True(sut2.CompareTo(sut1) > 0); + } - [Theory] - [InlineData("1.2.3-alpha", "1.2.3")] - [InlineData("1.2.3-alpha.1", "1.2.3-alpha.beta")] - [InlineData("1.2.3-alpha.1", "1.2.3-alpha.2")] - [InlineData("1.2.3-alpha.2", "1.2.3-alpha.10")] - [InlineData("1.2.3-alpha.99999999999999999999", "1.2.3-alpha.100000000000000000000")] - public void CompareTo_ShouldApplySemanticVersionPrereleasePrecedence(string lower, string higher) - { - var sut1 = SemanticApiVersionParser.Default.Parse(lower); - var sut2 = SemanticApiVersionParser.Default.Parse(higher); + [Theory] + [InlineData("1.2.3-alpha", "1.2.3")] + [InlineData("1.2.3-alpha.1", "1.2.3-alpha.beta")] + [InlineData("1.2.3-alpha.1", "1.2.3-alpha.2")] + [InlineData("1.2.3-alpha.2", "1.2.3-alpha.10")] + [InlineData("1.2.3-alpha.99999999999999999999", "1.2.3-alpha.100000000000000000000")] + public void CompareTo_ShouldApplySemanticVersionPrereleasePrecedence(string lower, string higher) + { + var sut1 = SemanticApiVersionParser.Default.Parse(lower); + var sut2 = SemanticApiVersionParser.Default.Parse(higher); - Assert.True(sut1.CompareTo(sut2) < 0); - Assert.True(sut2.CompareTo(sut1) > 0); - } + Assert.True(sut1.CompareTo(sut2) < 0); + Assert.True(sut2.CompareTo(sut1) > 0); + } - [Theory] - [InlineData("1.2.3", 1, 2, 3, null, null)] - [InlineData("1.2.3-alpha.1", 1, 2, 3, "alpha.1", null)] - [InlineData("1.2.3+build.01", 1, 2, 3, null, "build.01")] - [InlineData("1.2.3-alpha.1+build.5", 1, 2, 3, "alpha.1", "build.5")] - [InlineData("1.2.3-ALPHA+BUILD", 1, 2, 3, "ALPHA", "BUILD")] - [InlineData("1.2.3-alpha-1+build-5", 1, 2, 3, "alpha-1", "build-5")] - public void Parse_ShouldReturnSemanticApiVersion_WhenVersionIsValid(string version, int major, int minor, int patch, string prerelease, string buildMetadata) - { - var sut = Assert.IsType(SemanticApiVersionParser.Default.Parse(version)); + [Theory] + [InlineData("1.2.3", 1, 2, 3, null, null)] + [InlineData("1.2.3-alpha.1", 1, 2, 3, "alpha.1", null)] + [InlineData("1.2.3+build.01", 1, 2, 3, null, "build.01")] + [InlineData("1.2.3-alpha.1+build.5", 1, 2, 3, "alpha.1", "build.5")] + [InlineData("1.2.3-ALPHA+BUILD", 1, 2, 3, "ALPHA", "BUILD")] + [InlineData("1.2.3-alpha-1+build-5", 1, 2, 3, "alpha-1", "build-5")] + public void Parse_ShouldReturnSemanticApiVersion_WhenVersionIsValid(string version, int major, int minor, int patch, string prerelease, string buildMetadata) + { + var sut = Assert.IsType(SemanticApiVersionParser.Default.Parse(version)); - Assert.Equal(major, sut.MajorVersion); - Assert.Equal(minor, sut.MinorVersion); - Assert.Equal(patch, sut.PatchVersion); - Assert.Equal(prerelease, sut.Prerelease); - Assert.Equal(buildMetadata, sut.BuildMetadata); - } + Assert.Equal(major, sut.MajorVersion); + Assert.Equal(minor, sut.MinorVersion); + Assert.Equal(patch, sut.PatchVersion); + Assert.Equal(prerelease, sut.Prerelease); + Assert.Equal(buildMetadata, sut.BuildMetadata); + } - [Theory] - [InlineData("")] - [InlineData("1")] - [InlineData("1.2")] - [InlineData("1..3")] - [InlineData("1.2.")] - [InlineData("1.2.a")] - [InlineData("1.2.3.4")] - [InlineData("01.2.3")] - [InlineData("1.02.3")] - [InlineData("1.2.03")] - [InlineData("1.2.3-")] - [InlineData("1.2.3-alpha.")] - [InlineData("1.2.3-alpha..1")] - [InlineData("1.2.3-alpha.01")] - [InlineData("1.2.3-alpha_1")] - [InlineData("1.2.3+")] - [InlineData("1.2.3+build.")] - [InlineData("1.2.3+build..1")] - [InlineData("1.2.3+build_1")] - [InlineData("1.2.3+build+1")] - [InlineData("2147483648.2.3")] - public void TryParse_ShouldReturnFalse_WhenVersionIsInvalid(string version) - { - var result = SemanticApiVersionParser.Default.TryParse(version.AsSpan(), out var apiVersion); + [Theory] + [InlineData("")] + [InlineData("1")] + [InlineData("1.2")] + [InlineData("1..3")] + [InlineData("1.2.")] + [InlineData("1.2.a")] + [InlineData("1.2.3.4")] + [InlineData("01.2.3")] + [InlineData("1.02.3")] + [InlineData("1.2.03")] + [InlineData("1.2.3-")] + [InlineData("1.2.3-alpha.")] + [InlineData("1.2.3-alpha..1")] + [InlineData("1.2.3-alpha.01")] + [InlineData("1.2.3-alpha_1")] + [InlineData("1.2.3+")] + [InlineData("1.2.3+build.")] + [InlineData("1.2.3+build..1")] + [InlineData("1.2.3+build_1")] + [InlineData("1.2.3+build+1")] + [InlineData("2147483648.2.3")] + public void TryParse_ShouldReturnFalse_WhenVersionIsInvalid(string version) + { + var result = SemanticApiVersionParser.Default.TryParse(version.AsSpan(), out var apiVersion); - Assert.False(result); - Assert.Null(apiVersion); - } + Assert.False(result); + Assert.Null(apiVersion); + } - [Fact] - public void Parse_ShouldThrowFormatException_WhenVersionIsInvalid() - { - var sut = Assert.Throws(() => SemanticApiVersionParser.Default.Parse("1.2")); + [Fact] + public void Parse_ShouldThrowFormatException_WhenVersionIsInvalid() + { + var sut = Assert.Throws(() => SemanticApiVersionParser.Default.Parse("1.2")); - Assert.Equal("The specified API version is not a valid semantic version.", sut.Message); - } + Assert.Equal("The specified API version is not a valid semantic version.", sut.Message); + } - [Theory] - [InlineData(-1, 0, 0, "major")] - [InlineData(1, -1, 0, "minor")] - [InlineData(1, 0, -1, "patch")] - public void SemanticApiVersion_ShouldThrowArgumentOutOfRangeException_WhenVersionPartIsNegative(int major, int minor, int patch, string parameterName) - { - var sut = Assert.Throws(() => new SemanticApiVersion(major, minor, patch)); + [Theory] + [InlineData(-1, 0, 0, "major")] + [InlineData(1, -1, 0, "minor")] + [InlineData(1, 0, -1, "patch")] + public void SemanticApiVersion_ShouldThrowArgumentOutOfRangeException_WhenVersionPartIsNegative(int major, int minor, int patch, string parameterName) + { + var sut = Assert.Throws(() => new SemanticApiVersion(major, minor, patch)); - Assert.Equal(parameterName, sut.ParamName); - } + Assert.Equal(parameterName, sut.ParamName); + } - [Theory] - [InlineData("alpha.01", null, "prerelease")] - [InlineData(null, "build_1", "buildMetadata")] - public void SemanticApiVersion_ShouldThrowArgumentException_WhenIdentifierIsInvalid(string prerelease, string buildMetadata, string parameterName) - { - var sut = Assert.Throws(() => new SemanticApiVersion(1, 2, 3, prerelease, buildMetadata)); + [Theory] + [InlineData("alpha.01", null, "prerelease")] + [InlineData(null, "build_1", "buildMetadata")] + public void SemanticApiVersion_ShouldThrowArgumentException_WhenIdentifierIsInvalid(string prerelease, string buildMetadata, string parameterName) + { + var sut = Assert.Throws(() => new SemanticApiVersion(1, 2, 3, prerelease, buildMetadata)); - Assert.Equal(parameterName, sut.ParamName); - } + Assert.Equal(parameterName, sut.ParamName); + } - [Theory] - [InlineData(null, "1.2.3-alpha+build.5")] - [InlineData("G", "1.2.3-alpha+build.5")] - [InlineData("F", "1.2.3-alpha+build.5")] - [InlineData("FF", "1.2.3-alpha+build.5")] - [InlineData("g", "1.2.3-alpha+build.5")] - [InlineData("V", "1")] - [InlineData("M", "1")] - [InlineData("v", "2")] - [InlineData("m", "2")] - [InlineData("VV", "1.2")] - [InlineData("VVV", "1")] - [InlineData("VVVV", "1.2-alpha")] - [InlineData("P", "3")] - [InlineData("S", "alpha")] - [InlineData("R", "alpha")] - [InlineData("B", "build.5")] - [InlineData("VV'-'S'+'B", "1.2-alpha+build.5")] - [InlineData("VV\"-\"S\"+\"B", "1.2-alpha+build.5")] - [InlineData("V'.'v", "1.2")] - [InlineData("V'.'v'.'P", "1.2.3")] - [InlineData("M'.'m'.'P'-'R", "1.2.3-alpha")] - [InlineData("VVVV'+'B", "1.2-alpha+build.5")] - public void ToString_ShouldReturnFormattedVersion_WhenFormatIsSupported(string format, string expected) - { - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); + [Theory] + [InlineData(null, "1.2.3-alpha+build.5")] + [InlineData("G", "1.2.3-alpha+build.5")] + [InlineData("F", "1.2.3-alpha+build.5")] + [InlineData("FF", "1.2.3-alpha+build.5")] + [InlineData("g", "1.2.3-alpha+build.5")] + [InlineData("V", "1")] + [InlineData("M", "1")] + [InlineData("v", "2")] + [InlineData("m", "2")] + [InlineData("VV", "1.2")] + [InlineData("VVV", "1")] + [InlineData("VVVV", "1.2-alpha")] + [InlineData("P", "3")] + [InlineData("S", "alpha")] + [InlineData("R", "alpha")] + [InlineData("B", "build.5")] + [InlineData("VV'-'S'+'B", "1.2-alpha+build.5")] + [InlineData("VV\"-\"S\"+\"B", "1.2-alpha+build.5")] + [InlineData("V'.'v", "1.2")] + [InlineData("V'.'v'.'P", "1.2.3")] + [InlineData("M'.'m'.'P'-'R", "1.2.3-alpha")] + [InlineData("VVVV'+'B", "1.2-alpha+build.5")] + public void ToString_ShouldReturnFormattedVersion_WhenFormatIsSupported(string format, string expected) + { + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); - Assert.Equal(expected, sut.ToString(format)); - } + Assert.Equal(expected, sut.ToString(format)); + } - [Theory] - [InlineData("S", "")] - [InlineData("B", "")] - [InlineData("G", "1.2.3")] - [InlineData("VVVV", "1.2")] - public void ToString_ShouldOmitOptionalParts_WhenSemanticVersionHasNoOptionalIdentifiers(string format, string expected) - { - var sut = new SemanticApiVersion(1, 2, 3); + [Theory] + [InlineData("S", "")] + [InlineData("B", "")] + [InlineData("G", "1.2.3")] + [InlineData("VVVV", "1.2")] + public void ToString_ShouldOmitOptionalParts_WhenSemanticVersionHasNoOptionalIdentifiers(string format, string expected) + { + var sut = new SemanticApiVersion(1, 2, 3); - Assert.Equal(expected, sut.ToString(format)); - } + Assert.Equal(expected, sut.ToString(format)); + } - [Theory] - [InlineData("Q")] - [InlineData("VVVVV")] - [InlineData("VV'")] - public void ToString_ShouldThrowFormatException_WhenFormatIsUnsupported(string format) - { - var sut = new SemanticApiVersion(1, 2, 3); + [Theory] + [InlineData("Q")] + [InlineData("VVVVV")] + [InlineData("VV'")] + public void ToString_ShouldThrowFormatException_WhenFormatIsUnsupported(string format) + { + var sut = new SemanticApiVersion(1, 2, 3); - Assert.Throws(() => sut.ToString(format)); - } + Assert.Throws(() => sut.ToString(format)); + } - [Fact] - public void SemanticApiVersionFormatter_ShouldReturnExpectedFormatProvider() - { - var sut = new SemanticApiVersionFormatter(); - var provider = new SemanticApiVersionFormatterProvider(sut); - - Assert.Same(sut, SemanticApiVersionFormatter.GetInstance(sut)); - Assert.Same(sut, SemanticApiVersionFormatter.GetInstance(provider)); - Assert.Same(sut, sut.GetFormat(typeof(ICustomFormatter))); - Assert.Same(sut, sut.GetFormat(typeof(SemanticApiVersionFormatter))); - Assert.Null(sut.GetFormat(null)); - Assert.Null(sut.GetFormat(typeof(string))); - } + [Fact] + public void SemanticApiVersionFormatter_ShouldReturnExpectedFormatProvider() + { + var sut = new SemanticApiVersionFormatter(); + var provider = new SemanticApiVersionFormatterProvider(sut); + + Assert.Same(sut, SemanticApiVersionFormatter.GetInstance(sut)); + Assert.Same(sut, SemanticApiVersionFormatter.GetInstance(provider)); + Assert.Same(sut, sut.GetFormat(typeof(ICustomFormatter))); + Assert.Same(sut, sut.GetFormat(typeof(SemanticApiVersionFormatter))); + Assert.Null(sut.GetFormat(null)); + Assert.Null(sut.GetFormat(typeof(string))); + } - [Fact] - public void SemanticApiVersionFormatter_ShouldUseDefaultFormatting_WhenArgumentIsNotSemanticApiVersion() - { - var sut = new SemanticApiVersionFormatter(); + [Fact] + public void SemanticApiVersionFormatter_ShouldUseDefaultFormatting_WhenArgumentIsNotSemanticApiVersion() + { + var sut = new SemanticApiVersionFormatter(); - Assert.Equal(string.Empty, sut.Format(null, null, null)); - Assert.Equal("2A", sut.Format("X", 42, null)); - Assert.Equal("text", sut.Format("ignored", "text", null)); - } + Assert.Equal(string.Empty, sut.Format(null, null, null)); + Assert.Equal("2A", sut.Format("X", 42, null)); + Assert.Equal("text", sut.Format("ignored", "text", null)); + } - [Fact] - public void TryFormat_ShouldWriteFormattedVersion_WhenDestinationIsLargeEnough() - { - Span destination = stackalloc char[32]; - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); + [Fact] + public void TryFormat_ShouldWriteFormattedVersion_WhenDestinationIsLargeEnough() + { + Span destination = stackalloc char[32]; + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); - var result = sut.TryFormat(destination, out var charsWritten, "G", null); + var result = sut.TryFormat(destination, out var charsWritten, "G", null); - Assert.True(result); - Assert.Equal(19, charsWritten); - Assert.Equal("1.2.3-alpha+build.5", destination[..charsWritten].ToString()); - } + Assert.True(result); + Assert.Equal(19, charsWritten); + Assert.Equal("1.2.3-alpha+build.5", destination[..charsWritten].ToString()); + } - [Fact] - public void TryFormat_ShouldReturnFalse_WhenDestinationIsTooSmall() - { - Span destination = stackalloc char[4]; - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); + [Fact] + public void TryFormat_ShouldReturnFalse_WhenDestinationIsTooSmall() + { + Span destination = stackalloc char[4]; + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build.5"); - var result = sut.TryFormat(destination, out var charsWritten, "G", null); + var result = sut.TryFormat(destination, out var charsWritten, "G", null); - Assert.False(result); - Assert.Equal(0, charsWritten); - } + Assert.False(result); + Assert.Equal(0, charsWritten); + } - [Theory] - [InlineData(1, 0, 1, "v1")] - [InlineData(1, 2, 0, "v1")] - [InlineData(1, 2, 3, "v1")] - public void ToString_ShouldReturnMajorVersionGroupName_WhenUsingRestfulApiExplorerFormat(int major, int minor, int patch, string expected) - { - var sut = new SemanticApiVersion(major, minor, patch, "alpha", "build.5"); + [Theory] + [InlineData(1, 0, 1, "v1")] + [InlineData(1, 2, 0, "v1")] + [InlineData(1, 2, 3, "v1")] + public void ToString_ShouldReturnMajorVersionGroupName_WhenUsingRestfulApiExplorerFormat(int major, int minor, int patch, string expected) + { + var sut = new SemanticApiVersion(major, minor, patch, "alpha", "build.5"); - Assert.Equal(expected, sut.ToString("'v'VVV")); - } + Assert.Equal(expected, sut.ToString("'v'VVV")); + } - [Fact] - public void SemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata() - { - var sut = new SemanticApiVersionAttribute("1.2.3-alpha+build.5"); - var version = Assert.IsType(sut.Versions.Single()); + [Fact] + public void SemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata() + { + var sut = new SemanticApiVersionAttribute("1.2.3-alpha+build.5"); + var version = Assert.IsType(sut.Versions.Single()); - Assert.Equal("1.2.3-alpha+build.5", version.ToString()); - } + Assert.Equal("1.2.3-alpha+build.5", version.ToString()); + } - [Fact] - public void SemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata_WhenUsingVersionParts() - { - var sut = new SemanticApiVersionAttribute(1, 2, 3, "alpha", "build.5"); - var version = Assert.IsType(sut.Versions.Single()); + [Fact] + public void SemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata_WhenUsingVersionParts() + { + var sut = new SemanticApiVersionAttribute(1, 2, 3, "alpha", "build.5"); + var version = Assert.IsType(sut.Versions.Single()); - Assert.Equal("1.2.3-alpha+build.5", version.ToString()); - } + Assert.Equal("1.2.3-alpha+build.5", version.ToString()); + } - [Fact] - public void MapToSemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata() - { - var sut = new MapToSemanticApiVersionAttribute(1, 2, 3, "alpha", "build.5"); - var version = Assert.IsType(sut.Versions.Single()); + [Fact] + public void MapToSemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata() + { + var sut = new MapToSemanticApiVersionAttribute(1, 2, 3, "alpha", "build.5"); + var version = Assert.IsType(sut.Versions.Single()); - Assert.Equal("1.2.3-alpha+build.5", version.ToString()); - } + Assert.Equal("1.2.3-alpha+build.5", version.ToString()); + } - [Fact] - public void MapToSemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata_WhenUsingString() - { - var sut = new MapToSemanticApiVersionAttribute("1.2.3-alpha+build.5"); - var version = Assert.IsType(sut.Versions.Single()); + [Fact] + public void MapToSemanticApiVersionAttribute_ShouldDefineSemanticApiVersionMetadata_WhenUsingString() + { + var sut = new MapToSemanticApiVersionAttribute("1.2.3-alpha+build.5"); + var version = Assert.IsType(sut.Versions.Single()); - Assert.Equal("1.2.3-alpha+build.5", version.ToString()); - } + Assert.Equal("1.2.3-alpha+build.5", version.ToString()); + } - [Fact] - public void FromVersion_ShouldThrowArgumentNullException_WhenVersionIsNull() - { - var sut = Assert.Throws(() => SemanticApiVersion.FromVersion(null)); + [Fact] + public void FromVersion_ShouldThrowArgumentNullException_WhenVersionIsNull() + { + var sut = Assert.Throws(() => SemanticApiVersion.FromVersion(null)); - Assert.Equal("version", sut.ParamName); - } + Assert.Equal("version", sut.ParamName); + } - [Fact] - public void FromVersion_ShouldDefaultPatchToZero_WhenBuildComponentIsNegative() - { - var sut = SemanticApiVersion.FromVersion(new Version(1, 2)); + [Fact] + public void FromVersion_ShouldDefaultPatchToZero_WhenBuildComponentIsNegative() + { + var sut = SemanticApiVersion.FromVersion(new Version(1, 2)); - Assert.Equal(1, sut.MajorVersion); - Assert.Equal(2, sut.MinorVersion); - Assert.Equal(0, sut.PatchVersion); - Assert.Null(sut.BuildMetadata); - Assert.Equal("1.2.0", sut.ToString()); - } + Assert.Equal(1, sut.MajorVersion); + Assert.Equal(2, sut.MinorVersion); + Assert.Equal(0, sut.PatchVersion); + Assert.Null(sut.BuildMetadata); + Assert.Equal("1.2.0", sut.ToString()); + } - [Theory] - [InlineData(1, 2, 3)] - [InlineData(2, 0, 5)] - [InlineData(10, 1, 0)] - public void FromVersion_ShouldMapVersionComponents_WhenBuildComponentIsNonNegative(int major, int minor, int build) - { - var sut = SemanticApiVersion.FromVersion(new Version(major, minor, build)); + [Theory] + [InlineData(1, 2, 3)] + [InlineData(2, 0, 5)] + [InlineData(10, 1, 0)] + public void FromVersion_ShouldMapVersionComponents_WhenBuildComponentIsNonNegative(int major, int minor, int build) + { + var sut = SemanticApiVersion.FromVersion(new Version(major, minor, build)); - Assert.Equal(major, sut.MajorVersion); - Assert.Equal(minor, sut.MinorVersion); - Assert.Equal(build, sut.PatchVersion); - Assert.Null(sut.BuildMetadata); - } + Assert.Equal(major, sut.MajorVersion); + Assert.Equal(minor, sut.MinorVersion); + Assert.Equal(build, sut.PatchVersion); + Assert.Null(sut.BuildMetadata); + } - [Fact] - public void FromVersion_ShouldNotIncludeBuildMetadata_WhenIncludeRevisionIsFalse() - { - var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4)); + [Fact] + public void FromVersion_ShouldNotIncludeBuildMetadata_WhenIncludeRevisionIsFalse() + { + var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4)); - Assert.Equal("1.2.3", sut.ToString()); - Assert.Null(sut.BuildMetadata); - } + Assert.Equal("1.2.3", sut.ToString()); + Assert.Null(sut.BuildMetadata); + } - [Fact] - public void FromVersion_ShouldIncludeRevisionInBuildMetadata_WhenIncludeRevisionIsTrue() - { - var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4), o => o.IncludeRevision = true); + [Fact] + public void FromVersion_ShouldIncludeRevisionInBuildMetadata_WhenIncludeRevisionIsTrue() + { + var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4), o => o.IncludeRevision = true); - Assert.Equal(1, sut.MajorVersion); - Assert.Equal(2, sut.MinorVersion); - Assert.Equal(3, sut.PatchVersion); - Assert.Equal("revision.4", sut.BuildMetadata); - Assert.Equal("1.2.3+revision.4", sut.ToString()); - } + Assert.Equal(1, sut.MajorVersion); + Assert.Equal(2, sut.MinorVersion); + Assert.Equal(3, sut.PatchVersion); + Assert.Equal("revision.4", sut.BuildMetadata); + Assert.Equal("1.2.3+revision.4", sut.ToString()); + } - [Fact] - public void FromVersion_ShouldUseCustomRevisionIdentifier_WhenConfigured() + [Fact] + public void FromVersion_ShouldUseCustomRevisionIdentifier_WhenConfigured() + { + var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4), o => { - var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4), o => - { - o.IncludeRevision = true; - o.RevisionIdentifier = "build"; - }); - - Assert.Equal("build.4", sut.BuildMetadata); - Assert.Equal("1.2.3+build.4", sut.ToString()); - } + o.IncludeRevision = true; + o.RevisionIdentifier = "build"; + }); - [Fact] - public void FromVersion_ShouldNotIncludeBuildMetadata_WhenRevisionIsNegativeAndIncludeRevisionIsTrue() - { - var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3), o => o.IncludeRevision = true); + Assert.Equal("build.4", sut.BuildMetadata); + Assert.Equal("1.2.3+build.4", sut.ToString()); + } - Assert.Equal("1.2.3", sut.ToString()); - Assert.Null(sut.BuildMetadata); - } + [Fact] + public void FromVersion_ShouldNotIncludeBuildMetadata_WhenRevisionIsNegativeAndIncludeRevisionIsTrue() + { + var sut = SemanticApiVersion.FromVersion(new Version(1, 2, 3), o => o.IncludeRevision = true); - [Fact] - public void FromVersion_ShouldThrowArgumentException_WhenSetupIsInvalid() + Assert.Equal("1.2.3", sut.ToString()); + Assert.Null(sut.BuildMetadata); + } + + [Fact] + public void FromVersion_ShouldThrowArgumentException_WhenSetupIsInvalid() + { + var sut = Assert.Throws(() => SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4), o => { - var sut = Assert.Throws(() => SemanticApiVersion.FromVersion(new Version(1, 2, 3, 4), o => - { - o.IncludeRevision = true; - o.RevisionIdentifier = null; - })); + o.IncludeRevision = true; + o.RevisionIdentifier = null; + })); - Assert.Equal("setup", sut.ParamName); - } + Assert.Equal("setup", sut.ParamName); + } - [Fact] - public void ToString_ShouldReturnDefaultFormat_WhenCalledWithoutParameters() - { - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); + [Fact] + public void ToString_ShouldReturnDefaultFormat_WhenCalledWithoutParameters() + { + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); - var result = sut.ToString(); + var result = sut.ToString(); - Assert.Equal("1.2.3-alpha+build", result); - } + Assert.Equal("1.2.3-alpha+build", result); + } - [Fact] - public void ToString_ShouldReturnFormattedVersion_WhenCalledWithFormatOnly() - { - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); + [Fact] + public void ToString_ShouldReturnFormattedVersion_WhenCalledWithFormatOnly() + { + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); - var result = sut.ToString("G"); + var result = sut.ToString("G"); - Assert.Equal("1.2.3-alpha+build", result); - } + Assert.Equal("1.2.3-alpha+build", result); + } - [Fact] - public void TryFormat_ShouldHandleEmptyFormatSpan() - { - Span destination = stackalloc char[32]; - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); - ReadOnlySpan format = default; + [Fact] + public void TryFormat_ShouldHandleEmptyFormatSpan() + { + Span destination = stackalloc char[32]; + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); + ReadOnlySpan format = default; - var result = sut.TryFormat(destination, out var charsWritten, format, null); + var result = sut.TryFormat(destination, out var charsWritten, format, null); - Assert.True(result); - Assert.Equal("1.2.3-alpha+build", destination[..charsWritten].ToString()); - } + Assert.True(result); + Assert.Equal("1.2.3-alpha+build", destination[..charsWritten].ToString()); + } - [Fact] - public void TryFormat_ShouldReturnZeroCharsWritten_WhenCopyFails() - { - Span destination = stackalloc char[5]; - var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); - ReadOnlySpan format = default; + [Fact] + public void TryFormat_ShouldReturnZeroCharsWritten_WhenCopyFails() + { + Span destination = stackalloc char[5]; + var sut = new SemanticApiVersion(1, 2, 3, "alpha", "build"); + ReadOnlySpan format = default; - var result = sut.TryFormat(destination, out var charsWritten, format, null); + var result = sut.TryFormat(destination, out var charsWritten, format, null); - Assert.False(result); - Assert.Equal(0, charsWritten); + Assert.False(result); + Assert.Equal(0, charsWritten); + } + + private sealed class SemanticApiVersionFormatterProvider : IFormatProvider + { + private readonly SemanticApiVersionFormatter _formatter; + + public SemanticApiVersionFormatterProvider(SemanticApiVersionFormatter formatter) + { + _formatter = formatter; } - private sealed class SemanticApiVersionFormatterProvider : IFormatProvider + public object GetFormat(Type formatType) { - private readonly SemanticApiVersionFormatter _formatter; - - public SemanticApiVersionFormatterProvider(SemanticApiVersionFormatter formatter) - { - _formatter = formatter; - } - - public object GetFormat(Type formatType) - { - return formatType == typeof(SemanticApiVersionFormatter) - ? _formatter - : null; - } + return formatType == typeof(SemanticApiVersionFormatter) + ? _formatter + : null; } } } diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceCollectionExtensionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceCollectionExtensionsTest.cs index 29f1b78..fa72ff5 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceCollectionExtensionsTest.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http; using System.Linq; using System.Threading.Tasks; @@ -14,113 +14,112 @@ using Microsoft.Extensions.Options; using Xunit; -namespace Codebelt.Extensions.Asp.Versioning +namespace Codebelt.Extensions.Asp.Versioning; + +public class ServiceCollectionExtensionsTest : Test { - public class ServiceCollectionExtensionsTest : Test + public ServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) { - 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 => + [Fact] + public void AddRestfulApiVersioning_ShouldConfigureApiExplorerOptions_WithExpectedGroupNameFormat() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(o => { - app.UseRouting(); - app.UseEndpoints(routes => routes.MapControllers()); - })) - { - var options = app.Host.Services.GetRequiredService>().Value; + 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}"); + 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); - } + Assert.Equal("'version'VVV", options.GroupNameFormat); + Assert.True(options.SubstituteApiVersionInUrl); + Assert.Equal(new ApiVersion(2, 0), options.DefaultApiVersion); } + } - [Fact] - public void AddRestfulApiVersioning_ShouldGroupSemanticApiVersionsByMajorVersion() - { - using (var app = WebHostTestFactory.Create(services => - { - services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); - services.AddRestfulApiVersioning(o => - { - o.DefaultApiVersion = new SemanticApiVersion(1, 2, 0); - var controller = o.Conventions.Controller(); - controller.HasApiVersion(new SemanticApiVersion(1, 0, 1)); - controller.HasApiVersion(new SemanticApiVersion(1, 2, 0)); - controller.HasApiVersion(new SemanticApiVersion(1, 2, 3, "alpha", "build.5")); - }); - }, app => + [Fact] + public void AddRestfulApiVersioning_ShouldGroupSemanticApiVersionsByMajorVersion() + { + using (var app = WebHostTestFactory.Create(services => + { + services.AddControllers().AddApplicationPart(typeof(FakeController).Assembly); + services.AddRestfulApiVersioning(o => { - app.UseRouting(); - app.UseEndpoints(routes => routes.MapControllers()); - })) - { - var descriptions = app.Host.Services.GetRequiredService().ApiVersionDescriptions - .Where(description => description.ApiVersion is SemanticApiVersion) - .ToList(); + o.DefaultApiVersion = new SemanticApiVersion(1, 2, 0); + var controller = o.Conventions.Controller(); + controller.HasApiVersion(new SemanticApiVersion(1, 0, 1)); + controller.HasApiVersion(new SemanticApiVersion(1, 2, 0)); + controller.HasApiVersion(new SemanticApiVersion(1, 2, 3, "alpha", "build.5")); + }); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => routes.MapControllers()); + })) + { + var descriptions = app.Host.Services.GetRequiredService().ApiVersionDescriptions + .Where(description => description.ApiVersion is SemanticApiVersion) + .ToList(); - var options = app.Host.Services.GetRequiredService>().Value; + var options = app.Host.Services.GetRequiredService>().Value; - TestOutput.WriteLine($"GroupNameFormat: {options.GroupNameFormat}"); - TestOutput.WriteLine($"SubstituteApiVersionInUrl: {options.SubstituteApiVersionInUrl}"); - TestOutput.WriteLine($"DefaultApiVersion: {options.DefaultApiVersion}"); + TestOutput.WriteLine($"GroupNameFormat: {options.GroupNameFormat}"); + TestOutput.WriteLine($"SubstituteApiVersionInUrl: {options.SubstituteApiVersionInUrl}"); + TestOutput.WriteLine($"DefaultApiVersion: {options.DefaultApiVersion}"); - Assert.Contains(descriptions, description => description.ApiVersion.Equals(new SemanticApiVersion(1, 2, 0))); - Assert.Collection(descriptions.OrderBy(description => description.ApiVersion.ToString()).ToList(), - description => - { - Assert.Equal("1.0.1", description.ApiVersion.ToString()); - Assert.Equal("v1", description.GroupName); - }, - description => - { - Assert.Equal("1.2.0", description.ApiVersion.ToString()); - Assert.Equal("v1", description.GroupName); - }, - description => - { - Assert.Equal("1.2.3-alpha+build.5", description.ApiVersion.ToString()); - Assert.Equal("v1", description.GroupName); - }); - } + Assert.Contains(descriptions, description => description.ApiVersion.Equals(new SemanticApiVersion(1, 2, 0))); + Assert.Collection(descriptions.OrderBy(description => description.ApiVersion.ToString()).ToList(), + description => + { + Assert.Equal("1.0.1", description.ApiVersion.ToString()); + Assert.Equal("v1", description.GroupName); + }, + description => + { + Assert.Equal("1.2.0", description.ApiVersion.ToString()); + Assert.Equal("v1", description.GroupName); + }, + description => + { + Assert.Equal("1.2.3-alpha+build.5", description.ApiVersion.ToString()); + Assert.Equal("v1", description.GroupName); + }); } + } - [Fact] - public async Task AddRestfulApiVersioning_CustomizeProblemDetails_ShouldThrowInternalServerErrorException_WhenStatusCodeIsUnmapped() + [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()); + })) { - 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(); + 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")); - } + // /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")); } } } diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceProviderExtensionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceProviderExtensionsTest.cs index 30ca67c..6585d12 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceProviderExtensionsTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/ServiceProviderExtensionsTest.cs @@ -6,66 +6,43 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace Codebelt.Extensions.Asp.Versioning +namespace Codebelt.Extensions.Asp.Versioning; +public class ServiceProviderExtensionsTest : Test { - public class ServiceProviderExtensionsTest : Test + public ServiceProviderExtensionsTest(ITestOutputHelper output) : base(output) { - public ServiceProviderExtensionsTest(ITestOutputHelper output) : base(output) - { - } - - [Fact] - public void GetServiceDescriptors_ShouldGetDescriptors_WhenProviderIsWrappedByAspVersioningInjectApiVersion() - { - var services = new ServiceCollection(); - services.AddSingleton(new object()); - - using (var serviceProvider = services.BuildServiceProvider()) - { - var wrappedProvider = new global::Asp.Versioning.Builder.EndpointBuilderFinalizer.InjectApiVersion(serviceProvider); - - var descriptors = wrappedProvider.GetServiceDescriptors().ToList(); + } - Assert.EndsWith("Asp.Versioning.Builder.EndpointBuilderFinalizer+InjectApiVersion", wrappedProvider.GetType().FullName, StringComparison.Ordinal); - Assert.Contains(descriptors, descriptor => descriptor.ServiceType == typeof(object)); - } - } + [Fact] + public void GetServiceDescriptors_ShouldGetDescriptors_WhenProviderIsWrappedByAspVersioningInjectApiVersion() + { + var services = new ServiceCollection(); + services.AddSingleton(new object()); - [Fact] - public void GetServiceDescriptors_ShouldGetDescriptors_WhenProviderWrapsServiceProvider() + using (var serviceProvider = services.BuildServiceProvider()) { - var services = new ServiceCollection(); - services.AddSingleton(new object()); - - using (var serviceProvider = services.BuildServiceProvider()) - { - var wrappedProvider = new DelegatingServiceProvider(serviceProvider); + var wrappedProvider = new global::Asp.Versioning.Builder.EndpointBuilderFinalizer.InjectApiVersion(serviceProvider); - var descriptors = wrappedProvider.GetServiceDescriptors().ToList(); + var descriptors = wrappedProvider.GetServiceDescriptors().ToList(); - Assert.Contains(descriptors, descriptor => descriptor.ServiceType == typeof(object)); - } + Assert.EndsWith("Asp.Versioning.Builder.EndpointBuilderFinalizer+InjectApiVersion", wrappedProvider.GetType().FullName, StringComparison.Ordinal); + Assert.Contains(descriptors, descriptor => descriptor.ServiceType == typeof(object)); } } -} -namespace Asp.Versioning.Builder -{ - internal static class EndpointBuilderFinalizer + [Fact] + public void GetServiceDescriptors_ShouldGetDescriptors_WhenProviderWrapsServiceProvider() { - internal sealed class InjectApiVersion : IServiceProvider + var services = new ServiceCollection(); + services.AddSingleton(new object()); + + using (var serviceProvider = services.BuildServiceProvider()) { - private readonly IServiceProvider _serviceProvider; + var wrappedProvider = new DelegatingServiceProvider(serviceProvider); - public InjectApiVersion(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } + var descriptors = wrappedProvider.GetServiceDescriptors().ToList(); - public object GetService(Type serviceType) - { - return _serviceProvider.GetService(serviceType); - } + Assert.Contains(descriptors, descriptor => descriptor.ServiceType == typeof(object)); } } } diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/VersionConversionOptionsTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/VersionConversionOptionsTest.cs index 8ffae18..c8fe766 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/VersionConversionOptionsTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/VersionConversionOptionsTest.cs @@ -1,116 +1,115 @@ -using System; -using Codebelt.Extensions.Xunit; -using Cuemon; -using Xunit; - -namespace Codebelt.Extensions.Asp.Versioning -{ - public class VersionConversionOptionsTest : Test - { - public VersionConversionOptionsTest(ITestOutputHelper output) : base(output) - { - } - - [Fact] - public void VersionConversionOptions_ShouldHaveDefaultValues() +using System; +using Codebelt.Extensions.Xunit; +using Cuemon; +using Xunit; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class VersionConversionOptionsTest : Test +{ + public VersionConversionOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void VersionConversionOptions_ShouldHaveDefaultValues() + { + var sut = new VersionConversionOptions(); + + Assert.False(sut.IncludeRevision); + Assert.Equal("revision", sut.RevisionIdentifier); + } + + [Fact] + public void VersionConversionOptions_IncludeRevisionIsFalse_ShouldAllowInvalidRevisionIdentifier() + { + var sut = new VersionConversionOptions + { + IncludeRevision = false, + RevisionIdentifier = null + }; + + sut.ValidateOptions(); + Validator.ThrowIfInvalidOptions(sut); + } + + [Theory] + [InlineData("build")] + [InlineData("build.01")] + [InlineData("build-1")] + public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsValid_ShouldPassValidation(string revisionIdentifier) + { + var sut = new VersionConversionOptions { - var sut = new VersionConversionOptions(); + IncludeRevision = true, + RevisionIdentifier = revisionIdentifier + }; - Assert.False(sut.IncludeRevision); - Assert.Equal("revision", sut.RevisionIdentifier); - } + sut.ValidateOptions(); + Validator.ThrowIfInvalidOptions(sut); + } - [Fact] - public void VersionConversionOptions_IncludeRevisionIsFalse_ShouldAllowInvalidRevisionIdentifier() + [Fact] + public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsNull_ShouldThrowInvalidOperationException() + { + var sut1 = new VersionConversionOptions { - var sut = new VersionConversionOptions - { - IncludeRevision = false, - RevisionIdentifier = null - }; - - sut.ValidateOptions(); - Validator.ThrowIfInvalidOptions(sut); - } - - [Theory] - [InlineData("build")] - [InlineData("build.01")] - [InlineData("build-1")] - public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsValid_ShouldPassValidation(string revisionIdentifier) + IncludeRevision = true, + RevisionIdentifier = null + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)')", sut2.Message); + Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsEmpty_ShouldThrowInvalidOperationException() + { + var sut1 = new VersionConversionOptions + { + IncludeRevision = true, + RevisionIdentifier = string.Empty + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)')", sut2.Message); + Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsWhiteSpace_ShouldThrowInvalidOperationException() + { + var sut1 = new VersionConversionOptions { - var sut = new VersionConversionOptions - { - IncludeRevision = true, - RevisionIdentifier = revisionIdentifier - }; - - sut.ValidateOptions(); - Validator.ThrowIfInvalidOptions(sut); - } - - [Fact] - public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsNull_ShouldThrowInvalidOperationException() + IncludeRevision = true, + RevisionIdentifier = " " + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)')", sut2.Message); + Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsInvalid_ShouldThrowInvalidOperationException() + { + var sut1 = new VersionConversionOptions { - var sut1 = new VersionConversionOptions - { - IncludeRevision = true, - RevisionIdentifier = null - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)')", sut2.Message); - Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - Assert.IsType(sut3.InnerException); - } - - [Fact] - public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsEmpty_ShouldThrowInvalidOperationException() - { - var sut1 = new VersionConversionOptions - { - IncludeRevision = true, - RevisionIdentifier = string.Empty - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)')", sut2.Message); - Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - Assert.IsType(sut3.InnerException); - } - - [Fact] - public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsWhiteSpace_ShouldThrowInvalidOperationException() - { - var sut1 = new VersionConversionOptions - { - IncludeRevision = true, - RevisionIdentifier = " " - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && string.IsNullOrWhiteSpace(RevisionIdentifier)')", sut2.Message); - Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - Assert.IsType(sut3.InnerException); - } - - [Fact] - public void VersionConversionOptions_IncludeRevisionIsTrueAndRevisionIdentifierIsInvalid_ShouldThrowInvalidOperationException() - { - var sut1 = new VersionConversionOptions - { - IncludeRevision = true, - RevisionIdentifier = "build_1" - }; - var sut2 = Assert.Throws(() => sut1.ValidateOptions()); - var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); - - Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && !SemanticApiVersionParser.IsValidBuildMetadata(RevisionIdentifier.AsSpan())')", sut2.Message); - Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); - Assert.IsType(sut3.InnerException); - } - } -} + IncludeRevision = true, + RevisionIdentifier = "build_1" + }; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'IncludeRevision && !SemanticApiVersionParser.IsValidBuildMetadata(RevisionIdentifier.AsSpan())')", sut2.Message); + Assert.Equal("VersionConversionOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.IsType(sut3.InnerException); + } +} diff --git a/testenvironments.json b/testenvironments.json index 587b2ef..6644ae3 100644 --- a/testenvironments.json +++ b/testenvironments.json @@ -1,15 +1,20 @@ -{ - "version": "1", - "environments": [ - { - "name": "WSL-Ubuntu", - "type": "wsl", - "wslDistribution": "Ubuntu-24.04" - }, - { - "name": "Docker-Ubuntu", - "type": "docker", - "dockerImage": "codebeltnet/ubuntu-testrunner:8-9-10-11" - } - ] -} +{ + "version": "1", + "environments": [ + { + "name": "WSL-Ubuntu", + "type": "wsl", + "wslDistribution": "Ubuntu-24.04" + }, + { + "name": "Docker-Ubuntu (net10.0)", + "type": "docker", + "dockerImage": "codebeltnet/ubuntu-testrunner:10" + }, + { + "name": "Docker-Ubuntu (net11.0)", + "type": "docker", + "dockerImage": "codebeltnet/ubuntu-testrunner:11" + } + ] +}