feat(api): REST API foundation + Channels CRUD (#2a) #44

Merged
timothy merged 3 commits from worktree-rest-api-2a-foundation-channels into main 2026-06-27 16:14:15 +02:00
Owner

First slice of the REST API (ersatztv#2). Closes #34. Part of #2. Design: docs/rest-api.md.

Idiomatic REST over the existing MediatR handlers — thin controllers, no new business logic. Establishes the conventions the later slices (#35/#36/#37) copy.

Foundation

  • NotFoundError : BaseError (Core) to distinguish 404 from 422.
  • ApiResults mapping helpers over Either/Option → 201+Location / 200 / 204 / 404 / 422. Additive — existing *ToActionResult extensions untouched (IPTV etc. unaffected).
  • ApiKeyAuthorizationFilter: optional X-Api-Key on mutating actions, gated by config Api:WriteKey (no-op when unset, preserving current open LAN behavior). Fully independent of the IPTV JWT toggle — /iptv/* and all reads are untouched, so Jellyfin/Dispatcharr need no changes. Applied at controller-class level so every mutating action (incl. ResetPlayout) is covered fail-safe.

Channels

POST /api/channels (201+Location) · PUT /api/channels/{id} (200/404) · DELETE /api/channels/{id} (204/404) · GET /api/channels/{id} (200/404). Request DTOs → existing CreateChannel/UpdateChannel/DeleteChannel; responses reuse ChannelViewModel. Ported page-only validations into the handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + added FFmpegProfile/Watermark/Filler existence checks on update for parity with create.

How it was built

Implemented via an LSP-verified multi-agent workflow, then a 5-dimension adversarial review + fix pass. The review caught (and the fix closed) real issues the first implementation missed:

  • 🔴 HIGH: ResetPlayout write path bypassed the API-key filter → fixed (filter moved to class level + reflection-based regression test).
  • 🟡 MED×2: Group NotEmpty parity gap; FFmpegProfileId/Watermark/Filler existence not validated on update.
  • 🐛 Latent 500: the existing .Filter(c>0) existence pattern threw TaskCanceledException on not-found → rewritten to AnyAsync.
  • ⏸️ MED deferred → #43: a global default-deny model for all /api/* writers (other controllers stay open when a key is set; needs a designed scanner-callback exemption).

Notable side effect

ErsatzTV.Tests lacked Microsoft.NET.Test.Sdk + NSubstitute, so dotnet test was silently running zero tests (the pre-existing MultiSelectBaseTests never executed). Added the missing refs by name (CPM preserved). Split into its own commit (test: make ErsatzTV.Tests execute its tests).

Verification (independently re-run, post-rebase)

  • Build 0 warnings / 0 errors.
  • ErsatzTV.Tests 46/46 pass under TZ=UTC (38 new).
  • ErsatzTV.Architecture.Tests 5/5 (layering intact).
  • Rebased on current main (incl. #24 + #28).

Known low-severity (documented, not blocking)

  • GET /api/channels/{id} returns PlayoutCount=0 (pre-existing handler doesn't Include(Playouts)).
  • Successful POST has an unreachable None read-back branch returning bare 404.
  • Update-path coverage gaps for name / external-logo validation.

🤖 Generated with Claude Code

First slice of the REST API (ersatztv#2). **Closes #34. Part of #2.** Design: `docs/rest-api.md`. Idiomatic REST over the existing MediatR handlers — thin controllers, no new business logic. Establishes the conventions the later slices (#35/#36/#37) copy. ## Foundation - **`NotFoundError : BaseError`** (Core) to distinguish 404 from 422. - **`ApiResults`** mapping helpers over `Either`/`Option` → 201+`Location` / 200 / 204 / 404 / 422. Additive — existing `*ToActionResult` extensions untouched (IPTV etc. unaffected). - **`ApiKeyAuthorizationFilter`**: optional `X-Api-Key` on mutating actions, gated by config `Api:WriteKey` (no-op when unset, preserving current open LAN behavior). **Fully independent of the IPTV JWT toggle** — `/iptv/*` and all reads are untouched, so **Jellyfin/Dispatcharr need no changes**. Applied at controller-class level so every mutating action (incl. `ResetPlayout`) is covered fail-safe. ## Channels `POST /api/channels` (201+Location) · `PUT /api/channels/{id}` (200/404) · `DELETE /api/channels/{id}` (204/404) · `GET /api/channels/{id}` (200/404). Request DTOs → existing `CreateChannel`/`UpdateChannel`/`DeleteChannel`; responses reuse `ChannelViewModel`. Ported page-only validations into the handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + added FFmpegProfile/Watermark/Filler existence checks on update for parity with create. ## How it was built Implemented via an LSP-verified multi-agent workflow, then a **5-dimension adversarial review + fix pass**. The review caught (and the fix closed) real issues the first implementation missed: - 🔴 **HIGH:** `ResetPlayout` write path bypassed the API-key filter → fixed (filter moved to class level + reflection-based regression test). - 🟡 **MED×2:** `Group` NotEmpty parity gap; `FFmpegProfileId`/Watermark/Filler existence not validated on update. - 🐛 **Latent 500:** the existing `.Filter(c>0)` existence pattern threw `TaskCanceledException` on not-found → rewritten to `AnyAsync`. - ⏸️ **MED deferred → #43:** a *global* default-deny model for all `/api/*` writers (other controllers stay open when a key is set; needs a designed scanner-callback exemption). ## Notable side effect `ErsatzTV.Tests` lacked `Microsoft.NET.Test.Sdk` + `NSubstitute`, so `dotnet test` was silently running **zero** tests (the pre-existing `MultiSelectBaseTests` never executed). Added the missing refs by name (CPM preserved). Split into its own commit (`test: make ErsatzTV.Tests execute its tests`). ## Verification (independently re-run, post-rebase) - Build **0 warnings / 0 errors**. - `ErsatzTV.Tests` **46/46** pass under `TZ=UTC` (38 new). - `ErsatzTV.Architecture.Tests` **5/5** (layering intact). - Rebased on current `main` (incl. #24 + #28). ## Known low-severity (documented, not blocking) - `GET /api/channels/{id}` returns `PlayoutCount=0` (pre-existing handler doesn't `Include(Playouts)`). - Successful `POST` has an unreachable `None` read-back branch returning bare 404. - Update-path coverage gaps for name / external-logo validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 3 commits 2026-06-27 15:51:37 +02:00
Phase 1 sign-off landed. Idiomatic REST, dedicated API-key write auth
(decoupled from IPTV JWT toggle), 201/204/404/422 mapping + NotFoundError,
request DTOs in / ViewModels out. Sub-issues #34-#38 under tracker #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ErsatzTV.Tests had NUnit3TestAdapter + Shouldly but lacked Microsoft.NET.Test.Sdk and NSubstitute, so 'dotnet test' ran zero tests (no test host); the pre-existing MultiSelectBaseTests never executed. Add Microsoft.NET.Test.Sdk, NSubstitute, and Testably.Abstractions.Testing by name — versions already centralized in Directory.Packages.props (CPM preserved, no Version= attributes).

Surfaced while wiring tests for #34. Refs #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
8f7d240fc8
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>
timothy merged commit 8f7d240fc8 into main 2026-06-27 16:14:15 +02:00
timothy deleted branch worktree-rest-api-2a-foundation-channels 2026-06-27 16:14:15 +02:00
Sign in to join this conversation.