diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs new file mode 100644 index 000000000..1f61a1166 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using ErsatzTV.Controllers.Api; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class ApiErrorResponseMetadataTests +{ + [TestCase(typeof(ChannelController), nameof(ChannelController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status404NotFound)] + [TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)] + [TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(CollectionController), nameof(CollectionController.Update), StatusCodes.Status404NotFound)] + [TestCase(typeof(CollectionController), nameof(CollectionController.Update), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(CollectionController), nameof(CollectionController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(CollectionController), nameof(CollectionController.Delete), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(CollectionController), nameof(CollectionController.AddItems), StatusCodes.Status404NotFound)] + [TestCase(typeof(CollectionController), nameof(CollectionController.AddItems), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(CollectionController), nameof(CollectionController.RemoveItem), StatusCodes.Status404NotFound)] + [TestCase(typeof(CollectionController), nameof(CollectionController.RemoveItem), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status404NotFound)] + [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status422UnprocessableEntity)] + public void Api_Error_Response_Metadata_Should_Document_ProblemDetails( + Type controllerType, + string actionName, + int statusCode) + { + MethodInfo action = controllerType.GetMethods().Single(m => m.Name == actionName); + + ProducesResponseTypeAttribute? metadata = action + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.StatusCode == statusCode); + + metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}"); + metadata.Type.ShouldBe(typeof(ProblemDetails)); + } +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index f6eed998d..b8cf0ec1f 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -2,6 +2,7 @@ using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; @@ -144,7 +145,24 @@ public class ChannelControllerTests IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeOfType(); + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + problemDetails.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task ResetPlayout_Should_Return_ProblemDetails_404_For_Missing_Channel() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.ResetPlayout("404"); + + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + problemDetails.Title.ShouldBe("Resource not found"); } private static ChannelViewModel MakeVm(int id) => diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs index 104135bdc..96a87014e 100644 --- a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs @@ -232,7 +232,10 @@ public class CollectionControllerTests IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeOfType(); + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + problemDetails.Title.ShouldBe("Resource not found"); } private static MediaCollectionViewModel MakeVm(int id, string name) => diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs new file mode 100644 index 000000000..2755a9a15 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class OpenApiErrorResponseContractTests +{ + [TestCase("/api/channels/{id}", "get", "404")] + [TestCase("/api/channels", "post", "404")] + [TestCase("/api/channels", "post", "422")] + [TestCase("/api/channels/{id}", "put", "404")] + [TestCase("/api/channels/{id}", "put", "422")] + [TestCase("/api/channels/{id}", "delete", "404")] + [TestCase("/api/channels/{id}", "delete", "422")] + [TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")] + [TestCase("/api/collections/{id}", "get", "404")] + [TestCase("/api/collections", "post", "404")] + [TestCase("/api/collections", "post", "422")] + [TestCase("/api/collections/{id}", "put", "404")] + [TestCase("/api/collections/{id}", "put", "422")] + [TestCase("/api/collections/{id}", "delete", "404")] + [TestCase("/api/collections/{id}", "delete", "422")] + [TestCase("/api/collections/{id}/items", "post", "404")] + [TestCase("/api/collections/{id}/items", "post", "422")] + [TestCase("/api/collections/{id}/items/{mediaItemId}", "delete", "404")] + [TestCase("/api/collections/{id}/items/{mediaItemId}", "delete", "422")] + [TestCase("/api/smart-collections/{id}", "get", "404")] + [TestCase("/api/smart-collections", "post", "404")] + [TestCase("/api/smart-collections", "post", "422")] + [TestCase("/api/smart-collections/{id}", "put", "404")] + [TestCase("/api/smart-collections/{id}", "put", "422")] + [TestCase("/api/smart-collections/{id}", "delete", "404")] + [TestCase("/api/smart-collections/{id}", "delete", "422")] + public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( + string path, + string method, + string statusCode) + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); + + JsonElement response = document.RootElement + .GetProperty("paths") + .GetProperty(path) + .GetProperty(method) + .GetProperty("responses") + .GetProperty(statusCode); + + JsonElement content = response.GetProperty("content"); + content.EnumerateObject().ShouldNotBeEmpty(); + + foreach (JsonProperty mediaType in content.EnumerateObject()) + { + string? schemaRef = mediaType.Value + .GetProperty("schema") + .GetProperty("$ref") + .GetString(); + + schemaRef.ShouldBe("#/components/schemas/ProblemDetails", mediaType.Name); + } + } + + private static string FindOpenApiDocument() + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + while (directory is not null) + { + string candidate = Path.Combine(directory.FullName, "ErsatzTV", "wwwroot", "openapi", "v1.json"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("Could not find ErsatzTV/wwwroot/openapi/v1.json"); + } +} diff --git a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs index 483337113..85a887861 100644 --- a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs @@ -188,7 +188,10 @@ public class SmartCollectionControllerTests IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeOfType(); + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + problemDetails.Title.ShouldBe("Resource not found"); } private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) diff --git a/ErsatzTV.Tests/Extensions/ApiResultsTests.cs b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs index 9170eeaac..0bd13692f 100644 --- a/ErsatzTV.Tests/Extensions/ApiResultsTests.cs +++ b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs @@ -19,6 +19,18 @@ public class ApiResultsTests result.ShouldBeOfType().StatusCode.ShouldBe(404); } + [Test] + public void ToErrorResult_Should_Return_ProblemDetails_For_NotFoundError() + { + IActionResult result = new NotFoundError("missing").ToErrorResult(); + + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + problemDetails.Title.ShouldBe("Resource not found"); + problemDetails.Detail.ShouldBe("missing"); + } + [Test] public void ToErrorResult_Should_Map_Other_Error_To_422() { @@ -26,6 +38,18 @@ public class ApiResultsTests result.ShouldBeOfType().StatusCode.ShouldBe(422); } + [Test] + public void ToErrorResult_Should_Return_ProblemDetails_For_Validation_Error() + { + IActionResult result = BaseError.New("bad").ToErrorResult(); + + var unprocessable = result.ShouldBeOfType(); + var problemDetails = unprocessable.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(422); + problemDetails.Title.ShouldBe("Validation failed"); + problemDetails.Detail.ShouldBe("bad"); + } + [Test] public void ToCreatedResult_Should_Return_201_With_Location_And_Body() { @@ -110,6 +134,18 @@ public class ApiResultsTests Option option = Option.None; IActionResult result = option.ToGetResult(); - result.ShouldBeOfType(); + result.ShouldBeOfType(); + } + + [Test] + public void ToGetResult_Should_Return_ProblemDetails_For_None() + { + Option option = Option.None; + IActionResult result = option.ToGetResult(); + + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + problemDetails.Title.ShouldBe("Resource not found"); } } diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 2ff273549..c31682c83 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -10,6 +10,7 @@ using ErsatzTV.Core.Scheduling; using ErsatzTV.Extensions; using ErsatzTV.Filters; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; @@ -30,6 +31,8 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Get a channel by id")] [EndpointGroupName("general")] + [ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetChannelById(id), cancellationToken); @@ -40,6 +43,9 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Create a channel")] [EndpointGroupName("general")] + [ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreateChannelRequest request, CancellationToken cancellationToken) @@ -53,7 +59,7 @@ public class ChannelController(ChannelWriter workerCh await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken); return channel.Match( Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm), - None: () => new NotFoundResult()); + None: () => ApiResults.NotFoundProblem()); }); } @@ -61,6 +67,9 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Update a channel")] [EndpointGroupName("general")] + [ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required] [FromBody] UpdateChannelRequest request, @@ -75,6 +84,9 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Delete a channel")] [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteChannel(id), cancellationToken); @@ -85,6 +97,8 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Reset channel playout")] [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task ResetPlayout(string channelNumber) { Option maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber)); @@ -94,6 +108,6 @@ public class ChannelController(ChannelWriter workerCh return new OkResult(); } - return new NotFoundResult(); + return ApiResults.NotFoundProblem(); } } diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs index 35f2a4ef9..b4e1871be 100644 --- a/ErsatzTV/Controllers/Api/CollectionController.cs +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -27,7 +27,7 @@ public class CollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Get a collection by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetCollectionById(id), cancellationToken); @@ -39,8 +39,8 @@ public class CollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Create a collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreateCollectionRequest request, CancellationToken cancellationToken) @@ -55,8 +55,8 @@ public class CollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Update a collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required] [FromBody] UpdateCollectionRequest request, @@ -71,7 +71,7 @@ public class CollectionController(IMediator mediator) : ControllerBase await mediator.Send(new GetCollectionById(id), cancellationToken); return collection.Match( Some: vm => (IActionResult)new OkObjectResult(vm), - None: () => new NotFoundResult()); + None: () => ApiResults.NotFoundProblem()); }); } @@ -80,8 +80,8 @@ public class CollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Delete a collection")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteCollection(id), cancellationToken); @@ -93,8 +93,8 @@ public class CollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Add items to a collection")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddItems( int id, [Required] [FromBody] AddItemsToCollectionRequest request, @@ -109,8 +109,8 @@ public class CollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Remove an item from a collection")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken) { Either result = await mediator.Send( diff --git a/ErsatzTV/Controllers/Api/SmartCollectionController.cs b/ErsatzTV/Controllers/Api/SmartCollectionController.cs index 9b983a8e5..794873a33 100644 --- a/ErsatzTV/Controllers/Api/SmartCollectionController.cs +++ b/ErsatzTV/Controllers/Api/SmartCollectionController.cs @@ -28,7 +28,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Get a smart collection by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = @@ -41,8 +41,8 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Create a smart collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreateSmartCollectionRequest request, CancellationToken cancellationToken) @@ -57,8 +57,8 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Update a smart collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required] [FromBody] UpdateSmartCollectionRequest request, @@ -74,7 +74,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase await mediator.Send(new GetSmartCollectionById(id), cancellationToken); return smartCollection.Match( Some: vm => (IActionResult)new OkObjectResult(vm), - None: () => new NotFoundResult()); + None: () => ApiResults.NotFoundProblem()); }); } @@ -83,8 +83,8 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase [EndpointSummary("Delete a smart collection")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken); diff --git a/ErsatzTV/Extensions/ApiResults.cs b/ErsatzTV/Extensions/ApiResults.cs index 8bd85137c..8ec9a331f 100644 --- a/ErsatzTV/Extensions/ApiResults.cs +++ b/ErsatzTV/Extensions/ApiResults.cs @@ -17,8 +17,8 @@ public static class ApiResults /// Maps a failure to 404 when it is a , otherwise 422. public static IActionResult ToErrorResult(this BaseError error) => error is NotFoundError - ? new NotFoundObjectResult(error.Value) - : new UnprocessableEntityObjectResult(error.Value); + ? new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", error.Value)) + : new UnprocessableEntityObjectResult(CreateProblemDetails(422, "Validation failed", error.Value)); /// Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422. public static IActionResult ToCreatedResult( @@ -45,5 +45,16 @@ public static class ApiResults public static IActionResult ToGetResult(this Option option) => option.Match( Some: value => (IActionResult)new OkObjectResult(value), - None: () => new NotFoundResult()); + None: () => NotFoundProblem()); + + public static IActionResult NotFoundProblem(string detail = "Resource not found") => + new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail)); + + private static ProblemDetails CreateProblemDetails(int status, string title, string detail) => + new() + { + Status = status, + Title = title, + Detail = detail + }; } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 349871770..018f82046 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -73,8 +73,65 @@ "required": true }, "responses": { - "200": { - "description": "OK" + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -99,7 +156,44 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, @@ -146,7 +240,64 @@ }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ChannelViewModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, @@ -167,8 +318,48 @@ } ], "responses": { - "200": { - "description": "OK" + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -192,6 +383,26 @@ "responses": { "200": { "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -1696,6 +1907,185 @@ "ChannelTranscodeMode": { "type": "integer" }, + "ChannelViewModel": { + "required": [ + "id", + "number", + "name", + "group", + "categories", + "fFmpegProfileId", + "slugSeconds", + "logo", + "streamSelectorMode", + "streamSelector", + "preferredAudioLanguageCode", + "preferredAudioTitle", + "playoutSource", + "playoutMode", + "mirrorSourceChannelId", + "playoutOffset", + "streamingMode", + "watermarkId", + "fallbackFillerId", + "playoutCount", + "preferredSubtitleLanguageCode", + "subtitleMode", + "musicVideoCreditsMode", + "musicVideoCreditsTemplate", + "songVideoMode", + "transcodeMode", + "idleBehavior", + "isEnabled", + "showInEpg" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "number": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "group": { + "type": [ + "null", + "string" + ] + }, + "categories": { + "type": [ + "null", + "string" + ] + }, + "fFmpegProfileId": { + "type": "integer", + "format": "int32" + }, + "slugSeconds": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "logo": { + "$ref": "#/components/schemas/ArtworkContentTypeModel" + }, + "streamSelectorMode": { + "$ref": "#/components/schemas/ChannelStreamSelectorMode" + }, + "streamSelector": { + "type": [ + "null", + "string" + ] + }, + "preferredAudioLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "preferredAudioTitle": { + "type": [ + "null", + "string" + ] + }, + "playoutSource": { + "$ref": "#/components/schemas/ChannelPlayoutSource" + }, + "playoutMode": { + "$ref": "#/components/schemas/ChannelPlayoutMode" + }, + "mirrorSourceChannelId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playoutOffset": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "streamingMode": { + "$ref": "#/components/schemas/StreamingMode" + }, + "watermarkId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fallbackFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playoutCount": { + "type": "integer", + "format": "int32" + }, + "preferredSubtitleLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "subtitleMode": { + "$ref": "#/components/schemas/ChannelSubtitleMode" + }, + "musicVideoCreditsMode": { + "$ref": "#/components/schemas/ChannelMusicVideoCreditsMode" + }, + "musicVideoCreditsTemplate": { + "type": [ + "null", + "string" + ] + }, + "songVideoMode": { + "$ref": "#/components/schemas/ChannelSongVideoMode" + }, + "transcodeMode": { + "$ref": "#/components/schemas/ChannelTranscodeMode" + }, + "idleBehavior": { + "$ref": "#/components/schemas/ChannelIdleBehavior" + }, + "isEnabled": { + "type": "boolean" + }, + "showInEpg": { + "type": "boolean" + }, + "webEncodedName": { + "type": [ + "null", + "string" + ] + } + } + }, "CollectionType": { "type": "integer" }, diff --git a/docs/rest-api.md b/docs/rest-api.md index 08ff709a8..2644232b1 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -82,12 +82,14 @@ POST /api/channels/{number}/playout/reset # exists; keep | Update success | **200 OK** + body | | Delete success | **204 No Content** | | Read success | **200 OK** | -| Resource not found | **404** | -| Validation failure (bad field, uniqueness, business rule) | **422 Unprocessable Entity** + error body | +| Resource not found | **404** + `ProblemDetails` body | +| Validation failure (bad field, uniqueness, business rule) | **422 Unprocessable Entity** + `ProblemDetails` body | | Malformed request (unparseable JSON, missing required) | **400** | The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapping helpers** (a deliberate, stated deviation — proper HTTP semantics matter for MCP/UI consumers). **Foundation decision (#2a):** to distinguish 404 from 422, handlers fold "does not exist" into `BaseError` today (flat). Introduce a lightweight typed error (e.g. `NotFoundError : BaseError`) in `ErsatzTV.Core`; mapping helper returns 404 for it, 422 for other `BaseError`s. This is a Core/error-layer change (not an EF model change) — test-before/after applies where it touches existing handlers. If it proves to sprawl, fall back to per-controller existence checks and backlog the typed-error refactor. +**Error body contract (#46):** `/docs` and `wwwroot/openapi/v1.json` are generated-client contracts for MCP/UI consumers, so runtime 404/422 bodies MUST match the OpenAPI `ProblemDetails` schema. API helpers return `ProblemDetails` with `status`, `title`, and `detail`; 404 uses title `Resource not found`, and 422 uses title `Validation failed`. Do not return plain string error bodies from REST API #2 endpoints. + ### 3.3 DTOs - **Requests:** dedicated request DTOs (bound from JSON) → mapped to existing Commands. Stable external contract; decouples the wire shape from internal command records. - **Responses:** **reuse existing `*ViewModel`s** (already the camelCase read contract used by current GETs). One contract, already proven.