docs(api): document collection REST response codes
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Refs #35
This commit is contained in:
2026-06-28 09:03:59 +02:00
parent 2f306ce59f
commit 82f3a1e633
4 changed files with 599 additions and 16 deletions
@@ -1,3 +1,4 @@
using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
@@ -7,6 +8,7 @@ using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -28,6 +30,21 @@ public class CollectionControllerTests
_controller = new CollectionController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/collections");
ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/collections/{id:int}");
ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/collections");
ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/collections/{id:int}");
ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/collections/{id:int}");
ShouldHaveActionRoute(nameof(CollectionController.AddItems), "POST", "/api/collections/{id:int}/items");
ShouldHaveActionRoute(
nameof(CollectionController.RemoveItem),
"DELETE",
"/api/collections/{id:int}/items/{mediaItemId:int}");
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -220,4 +237,14 @@ public class CollectionControllerTests
private static MediaCollectionViewModel MakeVm(int id, string name) =>
new(CollectionType.Collection, id, name, false, MediaItemState.Normal);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(CollectionController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -5,6 +5,7 @@ using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
@@ -17,6 +18,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Get all collections")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<MediaCollectionViewModel>), StatusCodes.Status200OK)]
public async Task<List<MediaCollectionViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllCollections(), cancellationToken);
@@ -24,6 +26,8 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Get a collection by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> result = await mediator.Send(new GetCollectionById(id), cancellationToken);
@@ -34,6 +38,9 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Create a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateCollectionRequest request,
CancellationToken cancellationToken)
@@ -47,6 +54,9 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Update a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateCollectionRequest request,
@@ -69,6 +79,9 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Delete a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteCollection(id), cancellationToken);
@@ -79,6 +92,9 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Add items to a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> AddItems(
int id,
[Required] [FromBody] AddItemsToCollectionRequest request,
@@ -92,6 +108,9 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Remove an item from a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
@@ -18,6 +19,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
[Tags("Smart Collections")]
[EndpointSummary("Get all smart collections")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<SmartCollectionResponseModel>), StatusCodes.Status200OK)]
public async Task<List<SmartCollectionResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllSmartCollectionsForApi(), cancellationToken);
@@ -25,6 +27,8 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
[Tags("Smart Collections")]
[EndpointSummary("Get a smart collection by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<SmartCollectionViewModel> result =
@@ -36,6 +40,9 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
[Tags("Smart Collections")]
[EndpointSummary("Create a smart collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateSmartCollectionRequest request,
CancellationToken cancellationToken)
@@ -49,6 +56,9 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
[Tags("Smart Collections")]
[EndpointSummary("Update a smart collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateSmartCollectionRequest request,
@@ -72,6 +82,9 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
[Tags("Smart Collections")]
[EndpointSummary("Delete a smart collection")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
+540 -16
View File
@@ -265,8 +265,65 @@
"required": true
},
"responses": {
"200": {
"description": "OK"
"201": {
"description": "Created",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
}
}
},
"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"
}
}
}
}
}
}
@@ -291,7 +348,44 @@
],
"responses": {
"200": {
"description": "OK"
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
}
}
},
"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"
}
}
}
}
}
},
@@ -338,7 +432,64 @@
},
"responses": {
"200": {
"description": "OK"
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MediaCollectionViewModel"
}
}
}
},
"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"
}
}
}
}
}
},
@@ -359,8 +510,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"
}
}
}
}
}
}
@@ -408,8 +599,48 @@
"required": true
},
"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"
}
}
}
}
}
}
@@ -441,8 +672,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"
}
}
}
}
}
}
@@ -879,8 +1150,65 @@
"required": true
},
"responses": {
"200": {
"description": "OK"
"201": {
"description": "Created",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
}
}
},
"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"
}
}
}
}
}
}
@@ -905,7 +1233,44 @@
],
"responses": {
"200": {
"description": "OK"
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
}
}
},
"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"
}
}
}
}
}
},
@@ -952,7 +1317,64 @@
},
"responses": {
"200": {
"description": "OK"
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SmartCollectionViewModel"
}
}
}
},
"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"
}
}
}
}
}
},
@@ -973,8 +1395,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"
}
}
}
}
}
}
@@ -1908,6 +2370,42 @@
"LoudNorm"
]
},
"ProblemDetails": {
"type": "object",
"properties": {
"type": {
"type": [
"null",
"string"
]
},
"title": {
"type": [
"null",
"string"
]
},
"status": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"detail": {
"type": [
"null",
"string"
]
},
"instance": {
"type": [
"null",
"string"
]
}
}
},
"ResolutionViewModel": {
"required": [
"id",
@@ -1992,6 +2490,32 @@
}
}
},
"SmartCollectionViewModel": {
"required": [
"id",
"name",
"query"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": [
"null",
"string"
]
},
"query": {
"type": [
"null",
"string"
]
}
}
},
"StreamingMode": {
"type": "integer"
},