From e0eeea3c925e58507ddd00436372fcafa9a28846 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Sun, 30 Aug 2026 21:31:49 +0200 Subject: [PATCH] OptionalParameterinWebApi: retarget net10.0, drop Swashbuckle, add the GetBy pair and the missing optional-parameter tests - net6.0 -> net10.0 on both projects; Mvc.Testing 10.0.11, Test.Sdk 18.9.0, xunit 2.9.3, xunit.runner.visualstudio 4.0.0, coverlet.collector 10.0.1. - Remove Swashbuckle and its Program.cs calls: the sample is about routing and the article never shows Program.cs or the Swagger UI. - Add the GetBy(string name) / GetBy(int id) pair the article prints but the sample never carried, plus tests proving the int constraint disambiguates them. - Fill the empty WeatherForecastControllerTest stub and add the test nobody had written: GetById with the id segment omitted. - Add RouteTemplateController and its tests, isolating {id?}, {id?} with no method default, {id=1}, {id:int?} and {id:int=1} so the difference between an optional parameter and a route default is observed rather than asserted. - Drop the unused ILogger injection from both controllers; collection expressions for the two string arrays. --- .../Controllers/ProductController.cs | 33 ++++--- .../Controllers/RouteTemplateController.cs | 43 ++++++++ .../Controllers/WeatherForecastController.cs | 17 +--- .../OptionalParameterinWebApi.csproj | 6 +- .../OptionalParameterinWebApi/Program.cs | 10 +- .../Test/ProductControllerTest.cs | 76 ++++++++------- .../Test/RouteTemplateControllerTest.cs | 97 +++++++++++++++++++ .../Test/Test.csproj | 13 +-- .../Test/WeatherForecastControllerTest.cs | 44 ++++++++- 9 files changed, 256 insertions(+), 83 deletions(-) create mode 100644 aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/RouteTemplateController.cs create mode 100644 aspnetcore-webapi/OptionalParameterinWebApi/Test/RouteTemplateControllerTest.cs diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/ProductController.cs b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/ProductController.cs index 7e2143b467..d2727fa49d 100644 --- a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/ProductController.cs +++ b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/ProductController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace OptionalParameterinWebApi.Controllers { @@ -6,18 +6,10 @@ namespace OptionalParameterinWebApi.Controllers [ApiController] public class ProductController : ControllerBase { - - private static readonly string[] Products = new[] - { + private static readonly string[] Products = + [ "Sweater", "Umbrella", "Jacket", "Polo", "Boots", "Microwave", "Schoolbag", "Sunshade", "SKinny Jeans", "Sunscreen" - }; - - private readonly ILogger _logger; - - public ProductController(ILogger logger) - { - _logger = logger; - } + ]; [HttpGet] public IEnumerable Get() @@ -37,6 +29,22 @@ public IEnumerable Get() return products; } + [HttpGet("GetBy/{name}")] + public Product GetBy(string name) + { + var products = Get(); + + return products.Where(p => p.Name == name).FirstOrDefault()!; + } + + [HttpGet("GetBy/{id:int}")] + public Product GetBy(int id) + { + var products = Get(); + + return products.Where(p => p.Id == id).FirstOrDefault()!; + } + [HttpGet("GetById/{id:int?}")] public Product GetById(int id = 1) { @@ -46,4 +54,3 @@ public Product GetById(int id = 1) } } } - diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/RouteTemplateController.cs b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/RouteTemplateController.cs new file mode 100644 index 0000000000..2b1833a5ee --- /dev/null +++ b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/RouteTemplateController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc; + +namespace OptionalParameterinWebApi.Controllers +{ + // Each action here isolates one route template form so a test can observe what the + // router does with it: whether the URL matches without the segment, and what the + // action receives when it does not. + [Route("api/[controller]")] + [ApiController] + public class RouteTemplateController : ControllerBase + { + // "?" makes the segment skippable. The router leaves the route value unset, + // so the method default is what supplies the value. + [HttpGet("Optional/{id?}")] + public string Optional(int id = 1) + => Describe(id); + + // The same template with no method default. The URL still matches and the + // parameter silently binds to default(int). + [HttpGet("OptionalNoDefault/{id?}")] + public string OptionalNoDefault(int id) + => Describe(id); + + // "=1" is a route default. The router substitutes the value before the action + // runs, so the route value is always set. + [HttpGet("Default/{id=1}")] + public string Default(int id) + => Describe(id); + + // Constraint first, "?" last. Still refuses a non-integer segment. + [HttpGet("ConstrainedOptional/{id:int?}")] + public string ConstrainedOptional(int id = 1) + => Describe(id); + + // A constraint and a route default in one segment, constraint first. + [HttpGet("ConstrainedDefault/{id:int=1}")] + public string ConstrainedDefault(int id) + => Describe(id); + + private string Describe(int id) + => $"id={id};routeValueSet={RouteData.Values.ContainsKey("id")}"; + } +} diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/WeatherForecastController.cs b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/WeatherForecastController.cs index fc1ddf882c..8009df3255 100644 --- a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/WeatherForecastController.cs +++ b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/WeatherForecastController.cs @@ -6,17 +6,10 @@ namespace OptionalParameterinWebApi.Controllers [Route("api/[controller]")] public class WeatherForecastController : ControllerBase { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } + private static readonly string[] Summaries = + [ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + ]; [HttpGet] public IEnumerable Get() @@ -41,4 +34,4 @@ public WeatherForecast GetById(int id = 1) return weatherForecast; } } -} \ No newline at end of file +} diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/OptionalParameterinWebApi.csproj b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/OptionalParameterinWebApi.csproj index 60bf9ead83..a3a34b647c 100644 --- a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/OptionalParameterinWebApi.csproj +++ b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/OptionalParameterinWebApi.csproj @@ -1,13 +1,9 @@ - net6.0 + net10.0 enable enable - - - - diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Program.cs b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Program.cs index 604511c667..1f7237cae2 100644 --- a/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Program.cs +++ b/aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Program.cs @@ -3,18 +3,10 @@ // Add services to the container. builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); var app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} app.UseHttpsRedirection(); @@ -24,4 +16,4 @@ app.Run(); -public partial class Program { } \ No newline at end of file +public partial class Program { } diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/Test/ProductControllerTest.cs b/aspnetcore-webapi/OptionalParameterinWebApi/Test/ProductControllerTest.cs index 2abe835704..4d9971dbe9 100644 --- a/aspnetcore-webapi/OptionalParameterinWebApi/Test/ProductControllerTest.cs +++ b/aspnetcore-webapi/OptionalParameterinWebApi/Test/ProductControllerTest.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.VisualStudio.TestPlatform.TestHost; -using Newtonsoft.Json; using OptionalParameterinWebApi; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Threading.Tasks; using Xunit; @@ -12,7 +12,7 @@ namespace Test { public class ProductControllerTest : IClassFixture> { - private HttpClient _httpClient; + private readonly HttpClient _httpClient; private readonly WebApplicationFactory _factory; public ProductControllerTest(WebApplicationFactory factory) @@ -24,9 +24,7 @@ public ProductControllerTest(WebApplicationFactory factory) [Fact] public async Task Get_WhenExecuted_ReturnsListOfProducts() { - var response = await _httpClient.GetAsync("api/Product"); - var content = await response.Content.ReadAsStringAsync(); - var products = JsonConvert.DeserializeObject>(content); + var products = await _httpClient.GetFromJsonAsync>("api/Product"); Assert.IsAssignableFrom>(products); } @@ -34,43 +32,55 @@ public async Task Get_WhenExecuted_ReturnsListOfProducts() [Theory] [InlineData(1)] [InlineData(2)] - public async Task GetBy_WithInt_ReturnsProduct(int id) + [InlineData(5)] + [InlineData(6)] + public async Task GetById_WithInt_ReturnsProduct(int id) { - var allProductsResponse = await _httpClient.GetAsync("api/Product"); - var response = await _httpClient.GetAsync($"/api/Product/GetById/{id}"); - - var allProductsContent = await allProductsResponse.Content.ReadAsStringAsync(); - var content = await response.Content.ReadAsStringAsync(); - - var products = JsonConvert.DeserializeObject>(allProductsContent).ToList(); - var product = JsonConvert.DeserializeObject(content); + var allProducts = (await _httpClient.GetFromJsonAsync>("api/Product"))!.ToList(); + var product = await _httpClient.GetFromJsonAsync($"/api/Product/GetById/{id}"); - var correspondingProduct = products.FirstOrDefault(x => x.Id == id); + var correspondingProduct = allProducts.FirstOrDefault(x => x.Id == id); - - Assert.IsType(product); + Assert.NotNull(product); Assert.Equal(correspondingProduct?.Name, product.Name); } - - [Theory] - [InlineData(5)] - [InlineData(6)] - public async Task GetById_WithInt_ReturnsProduct(int id) + // The article is about what happens when the segment is left out entirely, and + // no test covered it. "{id:int?}" makes the URL match without the segment, and + // the action's own default of 1 is what decides which product comes back. + [Fact] + public async Task GetById_WhenIdOmitted_ReturnsDefaultProduct() { - var allProductsResponse = await _httpClient.GetAsync("api/Product"); - var response = await _httpClient.GetAsync($"/api/Product/GetById/{id}"); + var response = await _httpClient.GetAsync("/api/Product/GetById"); + var product = await response.Content.ReadFromJsonAsync(); - var allProductsContent = await allProductsResponse.Content.ReadAsStringAsync(); - var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(product); + Assert.Equal(1, product.Id); + Assert.Equal("Sweater", product.Name); + } - var products = JsonConvert.DeserializeObject>(allProductsContent).ToList(); - var product = JsonConvert.DeserializeObject(content); + // The two GetBy actions share the "GetBy" literal and are told apart by the + // "int" constraint on the second one. Overlapping templates are the classic + // AmbiguousMatchException shape, so the disambiguation is asserted, not assumed. + [Fact] + public async Task GetBy_WithName_ReturnsProductMatchedByName() + { + var product = await _httpClient.GetFromJsonAsync("/api/Product/GetBy/Boots"); - var correspondingProduct = products.FirstOrDefault(x => x.Id == id); + Assert.NotNull(product); + Assert.Equal("Boots", product.Name); + Assert.Equal(5, product.Id); + } - Assert.IsType(product); - Assert.Equal(correspondingProduct?.Name, product.Name); + [Fact] + public async Task GetBy_WithInt_ReturnsProductMatchedById() + { + var product = await _httpClient.GetFromJsonAsync("/api/Product/GetBy/5"); + + Assert.NotNull(product); + Assert.Equal(5, product.Id); + Assert.Equal("Boots", product.Name); } } -} \ No newline at end of file +} diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/Test/RouteTemplateControllerTest.cs b/aspnetcore-webapi/OptionalParameterinWebApi/Test/RouteTemplateControllerTest.cs new file mode 100644 index 0000000000..73db22f94c --- /dev/null +++ b/aspnetcore-webapi/OptionalParameterinWebApi/Test/RouteTemplateControllerTest.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using OptionalParameterinWebApi; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Test +{ + // These tests observe the difference the article turns on: "?" makes a segment + // skippable and leaves the route value unset, while "=1" is a route default the + // router substitutes before the action runs. + public class RouteTemplateControllerTest : IClassFixture> + { + private readonly HttpClient _httpClient; + + public RouteTemplateControllerTest(WebApplicationFactory factory) + { + _httpClient = factory.CreateClient(); + } + + [Fact] + public async Task Optional_WhenSegmentOmitted_LeavesRouteValueUnsetAndUsesMethodDefault() + { + var response = await _httpClient.GetAsync("/api/RouteTemplate/Optional"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("id=1;routeValueSet=False", await response.Content.ReadAsStringAsync()); + } + + // The article's central claim. The method default is not a requirement: omit it + // and the URL still matches, the parameter just binds to 0 with no error. + [Fact] + public async Task OptionalWithNoMethodDefault_WhenSegmentOmitted_BindsZero() + { + var response = await _httpClient.GetAsync("/api/RouteTemplate/OptionalNoDefault"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("id=0;routeValueSet=False", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Default_WhenSegmentOmitted_RouterSuppliesTheRouteValue() + { + var response = await _httpClient.GetAsync("/api/RouteTemplate/Default"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("id=1;routeValueSet=True", await response.Content.ReadAsStringAsync()); + } + + // "{id=1}" and an explicit "/1" arrive identically as far as the action's + // parameters and route values are concerned. + [Fact] + public async Task Default_WithSegmentSupplied_IsIndistinguishableFromTheOmittedCase() + { + var omitted = await _httpClient.GetStringAsync("/api/RouteTemplate/Default"); + var supplied = await _httpClient.GetStringAsync("/api/RouteTemplate/Default/1"); + + Assert.Equal(omitted, supplied); + } + + [Theory] + [InlineData("/api/RouteTemplate/ConstrainedOptional", "id=1;routeValueSet=False")] + [InlineData("/api/RouteTemplate/ConstrainedOptional/9", "id=9;routeValueSet=True")] + public async Task ConstrainedOptional_MatchesWithAndWithoutTheSegment(string url, string expected) + { + var response = await _httpClient.GetAsync(url); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expected, await response.Content.ReadAsStringAsync()); + } + + // A constraint and a route default combine in one segment, constraint first. + [Theory] + [InlineData("/api/RouteTemplate/ConstrainedDefault", "id=1;routeValueSet=True")] + [InlineData("/api/RouteTemplate/ConstrainedDefault/9", "id=9;routeValueSet=True")] + public async Task ConstrainedDefault_SuppliesTheRouteValueAndStillAcceptsOne(string url, string expected) + { + var response = await _httpClient.GetAsync(url); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expected, await response.Content.ReadAsStringAsync()); + } + + // A constraint matches, it does not validate: a URL that fails one never reaches + // the action, so the caller sees 404 rather than 400. + [Theory] + [InlineData("/api/RouteTemplate/ConstrainedOptional/boots")] + [InlineData("/api/RouteTemplate/ConstrainedDefault/boots")] + public async Task NonIntegerSegment_Returns404(string url) + { + var response = await _httpClient.GetAsync(url); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/Test/Test.csproj b/aspnetcore-webapi/OptionalParameterinWebApi/Test/Test.csproj index 4e867f0032..c819a390bf 100644 --- a/aspnetcore-webapi/OptionalParameterinWebApi/Test/Test.csproj +++ b/aspnetcore-webapi/OptionalParameterinWebApi/Test/Test.csproj @@ -1,21 +1,22 @@ - net6.0 + net10.0 enable false + true - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/aspnetcore-webapi/OptionalParameterinWebApi/Test/WeatherForecastControllerTest.cs b/aspnetcore-webapi/OptionalParameterinWebApi/Test/WeatherForecastControllerTest.cs index 4668e4e6ff..346f22e31a 100644 --- a/aspnetcore-webapi/OptionalParameterinWebApi/Test/WeatherForecastControllerTest.cs +++ b/aspnetcore-webapi/OptionalParameterinWebApi/Test/WeatherForecastControllerTest.cs @@ -1,12 +1,46 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using Microsoft.AspNetCore.Mvc.Testing; +using OptionalParameterinWebApi; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; using System.Threading.Tasks; +using Xunit; namespace Test { - internal class WeatherForecastControllerTest + public class WeatherForecastControllerTest : IClassFixture> { + private readonly HttpClient _httpClient; + + public WeatherForecastControllerTest(WebApplicationFactory factory) + { + _httpClient = factory.CreateClient(); + } + + // This is the article's headline snippet: "GetById/{id?}" with a method default + // of 1. Omitting the segment still matches the route, and the method default is + // what decides the result. + [Fact] + public async Task GetById_WhenIdOmitted_ReturnsForecastOne() + { + var response = await _httpClient.GetAsync("/api/WeatherForecast/GetById"); + var forecast = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(forecast); + Assert.Equal(1, forecast.Id); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + public async Task GetById_WithId_ReturnsMatchingForecast(int id) + { + var forecast = await _httpClient.GetFromJsonAsync($"/api/WeatherForecast/GetById/{id}"); + + Assert.NotNull(forecast); + Assert.Equal(id, forecast.Id); + } } }