fix(api): standardize error response contract #47

Merged
timothy merged 1 commits from issue46-rest-api-error-contract into main 2026-06-28 16:37:54 +02:00
12 changed files with 647 additions and 37 deletions
@@ -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<ProducesResponseTypeAttribute>(inherit: true)
.SingleOrDefault(a => a.StatusCode == statusCode);
metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}");
metadata.Type.ShouldBe(typeof(ProblemDetails));
}
}
@@ -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<NotFoundResult>();
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
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<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
IActionResult result = await _controller.ResetPlayout("404");
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
private static ChannelViewModel MakeVm(int id) =>
@@ -232,7 +232,10 @@ public class CollectionControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundResult>();
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
private static MediaCollectionViewModel MakeVm(int id, string name) =>
@@ -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");
}
}
@@ -188,7 +188,10 @@ public class SmartCollectionControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundResult>();
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
+37 -1
View File
@@ -19,6 +19,18 @@ public class ApiResultsTests
result.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
}
[Test]
public void ToErrorResult_Should_Return_ProblemDetails_For_NotFoundError()
{
IActionResult result = new NotFoundError("missing").ToErrorResult();
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
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<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
}
[Test]
public void ToErrorResult_Should_Return_ProblemDetails_For_Validation_Error()
{
IActionResult result = BaseError.New("bad").ToErrorResult();
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problemDetails = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
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<string> option = Option<string>.None;
IActionResult result = option.ToGetResult();
result.ShouldBeOfType<NotFoundResult>();
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public void ToGetResult_Should_Return_ProblemDetails_For_None()
{
Option<string> option = Option<string>.None;
IActionResult result = option.ToGetResult();
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
}
+16 -2
View File
@@ -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<IBackgroundServiceRequest> workerCh
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
@@ -40,6 +43,9 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> 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<IActionResult> Create(
[Required] [FromBody] CreateChannelRequest request,
CancellationToken cancellationToken)
@@ -53,7 +59,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> 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<IBackgroundServiceRequest> 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<IActionResult> Update(
int id,
[Required] [FromBody] UpdateChannelRequest request,
@@ -75,6 +84,9 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannel(id), cancellationToken);
@@ -85,6 +97,8 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ResetPlayout(string channelNumber)
{
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
@@ -94,6 +108,6 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
return new OkResult();
}
return new NotFoundResult();
return ApiResults.NotFoundProblem();
}
}
@@ -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<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> 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<IActionResult> 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<IActionResult> RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
@@ -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<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<SmartCollectionViewModel> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
+14 -3
View File
@@ -17,8 +17,8 @@ public static class ApiResults
/// <summary>Maps a failure to 404 when it is a <see cref="NotFoundError" />, otherwise 422.</summary>
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));
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToCreatedResult<TR>(
@@ -45,5 +45,16 @@ public static class ApiResults
public static IActionResult ToGetResult<T>(this Option<T> 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
};
}
+396 -6
View File
@@ -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"
},
+4 -2
View File
@@ -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.