Skip to content

Commit e0eeea3

Browse files
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.
1 parent a59a6f6 commit e0eeea3

9 files changed

Lines changed: 256 additions & 83 deletions

File tree

‎aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/ProductController.cs‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
1-
using Microsoft.AspNetCore.Mvc;
1+
using Microsoft.AspNetCore.Mvc;
22

33
namespace OptionalParameterinWebApi.Controllers
44
{
55
[Route("api/[controller]")]
66
[ApiController]
77
public class ProductController : ControllerBase
88
{
9-
10-
private static readonly string[] Products = new[]
11-
{
9+
private static readonly string[] Products =
10+
[
1211
"Sweater", "Umbrella", "Jacket", "Polo", "Boots", "Microwave", "Schoolbag", "Sunshade", "SKinny Jeans", "Sunscreen"
13-
};
14-
15-
private readonly ILogger<ProductController> _logger;
16-
17-
public ProductController(ILogger<ProductController> logger)
18-
{
19-
_logger = logger;
20-
}
12+
];
2113

2214
[HttpGet]
2315
public IEnumerable<Product> Get()
@@ -37,6 +29,22 @@ public IEnumerable<Product> Get()
3729
return products;
3830
}
3931

32+
[HttpGet("GetBy/{name}")]
33+
public Product GetBy(string name)
34+
{
35+
var products = Get();
36+
37+
return products.Where(p => p.Name == name).FirstOrDefault()!;
38+
}
39+
40+
[HttpGet("GetBy/{id:int}")]
41+
public Product GetBy(int id)
42+
{
43+
var products = Get();
44+
45+
return products.Where(p => p.Id == id).FirstOrDefault()!;
46+
}
47+
4048
[HttpGet("GetById/{id:int?}")]
4149
public Product GetById(int id = 1)
4250
{
@@ -46,4 +54,3 @@ public Product GetById(int id = 1)
4654
}
4755
}
4856
}
49-
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
3+
namespace OptionalParameterinWebApi.Controllers
4+
{
5+
// Each action here isolates one route template form so a test can observe what the
6+
// router does with it: whether the URL matches without the segment, and what the
7+
// action receives when it does not.
8+
[Route("api/[controller]")]
9+
[ApiController]
10+
public class RouteTemplateController : ControllerBase
11+
{
12+
// "?" makes the segment skippable. The router leaves the route value unset,
13+
// so the method default is what supplies the value.
14+
[HttpGet("Optional/{id?}")]
15+
public string Optional(int id = 1)
16+
=> Describe(id);
17+
18+
// The same template with no method default. The URL still matches and the
19+
// parameter silently binds to default(int).
20+
[HttpGet("OptionalNoDefault/{id?}")]
21+
public string OptionalNoDefault(int id)
22+
=> Describe(id);
23+
24+
// "=1" is a route default. The router substitutes the value before the action
25+
// runs, so the route value is always set.
26+
[HttpGet("Default/{id=1}")]
27+
public string Default(int id)
28+
=> Describe(id);
29+
30+
// Constraint first, "?" last. Still refuses a non-integer segment.
31+
[HttpGet("ConstrainedOptional/{id:int?}")]
32+
public string ConstrainedOptional(int id = 1)
33+
=> Describe(id);
34+
35+
// A constraint and a route default in one segment, constraint first.
36+
[HttpGet("ConstrainedDefault/{id:int=1}")]
37+
public string ConstrainedDefault(int id)
38+
=> Describe(id);
39+
40+
private string Describe(int id)
41+
=> $"id={id};routeValueSet={RouteData.Values.ContainsKey("id")}";
42+
}
43+
}

‎aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Controllers/WeatherForecastController.cs‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,10 @@ namespace OptionalParameterinWebApi.Controllers
66
[Route("api/[controller]")]
77
public class WeatherForecastController : ControllerBase
88
{
9-
private static readonly string[] Summaries = new[]
10-
{
11-
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
12-
};
13-
14-
private readonly ILogger<WeatherForecastController> _logger;
15-
16-
public WeatherForecastController(ILogger<WeatherForecastController> logger)
17-
{
18-
_logger = logger;
19-
}
9+
private static readonly string[] Summaries =
10+
[
11+
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
12+
];
2013

2114
[HttpGet]
2215
public IEnumerable<WeatherForecast> Get()
@@ -41,4 +34,4 @@ public WeatherForecast GetById(int id = 1)
4134
return weatherForecast;
4235
}
4336
}
44-
}
37+
}
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
4-
<TargetFramework>net6.0</TargetFramework>
4+
<TargetFramework>net10.0</TargetFramework>
55
<Nullable>enable</Nullable>
66
<ImplicitUsings>enable</ImplicitUsings>
77
</PropertyGroup>
88

9-
<ItemGroup>
10-
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
11-
</ItemGroup>
12-
139
</Project>

‎aspnetcore-webapi/OptionalParameterinWebApi/OptionalParameterinWebApi/Program.cs‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,10 @@
33
// Add services to the container.
44

55
builder.Services.AddControllers();
6-
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
7-
builder.Services.AddEndpointsApiExplorer();
8-
builder.Services.AddSwaggerGen();
96

107
var app = builder.Build();
118

129
// Configure the HTTP request pipeline.
13-
if (app.Environment.IsDevelopment())
14-
{
15-
app.UseSwagger();
16-
app.UseSwaggerUI();
17-
}
1810

1911
app.UseHttpsRedirection();
2012

@@ -24,4 +16,4 @@
2416

2517
app.Run();
2618

27-
public partial class Program { }
19+
public partial class Program { }
Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
using Microsoft.AspNetCore.Mvc.Testing;
2-
using Microsoft.VisualStudio.TestPlatform.TestHost;
3-
using Newtonsoft.Json;
42
using OptionalParameterinWebApi;
53
using System.Collections.Generic;
64
using System.Linq;
5+
using System.Net;
76
using System.Net.Http;
7+
using System.Net.Http.Json;
88
using System.Threading.Tasks;
99
using Xunit;
1010

1111
namespace Test
1212
{
1313
public class ProductControllerTest : IClassFixture<WebApplicationFactory<Program>>
1414
{
15-
private HttpClient _httpClient;
15+
private readonly HttpClient _httpClient;
1616
private readonly WebApplicationFactory<Program> _factory;
1717

1818
public ProductControllerTest(WebApplicationFactory<Program> factory)
@@ -24,53 +24,63 @@ public ProductControllerTest(WebApplicationFactory<Program> factory)
2424
[Fact]
2525
public async Task Get_WhenExecuted_ReturnsListOfProducts()
2626
{
27-
var response = await _httpClient.GetAsync("api/Product");
28-
var content = await response.Content.ReadAsStringAsync();
29-
var products = JsonConvert.DeserializeObject<IEnumerable<Product>>(content);
27+
var products = await _httpClient.GetFromJsonAsync<IEnumerable<Product>>("api/Product");
3028

3129
Assert.IsAssignableFrom<IEnumerable<Product>>(products);
3230
}
3331

3432
[Theory]
3533
[InlineData(1)]
3634
[InlineData(2)]
37-
public async Task GetBy_WithInt_ReturnsProduct(int id)
35+
[InlineData(5)]
36+
[InlineData(6)]
37+
public async Task GetById_WithInt_ReturnsProduct(int id)
3838
{
39-
var allProductsResponse = await _httpClient.GetAsync("api/Product");
40-
var response = await _httpClient.GetAsync($"/api/Product/GetById/{id}");
41-
42-
var allProductsContent = await allProductsResponse.Content.ReadAsStringAsync();
43-
var content = await response.Content.ReadAsStringAsync();
44-
45-
var products = JsonConvert.DeserializeObject<IEnumerable<Product>>(allProductsContent).ToList();
46-
var product = JsonConvert.DeserializeObject<Product>(content);
39+
var allProducts = (await _httpClient.GetFromJsonAsync<IEnumerable<Product>>("api/Product"))!.ToList();
40+
var product = await _httpClient.GetFromJsonAsync<Product>($"/api/Product/GetById/{id}");
4741

48-
var correspondingProduct = products.FirstOrDefault(x => x.Id == id);
42+
var correspondingProduct = allProducts.FirstOrDefault(x => x.Id == id);
4943

50-
51-
Assert.IsType<Product>(product);
44+
Assert.NotNull(product);
5245
Assert.Equal(correspondingProduct?.Name, product.Name);
5346
}
54-
5547

56-
[Theory]
57-
[InlineData(5)]
58-
[InlineData(6)]
59-
public async Task GetById_WithInt_ReturnsProduct(int id)
48+
// The article is about what happens when the segment is left out entirely, and
49+
// no test covered it. "{id:int?}" makes the URL match without the segment, and
50+
// the action's own default of 1 is what decides which product comes back.
51+
[Fact]
52+
public async Task GetById_WhenIdOmitted_ReturnsDefaultProduct()
6053
{
61-
var allProductsResponse = await _httpClient.GetAsync("api/Product");
62-
var response = await _httpClient.GetAsync($"/api/Product/GetById/{id}");
54+
var response = await _httpClient.GetAsync("/api/Product/GetById");
55+
var product = await response.Content.ReadFromJsonAsync<Product>();
6356

64-
var allProductsContent = await allProductsResponse.Content.ReadAsStringAsync();
65-
var content = await response.Content.ReadAsStringAsync();
57+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
58+
Assert.NotNull(product);
59+
Assert.Equal(1, product.Id);
60+
Assert.Equal("Sweater", product.Name);
61+
}
6662

67-
var products = JsonConvert.DeserializeObject<IEnumerable<Product>>(allProductsContent).ToList();
68-
var product = JsonConvert.DeserializeObject<Product>(content);
63+
// The two GetBy actions share the "GetBy" literal and are told apart by the
64+
// "int" constraint on the second one. Overlapping templates are the classic
65+
// AmbiguousMatchException shape, so the disambiguation is asserted, not assumed.
66+
[Fact]
67+
public async Task GetBy_WithName_ReturnsProductMatchedByName()
68+
{
69+
var product = await _httpClient.GetFromJsonAsync<Product>("/api/Product/GetBy/Boots");
6970

70-
var correspondingProduct = products.FirstOrDefault(x => x.Id == id);
71+
Assert.NotNull(product);
72+
Assert.Equal("Boots", product.Name);
73+
Assert.Equal(5, product.Id);
74+
}
7175

72-
Assert.IsType<Product>(product);
73-
Assert.Equal(correspondingProduct?.Name, product.Name);
76+
[Fact]
77+
public async Task GetBy_WithInt_ReturnsProductMatchedById()
78+
{
79+
var product = await _httpClient.GetFromJsonAsync<Product>("/api/Product/GetBy/5");
80+
81+
Assert.NotNull(product);
82+
Assert.Equal(5, product.Id);
83+
Assert.Equal("Boots", product.Name);
7484
}
7585
}
76-
}
86+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using Microsoft.AspNetCore.Mvc.Testing;
2+
using OptionalParameterinWebApi;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Threading.Tasks;
6+
using Xunit;
7+
8+
namespace Test
9+
{
10+
// These tests observe the difference the article turns on: "?" makes a segment
11+
// skippable and leaves the route value unset, while "=1" is a route default the
12+
// router substitutes before the action runs.
13+
public class RouteTemplateControllerTest : IClassFixture<WebApplicationFactory<Program>>
14+
{
15+
private readonly HttpClient _httpClient;
16+
17+
public RouteTemplateControllerTest(WebApplicationFactory<Program> factory)
18+
{
19+
_httpClient = factory.CreateClient();
20+
}
21+
22+
[Fact]
23+
public async Task Optional_WhenSegmentOmitted_LeavesRouteValueUnsetAndUsesMethodDefault()
24+
{
25+
var response = await _httpClient.GetAsync("/api/RouteTemplate/Optional");
26+
27+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
28+
Assert.Equal("id=1;routeValueSet=False", await response.Content.ReadAsStringAsync());
29+
}
30+
31+
// The article's central claim. The method default is not a requirement: omit it
32+
// and the URL still matches, the parameter just binds to 0 with no error.
33+
[Fact]
34+
public async Task OptionalWithNoMethodDefault_WhenSegmentOmitted_BindsZero()
35+
{
36+
var response = await _httpClient.GetAsync("/api/RouteTemplate/OptionalNoDefault");
37+
38+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
39+
Assert.Equal("id=0;routeValueSet=False", await response.Content.ReadAsStringAsync());
40+
}
41+
42+
[Fact]
43+
public async Task Default_WhenSegmentOmitted_RouterSuppliesTheRouteValue()
44+
{
45+
var response = await _httpClient.GetAsync("/api/RouteTemplate/Default");
46+
47+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
48+
Assert.Equal("id=1;routeValueSet=True", await response.Content.ReadAsStringAsync());
49+
}
50+
51+
// "{id=1}" and an explicit "/1" arrive identically as far as the action's
52+
// parameters and route values are concerned.
53+
[Fact]
54+
public async Task Default_WithSegmentSupplied_IsIndistinguishableFromTheOmittedCase()
55+
{
56+
var omitted = await _httpClient.GetStringAsync("/api/RouteTemplate/Default");
57+
var supplied = await _httpClient.GetStringAsync("/api/RouteTemplate/Default/1");
58+
59+
Assert.Equal(omitted, supplied);
60+
}
61+
62+
[Theory]
63+
[InlineData("/api/RouteTemplate/ConstrainedOptional", "id=1;routeValueSet=False")]
64+
[InlineData("/api/RouteTemplate/ConstrainedOptional/9", "id=9;routeValueSet=True")]
65+
public async Task ConstrainedOptional_MatchesWithAndWithoutTheSegment(string url, string expected)
66+
{
67+
var response = await _httpClient.GetAsync(url);
68+
69+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
70+
Assert.Equal(expected, await response.Content.ReadAsStringAsync());
71+
}
72+
73+
// A constraint and a route default combine in one segment, constraint first.
74+
[Theory]
75+
[InlineData("/api/RouteTemplate/ConstrainedDefault", "id=1;routeValueSet=True")]
76+
[InlineData("/api/RouteTemplate/ConstrainedDefault/9", "id=9;routeValueSet=True")]
77+
public async Task ConstrainedDefault_SuppliesTheRouteValueAndStillAcceptsOne(string url, string expected)
78+
{
79+
var response = await _httpClient.GetAsync(url);
80+
81+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
82+
Assert.Equal(expected, await response.Content.ReadAsStringAsync());
83+
}
84+
85+
// A constraint matches, it does not validate: a URL that fails one never reaches
86+
// the action, so the caller sees 404 rather than 400.
87+
[Theory]
88+
[InlineData("/api/RouteTemplate/ConstrainedOptional/boots")]
89+
[InlineData("/api/RouteTemplate/ConstrainedDefault/boots")]
90+
public async Task NonIntegerSegment_Returns404(string url)
91+
{
92+
var response = await _httpClient.GetAsync(url);
93+
94+
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)