Files
ersatztv/docs/rest-api.md
timothy c113142a3d
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(api): standardize error response contract
refs #46
2026-06-28 14:58:48 +02:00

147 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# REST API Design — ersatztv#2
**Status:** Phase 1 signed off → Phase 2 in progress.
**Tracker:** ersatztv#2. **Sub-issues:** #34 (#2a), #35 (#2b), #36 (#2c), #37 (#2d), #38 (#2e). **Handoff:** `docs/handoffs/rest-api.md`.
## 1. Goals
1. **Programmatic CRUD** for channels / collections / schedules / playouts via REST — enabling **MCP** and direct API automation.
2. **Foundation for a new UI** built on top of the API (it replaces read paths too, not just writes).
3. **Retire fragile direct-SQLite writes.** All mutations route through EF Core + existing domain validation, returning proper HTTP codes — no more TPT/enum/NOT-NULL footguns, no container-stop-for-writes.
These goals push three things to the front: **OpenAPI is in-scope** (MCP tool-gen + UI codegen), **stable DTO contracts**, and a **programmatic auth** story.
## 2. What the investigation found (code-grounded)
### 2.1 No migration needed
Pure CRUD over existing tables. The full schema — including the four `ProgramScheduleItem` **TPT subtype tables** (`ProgramScheduleOneItem`, `ProgramScheduleMultipleItem`, `ProgramScheduleFloodItem`, `ProgramScheduleDurationItem`, via `.UseTptMappingStrategy()`) — already exists. The `migrations` CI job will stay green; no `scripts/add-migration.sh` run is required for any slice.
### 2.2 Every command already exists
All four resources already have MediatR Create/Update/Delete (+ item ops) handlers, **almost all returning `Either<BaseError, T>`** — which maps cleanly to HTTP. The API is mostly **thin controllers over existing handlers**, not new business logic.
| Resource | Create | Update | Delete | Item ops |
|---|---|---|---|---|
| **Channels** | `CreateChannel` → `Either<_, CreateChannelResult>` | `UpdateChannel` → `Either<_, ChannelViewModel>` | `DeleteChannel` → `Either<_, Unit>` | — |
| **Collections** | `CreateCollection` → `Either<_, MediaCollectionViewModel>` | `UpdateCollection` → `Either<_, Unit>` | `DeleteCollection` → `Either<_, Unit>` | `AddItemsToCollection`, `RemoveItemsFromCollection` |
| **Schedules** | `CreateProgramSchedule` → `Either<_, CreateProgramScheduleResult>` | `UpdateProgramSchedule` → `Either<_, UpdateProgramScheduleResult>` | `DeleteProgramSchedule` → `Either<_, Unit>` | `AddProgramScheduleItem`, `ReplaceProgramScheduleItems` (bulk) |
| **Playouts** | `CreateClassic/Block/Sequential/Scripted/ExternalJsonPlayout` → `Either<_, CreatePlayoutResponse>` | `UpdatePlayout` → `Either<_, PlayoutNameViewModel>` | `DeletePlayout` → `Either<_, Unit>` | reset (exists, via worker channel) |
Net-new command work is essentially **zero** for the happy paths. Effort is in controllers, DTOs, status-code mapping, auth, and tests.
### 2.3 Validation already lives in the handlers (mostly)
The substantive rules are in-handler, so the API inherits them:
- Channel **number uniqueness** (`CreateChannelHandler.cs:137`, `UpdateChannelHandler.cs:234` excludes self), name/FK-exists, mirror-source validity.
- Collection / schedule **name uniqueness** + length.
- Playout business rules: **"Channel already has one playout"** (`CreateClassicPlayoutHandler.cs:77`), **"Program schedule must have items"** (`:93`).
A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Validators/`) and would NOT reach the API — port these into the handlers (with test-before/after):
- Channel: `ShowInEpg` must be false when `!IsEnabled`; external-logo-URL format. (`ChannelEditViewModelValidator.cs`)
- Playout: required-field shape per kind. (`PlayoutEditViewModelValidator.cs`) — mostly redundant with handler checks; confirm parity.
### 2.4 Existing controller conventions (match these)
- Controllers in `ErsatzTV/Controllers/Api/`, base `ControllerBase`, **routes declared per-action** (full path, no controller `[Route]` prefix).
- JSON: **Newtonsoft**, **camelCase** (`CustomNamingStrategy`, with `FFmpegProfileId` special-cased), **enums as strings** (`StringEnumConverter`), `NullValueHandling.Ignore` (`Startup.cs:294`).
- **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`.
- `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.**
### 2.5 Two existing CRUD controllers are non-idiomatic → standardize
`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests.
### 2.6 Auth today
A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**.
## 3. Conventions (decided)
### 3.1 Routing — idiomatic REST
```
POST /api/channels GET /api/channels GET /api/channels/{id}
PUT /api/channels/{id} DELETE /api/channels/{id}
POST /api/collections GET /api/collections GET /api/collections/{id}
PUT /api/collections/{id} DELETE /api/collections/{id}
POST /api/collections/{id}/items
DELETE /api/collections/{id}/items/{mediaItemId}
POST /api/schedules GET /api/schedules GET /api/schedules/{id}
PUT /api/schedules/{id} DELETE /api/schedules/{id}
GET /api/schedules/{id}/items
POST /api/schedules/{id}/items
PUT /api/schedules/{id}/items # bulk replace (maps to ReplaceProgramScheduleItems)
DELETE /api/schedules/{id}/items/{itemId}
POST /api/playouts GET /api/playouts GET /api/playouts/{id}
DELETE /api/playouts/{id}
POST /api/channels/{number}/playout/reset # exists; keep
```
`{id}` = numeric entity id. Channel reset keeps its existing `{number}` route (already public).
### 3.2 Status codes
| Outcome | Code |
|---|---|
| Create success | **201 Created** + `Location` header + body |
| Update success | **200 OK** + body |
| Delete success | **204 No Content** |
| Read success | **200 OK** |
| 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.
### 3.4 Validation
Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers.
### 3.5 Auth — dedicated API key for writes (decoupled from IPTV)
**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open.
**Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled` ← `JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr.
**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern.
**Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN.
### 3.6 OpenAPI / discoverability
New endpoints carry `[EndpointGroupName("general")]` → appear in the existing `v1` doc + Scalar `/docs`. This *is* the MCP/UI enabler (tool-gen + client codegen). Keep summaries/`[ProducesResponseType]` accurate per action.
### 3.7 Other conventions
- **Absolute URLs** in responses (logos/streams) use the **request-derived host**, same as M3U/XMLTV (`docs/m3u-xmltv.md`) — never bake a host. (Carries the #1 lesson forward.)
- **List endpoints:** add simple pagination where the underlying query supports it; defer rich filtering/sorting (backlog — the new UI will want it).
- **DELETE idempotency:** return 404 if the entity is already absent (clear over silently-204). EF cascade handles dependents (e.g. Channel→Playout cascade, ProgramSchedule→Items cascade) — integration tests prove this per slice.
## 4. Standardization scope
- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green).
- Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code.
## 5. Increment plan (sub-issues under #2 as tracker)
One slice = one branch = one PR. PR runs `test` + `migrations` (both required); merge to `main` adds `build` + smoke/E2E. Tests with **NUnit + Shouldly + NSubstitute**, run locally `TZ=UTC`.
- **#2a (#34) — API foundation + Channels (pattern-setter).** Shared conventions: idiomatic routing, status-code mapping helpers (incl. `NotFoundError` typed error + 201/422), request-DTO pattern, response = ViewModels, `[EndpointGroupName("general")]`, API-key mutation auth filter (decoupled from the IPTV JWT toggle). Then Channels CRUD (POST/PUT/DELETE, confirm/standardize GET list + add GET `/{id}`). Port page-only channel validations into handlers. Tests: handler unit (success + 404/422), controller status-mapping, create→read→delete EF integration.
- **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first).
- **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written.
- **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong.
- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d.
**Sequencing:** #2a first (sets every convention the others copy), then #2b–#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally.
## 6. Testing strategy (per slice)
- Handler unit tests: success + each failure (not-found→404, validation→422).
- Controller tests: status-code/`Location`/DTO mapping.
- EF integration test (real SQLite): create→read→delete proving TPT subtype creation + cascade correctness.
- Characterization tests before any existing-controller retrofit.
- Lean on existing nets: architecture tests (#12 — controllers in `ErsatzTV`, logic in `ErsatzTV.Application`), M3U goldens (#11) if a change touches `ToM3U`.
## 7. Open items / backlog seeds
- CORS tightening for mutation routes (if exposed beyond LAN).
- List-endpoint filtering/sorting/pagination depth (new-UI driven).
- API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`).
- Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local.
```