Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;

namespace OptionalParameterinWebApi.Controllers
{
[Route("api/[controller]")]
[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<ProductController> _logger;

public ProductController(ILogger<ProductController> logger)
{
_logger = logger;
}
];

[HttpGet]
public IEnumerable<Product> Get()
Expand All @@ -37,6 +29,22 @@ public IEnumerable<Product> 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)
{
Expand All @@ -46,4 +54,3 @@ public Product GetById(int id = 1)
}
}
}

Original file line number Diff line number Diff line change
@@ -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")}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<WeatherForecastController> _logger;

public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
private static readonly string[] Summaries =
[
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
];

[HttpGet]
public IEnumerable<WeatherForecast> Get()
Expand All @@ -41,4 +34,4 @@ public WeatherForecast GetById(int id = 1)
return weatherForecast;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -24,4 +16,4 @@

app.Run();

public partial class Program { }
public partial class Program { }
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
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;

namespace Test
{
public class ProductControllerTest : IClassFixture<WebApplicationFactory<Program>>
{
private HttpClient _httpClient;
private readonly HttpClient _httpClient;
private readonly WebApplicationFactory<Program> _factory;

public ProductControllerTest(WebApplicationFactory<Program> factory)
Expand All @@ -24,53 +24,63 @@ public ProductControllerTest(WebApplicationFactory<Program> 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<IEnumerable<Product>>(content);
var products = await _httpClient.GetFromJsonAsync<IEnumerable<Product>>("api/Product");

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

[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<IEnumerable<Product>>(allProductsContent).ToList();
var product = JsonConvert.DeserializeObject<Product>(content);
var allProducts = (await _httpClient.GetFromJsonAsync<IEnumerable<Product>>("api/Product"))!.ToList();
var product = await _httpClient.GetFromJsonAsync<Product>($"/api/Product/GetById/{id}");

var correspondingProduct = products.FirstOrDefault(x => x.Id == id);
var correspondingProduct = allProducts.FirstOrDefault(x => x.Id == id);


Assert.IsType<Product>(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<Product>();

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<IEnumerable<Product>>(allProductsContent).ToList();
var product = JsonConvert.DeserializeObject<Product>(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<Product>("/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>(product);
Assert.Equal(correspondingProduct?.Name, product.Name);
[Fact]
public async Task GetBy_WithInt_ReturnsProductMatchedById()
{
var product = await _httpClient.GetFromJsonAsync<Product>("/api/Product/GetBy/5");

Assert.NotNull(product);
Assert.Equal(5, product.Id);
Assert.Equal("Boots", product.Name);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<WebApplicationFactory<Program>>
{
private readonly HttpClient _httpClient;

public RouteTemplateControllerTest(WebApplicationFactory<Program> 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);
}
}
}
Loading
Loading