diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/PizzaShop.Api.Tests.csproj b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/PizzaShop.Api.Tests.csproj new file mode 100644 index 000000000..30312a2a7 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/PizzaShop.Api.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/ProblemDetailsResponseTests.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/ProblemDetailsResponseTests.cs new file mode 100644 index 000000000..f1d053b67 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/ProblemDetailsResponseTests.cs @@ -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 factory) + : IClassFixture> +{ + [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 ReadJsonAsync(HttpResponseMessage response) => + JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Kitchen.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Kitchen.cs new file mode 100644 index 000000000..758c3efa0 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Kitchen.cs @@ -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 Stock = new() + { + ["Margherita"] = 10, + ["Diavola"] = 2 + }; + + public static Result Check(string pizza) => + Stock.TryGetValue(pizza, out var left) + ? new PizzaStock(pizza, left) + : KitchenErrors.NotFound(pizza); + + public static Result 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); + } +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaEndpoints.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaEndpoints.cs new file mode 100644 index 000000000..95df03e68 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaEndpoints.cs @@ -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(); + }); + } +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.csproj b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.csproj new file mode 100644 index 000000000..a3a34b647 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.http b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.http new file mode 100644 index 000000000..8767c03cc --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.http @@ -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 diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Program.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Program.cs new file mode 100644 index 000000000..87fb7b1e3 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Program.cs @@ -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(); diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Properties/launchSettings.json b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Properties/launchSettings.json new file mode 100644 index 000000000..8a7a1d863 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Result.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Result.cs new file mode 100644 index 000000000..0a0607d35 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Result.cs @@ -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 : 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 value) => + new(value, null); + + public static implicit operator Result(Error error) => + new(default, error); +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/ResultExtensions.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/ResultExtensions.cs new file mode 100644 index 000000000..09b1a9657 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/ResultExtensions.cs @@ -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); + } +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.Development.json b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.json b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/ProblemDetails.sln b/aspnetcore-webapi/ProblemDetailsInAspNetCore/ProblemDetails.sln new file mode 100644 index 000000000..9eb064eb2 --- /dev/null +++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/ProblemDetails.sln @@ -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