fix(api): standardize error response contract #47
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user