feat(api): REST foundation + Channels CRUD (#34)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m41s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m41s
First slice of the REST API (ersatztv#2). Idiomatic REST over existing MediatR handlers; design in docs/rest-api.md.
Foundation: NotFoundError : BaseError (Core) to split 404 from 422; ApiResults mapping helpers (Either/Option -> 201+Location / 200 / 204 / 404 / 422), additive (existing *ToActionResult untouched); ApiKeyAuthorizationFilter (optional X-Api-Key on mutating actions, gated by Api:WriteKey, no-op when unset) — independent of the IPTV JWT toggle so Jellyfin/Dispatcharr reads are unaffected, applied at controller-class level so every mutating action incl. ResetPlayout is covered fail-safe.
Channels: POST /api/channels (201+Location), PUT/{id} (200/404), DELETE/{id} (204/404), GET/{id} (200/404). Request DTOs -> existing commands; responses reuse ChannelViewModel. Ported page-only validations into handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + FFmpegProfile/Watermark/Filler existence on update for create-parity. Fixed a latent 500: the .Filter(c>0) existence pattern threw TaskCanceledException on not-found; rewritten to AnyAsync.
Tests (NUnit, TZ=UTC): handler success/404/422, ApiResults mapping, API-key filter, controller status/Location/mapping, a controller-security regression net, and a create->read->delete EF integration test on a new in-memory SQLite harness. ErsatzTV.Tests 46/46, Architecture 5/5.
Refs #34
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #44.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
using System.Threading.Channels;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -11,12 +15,72 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
// Apply the optional API-key control at the controller level so that EVERY mutating action
|
||||
// (including future ones) is covered by default; the filter no-ops on read methods (GET) and
|
||||
// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer
|
||||
// adding a new write endpoint here cannot accidentally leave it unauthenticated.
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
|
||||
{
|
||||
[HttpGet("/api/channels")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Create a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateChannelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateChannelResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async created =>
|
||||
{
|
||||
Option<ChannelViewModel> channel =
|
||||
await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
|
||||
return channel.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
|
||||
None: () => new NotFoundResult());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/channels/{id:int}")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Update a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateChannelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToUpdatedResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/channels/{id:int}")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Delete a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannel(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
@@ -32,24 +96,4 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
// for debugging by fast-forwarding a playout
|
||||
// [HttpPost("/api/channels/{channelNumber}/playout/continue")]
|
||||
// public async Task<IActionResult> ContinuePlayout(string channelNumber, [FromQuery] int days = 1)
|
||||
// {
|
||||
// Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
|
||||
// foreach (int playoutId in maybePlayoutId)
|
||||
// {
|
||||
// DateTimeOffset start = DateTimeOffset.Now;
|
||||
// for (int i = 0; i < 24 * days; i++)
|
||||
// {
|
||||
// await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue, start));
|
||||
// start += TimeSpan.FromHours(1);
|
||||
// }
|
||||
//
|
||||
// return new OkResult();
|
||||
// }
|
||||
//
|
||||
// return new NotFoundResult();
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// JSON request body for creating a channel. Mirrors <see cref="CreateChannel" />; decoupling the
|
||||
/// API contract from the MediatR command keeps the wire format stable as the command evolves.
|
||||
/// </summary>
|
||||
public record CreateChannelRequest(
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
{
|
||||
public CreateChannel ToCommand() =>
|
||||
new(
|
||||
Name,
|
||||
Number,
|
||||
Group,
|
||||
Categories,
|
||||
FFmpegProfileId,
|
||||
SlugSeconds,
|
||||
Logo,
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle,
|
||||
PlayoutSource,
|
||||
PlayoutMode,
|
||||
MirrorSourceChannelId,
|
||||
PlayoutOffset,
|
||||
StreamingMode,
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate,
|
||||
SongVideoMode,
|
||||
TranscodeMode,
|
||||
IdleBehavior,
|
||||
IsEnabled,
|
||||
ShowInEpg);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// JSON request body for updating a channel. The channel id comes from the route, not the body;
|
||||
/// all other fields mirror <see cref="UpdateChannel" />.
|
||||
/// </summary>
|
||||
public record UpdateChannelRequest(
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
{
|
||||
public UpdateChannel ToCommand(int channelId) =>
|
||||
new(
|
||||
channelId,
|
||||
Name,
|
||||
Number,
|
||||
Group,
|
||||
Categories,
|
||||
FFmpegProfileId,
|
||||
SlugSeconds,
|
||||
Logo,
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle,
|
||||
PlayoutSource,
|
||||
PlayoutMode,
|
||||
MirrorSourceChannelId,
|
||||
PlayoutOffset,
|
||||
StreamingMode,
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate,
|
||||
SongVideoMode,
|
||||
TranscodeMode,
|
||||
IdleBehavior,
|
||||
IsEnabled,
|
||||
ShowInEpg);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// REST status-code mapping helpers for the JSON write/read API (slice #2a).
|
||||
/// These are additive and must not change the behavior of the existing
|
||||
/// <see cref="EitherToActionResult" /> / <see cref="OptionToActionResult" /> helpers,
|
||||
/// which IptvController and other read endpoints depend on.
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "VSTHRD003")]
|
||||
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);
|
||||
|
||||
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
|
||||
public static IActionResult ToCreatedResult<TR>(
|
||||
this Either<BaseError, TR> either,
|
||||
Func<TR, string> location,
|
||||
Func<TR, object> body) =>
|
||||
either.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: value => new CreatedResult(location(value), body(value)));
|
||||
|
||||
/// <summary>Right: 200 with body; Left: 404 (NotFound) or 422.</summary>
|
||||
public static IActionResult ToUpdatedResult<TR>(this Either<BaseError, TR> either) =>
|
||||
either.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: value => (IActionResult)new OkObjectResult(value));
|
||||
|
||||
/// <summary>Right(Unit): 204 No Content; Left: 404 (NotFound) or 422.</summary>
|
||||
public static IActionResult ToDeletedResult(this Either<BaseError, Unit> either) =>
|
||||
either.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: _ => (IActionResult)new NoContentResult());
|
||||
|
||||
/// <summary>Some: 200 with body; None: 404.</summary>
|
||||
public static IActionResult ToGetResult<T>(this Option<T> option) =>
|
||||
option.Match(
|
||||
Some: value => (IActionResult)new OkObjectResult(value),
|
||||
None: () => new NotFoundResult());
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Optional API-key authorization for mutating JSON API endpoints (slice #2a).
|
||||
/// Reads the configured key from <c>Api:WriteKey</c>. When that key is empty the filter is
|
||||
/// a no-op (preserving the current open LAN behavior); when it is set, mutating requests
|
||||
/// (POST/PUT/PATCH/DELETE) must present a matching <c>X-Api-Key</c> header or receive 401.
|
||||
/// This is fully independent of <see cref="JwtHelper" /> and only applies to the actions it
|
||||
/// decorates — it never affects /iptv/* or any read endpoint.
|
||||
/// </summary>
|
||||
public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter
|
||||
{
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
public const string ConfigurationKey = "Api:WriteKey";
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
string configuredKey = configuration[ConfigurationKey];
|
||||
|
||||
// empty key => API-key auth disabled, endpoint is open
|
||||
if (string.IsNullOrEmpty(configuredKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string method = context.HttpContext.Request.Method;
|
||||
bool isMutating = HttpMethods.IsPost(method)
|
||||
|| HttpMethods.IsPut(method)
|
||||
|| HttpMethods.IsPatch(method)
|
||||
|| HttpMethods.IsDelete(method);
|
||||
|
||||
if (!isMutating)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided)
|
||||
|| !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal))
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,9 @@ public class Startup
|
||||
|
||||
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
|
||||
|
||||
// optional API-key authorization for mutating JSON API endpoints (independent of JWT/OIDC)
|
||||
services.AddScoped<ApiKeyAuthorizationFilter>();
|
||||
|
||||
services.AddFluentValidationAutoValidation();
|
||||
services.AddValidatorsFromAssemblyContaining<Startup>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user