# 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 Existing CRUD controllers are standardized
`SmartCollectionController` and `FFmpegProfileController` previously used verb-in-path routes (`/new`,
`/update`, `/delete/{id}`). They have been retrofitted to idiomatic REST routes as part of the REST #2
slice work, gated by characterization tests and the shared `ProblemDetails` error contract.

### 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/v1/channels            GET /api/v1/channels        GET /api/v1/channels/{id}
PUT    /api/v1/channels/{id}       DELETE /api/v1/channels/{id}

POST   /api/v1/collections         GET /api/v1/collections     GET /api/v1/collections/{id}
PUT    /api/v1/collections/{id}    DELETE /api/v1/collections/{id}
POST   /api/v1/collections/{id}/items
DELETE /api/v1/collections/{id}/items/{mediaItemId}

POST   /api/v1/schedules           GET /api/v1/schedules       GET /api/v1/schedules/{id}
PUT    /api/v1/schedules/{id}      DELETE /api/v1/schedules/{id}
GET    /api/v1/schedules/{id}/items
POST   /api/v1/schedules/{id}/items
PUT    /api/v1/schedules/{id}/items          # bulk replace (maps to ReplaceProgramScheduleItems)
DELETE /api/v1/schedules/{id}/items/{itemId}

POST   /api/v1/playouts            GET /api/v1/playouts        GET /api/v1/playouts/{id}
DELETE /api/v1/playouts/{id}
POST   /api/v1/channels/{id}/playout/reset        # re-keyed {number} → {id} (#197 Bundle C)
```
`{id}` = numeric entity id. Channel reset is keyed on `{id}` like every other single-item admin route
(re-keyed from `{number}` in #197 Bundle C — `Number` is user-mutable, so it's unusable as a stable
resource key; broadcast surfaces keep `{number}`. See `docs/decisions.md` 2026-07-12).

### 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 — `Api:WriteKey` / `Api__WriteKey`,
checked against the `X-Api-Key` request header by a global MVC filter. When the key is configured,
all mutating `/api/*` requests (`POST`/`PUT`/`PATCH`/`DELETE`) require the header; when the key is
unset, the LAN-open default is preserved. Reads and all `/iptv/*` routes stay open. The scanner
callback controller is the designed exemption because scanner child processes call
`/api/v1/scan/{scanId}/...` without `X-Api-Key`; any future exemption must be explicit via
`[SkipApiKeyAuthorization]`.

**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 global 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). Completed in #35/#38.
- CORS review in #38: the app still uses the existing global `AllowAll` policy. REST mutations are protected by the optional API-key write filter, and changing CORS defaults would be an operational exposure decision rather than an API-shape cleanup. Keep CORS tightening as backlog if write APIs are exposed beyond the LAN.
- Sweep result in #38: REST #2 CRUD controllers now use idiomatic routes. Older operational endpoints such as `/api/v1/libraries/{id}/scan`, `/api/v1/maintenance/empty_trash`, and `/api/v1/maintenance/clean_artwork` remain outside the REST #2 CRUD standardization scope.
- 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. Completed: FFmpeg profile CRUD now uses `/api/v1/ffmpeg/profiles[/{id}]`, request DTOs, API-key write filtering, `ProblemDetails` 404/422 responses, and generated OpenAPI metadata. Deferred: configurable CORS tightening and legacy operational endpoint reshaping.

**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).
- Legacy operational `/api` command routes (`libraries/*/scan`, maintenance actions) still use older action-style names. They are outside REST #2 CRUD and should be handled in a separate operational API cleanup if needed.
- 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.
```
