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
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PizzaShop.Api\PizzaShop.Api.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;

namespace PizzaShop.Api.Tests;

public sealed class ProblemDetailsResponseTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task KnownPizza_ReturnsStock()
{
var response = await factory.CreateClient()
.GetAsync("/api/pizzas/Margherita", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Fact]
public async Task UnknownPizza_Returns404WithErrorCodeInTitle()
{
var response = await factory.CreateClient()
.GetAsync("/api/pizzas/Hawaiian", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);

using var problem = await ReadJsonAsync(response);
Assert.Equal("Pizza.NotFound", problem.RootElement.GetProperty("title").GetString());
Assert.Equal(404, problem.RootElement.GetProperty("status").GetInt32());
Assert.Equal("/api/pizzas/Hawaiian", problem.RootElement.GetProperty("instance").GetString());
Assert.True(problem.RootElement.TryGetProperty("traceId", out _));
}

[Fact]
public async Task SoldOutPizza_Returns409Problem()
{
var response = await factory.CreateClient().PostAsJsonAsync(
"/api/pizzas/Diavola/orders", new { quantity = 3 }, TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);

using var problem = await ReadJsonAsync(response);
Assert.Equal("Pizza.SoldOut", problem.RootElement.GetProperty("title").GetString());
}

[Fact]
public async Task InvalidQuantity_Returns400WithErrors()
{
var response = await factory.CreateClient().PostAsJsonAsync(
"/api/pizzas/Margherita/orders", new { quantity = 0 }, TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

using var problem = await ReadJsonAsync(response);
Assert.True(problem.RootElement.GetProperty("errors").TryGetProperty("Quantity", out _));
}

[Fact]
public async Task UnknownRoute_Returns404ProblemFromStatusCodePages()
{
var response = await factory.CreateClient()
.GetAsync("/api/pizza/Margherita", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
}

private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response) =>
JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace PizzaShop.Api;

public sealed record PizzaStock(string Name, int Left);

public sealed record OrderConfirmation(string Pizza, int Quantity);

public static class KitchenErrors
{
public static Error NotFound(string pizza) =>
new("Pizza.NotFound", $"We don't make {pizza} pizza.", ErrorType.NotFound);

public static Error SoldOut(string pizza, int left, int requested) =>
new("Pizza.SoldOut", $"Only {left} {pizza} left, {requested} requested.", ErrorType.Conflict);
}

public static class Kitchen
{
private static readonly Dictionary<string, int> Stock = new()
{
["Margherita"] = 10,
["Diavola"] = 2
};

public static Result<PizzaStock> Check(string pizza) =>
Stock.TryGetValue(pizza, out var left)
? new PizzaStock(pizza, left)
: KitchenErrors.NotFound(pizza);

public static Result<OrderConfirmation> Order(string pizza, int quantity)
{
if (!Stock.TryGetValue(pizza, out var left))
return KitchenErrors.NotFound(pizza);

if (quantity > left)
return KitchenErrors.SoldOut(pizza, left, quantity);

return new OrderConfirmation(pizza, quantity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;

namespace PizzaShop.Api;

public sealed record OrderRequest([property: Range(1, 20)] int Quantity);

public static class PizzaEndpoints
{
public static void MapPizzaEndpoints(this IEndpointRouteBuilder app)
{
var pizzas = app.MapGroup("/api/pizzas");

pizzas.MapGet("/{name}", (string name) =>
{
var result = Kitchen.Check(name);

return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});

pizzas.MapPost("/{name}/orders", (string name, OrderRequest request) =>
{
var result = Kitchen.Order(name, request.Quantity);

return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

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

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
GET http://localhost:5000/api/pizzas/Margherita
Accept: application/json

###

GET http://localhost:5000/api/pizzas/Hawaiian
Accept: application/json

###

POST http://localhost:5000/api/pizzas/Diavola/orders
Content-Type: application/json
Accept: application/json

{ "quantity": 3 }

###

POST http://localhost:5000/api/pizzas/Margherita/orders
Content-Type: application/json
Accept: application/json

{ "quantity": 0 }

###

GET http://localhost:5000/api/pizza/Margherita
Accept: application/json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using PizzaShop.Api;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;

if (context.Exception is not null && builder.Environment.IsDevelopment())
context.ProblemDetails.Extensions["exception"] = context.Exception.Message;
};
});
builder.Services.AddValidation();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

app.MapPizzaEndpoints();

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5284",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7113;http://localhost:5284",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace PizzaShop.Api;

public enum ErrorType
{
NotFound,
Conflict
}

public sealed record Error(string Code, string Description, ErrorType Type);

public class Result
{
protected Result(Error? error) => Error = error;

public Error? Error { get; }
public bool IsSuccess => Error is null;

public static Result Success() => new(null);

public static implicit operator Result(Error error) => new(error);
}

public sealed class Result<TValue> : Result
{
private readonly TValue? _value;

private Result(TValue? value, Error? error)
: base(error) => _value = value;

public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("A failed result has no value.");

public static implicit operator Result<TValue>(TValue value) =>
new(value, null);

public static implicit operator Result<TValue>(Error error) =>
new(default, error);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace PizzaShop.Api;

public static class ResultExtensions
{
public static IResult ToProblem(this Result result)
{
var error = result.Error
?? throw new InvalidOperationException("A successful result is not a problem.");

var statusCode = error.Type switch
{
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status500InternalServerError
};

return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
48 changes: 48 additions & 0 deletions aspnetcore-webapi/ProblemDetailsInAspNetCore/ProblemDetails.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PizzaShop.Api", "PizzaShop.Api\PizzaShop.Api.csproj", "{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PizzaShop.Api.Tests", "PizzaShop.Api.Tests\PizzaShop.Api.Tests.csproj", "{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x64.ActiveCfg = Debug|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x64.Build.0 = Debug|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x86.ActiveCfg = Debug|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x86.Build.0 = Debug|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|Any CPU.Build.0 = Release|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x64.ActiveCfg = Release|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x64.Build.0 = Release|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x86.ActiveCfg = Release|Any CPU
{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x86.Build.0 = Release|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x64.ActiveCfg = Debug|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x64.Build.0 = Debug|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x86.ActiveCfg = Debug|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x86.Build.0 = Debug|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|Any CPU.Build.0 = Release|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x64.ActiveCfg = Release|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x64.Build.0 = Release|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x86.ActiveCfg = Release|Any CPU
{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
Loading