Files
ersatztv/docs/api-conventions.md
T
timothyandClaude Fable 5 d9faa8be7b docs: spec-follows-runtime naming decision + casing wart (#198)
- api-conventions.md §5a: runtime Newtonsoft casing vs generated spec, the
  schema transformer that mirrors it, and the contract test guarding it.
- decisions.md: append the "wire format is source of truth; spec follows via the
  real contract resolver" decision.
- spa-conventions.md §4: trust the generated key casing; note the removed
  troubleshooting escape hatch and runtime-cased test mocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:00:48 +02:00

191 lines
13 KiB
Markdown

# API conventions — "Add a REST endpoint" checklist
Purpose: a precise, file-path-and-exemplar checklist for adding or changing a `/api/*` endpoint on
the ErsatzTV fork, so an agent with no prior context in this repo can do it correctly on the first
pass. **Update this doc in the same PR that changes any convention below.**
This is a companion to `docs/contributing.md` (general CQRS/LanguageExt/testing conventions) and
`docs/ci-cd.md` (build/release pipeline) — read those first for anything not API-specific.
## 1. Controller shape
Controllers live in `ErsatzTV/Controllers/Api/*.cs`, one per domain (e.g. `TemplateController.cs`,
`BlockController.cs`, `LogsController.cs`). Every action needs this attribute set:
- `[ApiController]` on the class.
- `[HttpGet/Post/Put/Delete("/api/...")]` on the action, with `Name = "..."` on at least the
primary GET (used by the SPA's OpenAPI-generated client and by route-assertion tests).
- `[Tags("Domain")]` — groups the endpoint in Swagger UI / the SPA's generated client namespace.
- `[EndpointSummary("...")]` — one-line description; optionally `[EndpointDescription("...")]` for
more detail.
- `[EndpointGroupName("general")]`**REQUIRED**. Endpoints without it are excluded from the
generated OpenAPI document entirely (verified: every action in `TemplateController.cs` and
`LogsController.cs` has it; there is no other endpoint group in use).
- `[ProducesResponseType(typeof(X), StatusCodes.StatusYyy)]` for every status code the action can
actually return (200/201/204 success, plus 404/422 as applicable) — this drives the generated
TypeScript response types on the SPA side.
Exemplars:
- **Full CRUD controller**: `ErsatzTV/Controllers/Api/TemplateController.cs` — group CRUD +
item CRUD + copy, all four verbs, `ToCreatedResult`/`ToErrorResult` usage.
- **Paged GET with clamped params**: `ErsatzTV/Controllers/Api/LogsController.cs`
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
input for page math.
## 2. DTOs: where they live and their nullable context
- **Response DTOs**: `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs`. They are `record`s that mirror
the shape of the Application-layer ViewModel for that domain — **never expose a VM type directly**
from a controller. Convert framework types the SPA can't consume as-is (e.g. `CultureInfo`
its name string).
- Most response-model files start with `#nullable enable` (verified: 60 of 72 files under
`ErsatzTV.Core/Api/`, e.g. `ErsatzTV.Core/Api/Scheduling/BlockResponseModel.cs`,
`ErsatzTV.Core/Api/Settings/*ResponseModel.cs`). A minority of the simplest ones (e.g.
`FFmpegProfileResponseModel.cs`) have no pragma and rely on the project default. Check
`ErsatzTV.Core/ErsatzTV.Core.csproj` — the project sets `<Nullable>disable</Nullable>` — so
**add `#nullable enable` at the top of any new response-model file** that has an optional
(nullable) member; don't rely on the project default.
- **Request DTOs**: `ErsatzTV/Controllers/Api/Requests/*Request.cs`. No `#nullable enable` pragma —
match the existing files (e.g. `CreateFFmpegProfileRequest.cs`, `ReplaceScheduleItemsRequest.cs`).
A request record typically carries a `ToCommand()` (or `ToCommand(int parentId)`,
`ToReplaceCommand(int index)`) method that maps it to the Application-layer command type.
- **`ErsatzTV.Application` has no nullable context** (no `<Nullable>` = C# default `disable` for
that TFM in this repo — confirms CS8632 would otherwise fire) — do **not** add `?` nullable
annotations to types living there; that's a Core/Api-layer-only convention.
## 3. Error mapping
Central helper: `ErsatzTV/Extensions/ApiResults.cs`. Use these extension methods instead of
hand-rolling `IActionResult` status codes:
| Method | Input | Output |
|---|---|---|
| `ToErrorResult()` | `BaseError` | 404 if `NotFoundError`, else 422 (`ProblemDetails`) |
| `ToCreatedResult(location, body)` | `Either<BaseError, T>` | `Left``ToErrorResult()`; `Right` → 201 + `Location` header |
| `ToUpdatedResult()` | `Either<BaseError, T>` | `Left``ToErrorResult()`; `Right` → 200 + body |
| `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left``ToErrorResult()`; `Right` → 204 |
| `ToGetResult()` | `Option<T>` | `Some` → 200 + body; `None` → 404 |
| `ApiResults.NotFoundProblem(detail?)` | — | 404 `ProblemDetails` directly (e.g. when a controller has to pre-check existence itself, see `TemplateController.DeleteGroup`) |
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
**Handler-hardening checklist** when you touch (or add) a handler behind a new/changed endpoint:
- Missing FK existence checks that would otherwise 500 → convert to a `NotFoundError` (404) or a
validation `BaseError` (422).
- Dictionary-indexer lookups (`dict[key]`) that can throw `KeyNotFoundException` → guard or use
`TryGetValue`.
- Silent item filtering (e.g. `.Where(x => x.IsValid)` dropping bad rows without telling the
caller) → surface as 422 instead of silently returning a shorter list.
- Unbounded `int`/`TimeSpan` inputs from the request → clamp or validate, per the Logs pagination
pattern above.
- **Known, deliberate exception**: deep FK ids nested inside item-list request bodies (e.g. a
schedule item's `CollectionId`) are **not** existence-checked at that depth — this is established
precedent from the schedules endpoints (see issue #172) and intentional to avoid N+1 validation
queries; don't "fix" this without discussing it first.
## 4. Artwork contract
API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`,
`/artwork/thumbnails/...`, plus passthrough for `http://`/`https://` absolute URLs and for
Jellyfin/Emby proxy variants. This was established by PR #181 in
`ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs` (private `Artwork(...)`
helper, ~line 1330) — copy that helper's logic (or call a shared version of it) for any new
API surface that returns artwork paths. Comment in that file: *"Returns a rooted, directly-usable
artwork URL for the SPA's `<img src>`. Blazor pages rely on GetPosterUrl to prefix
`artwork/posters/`... but the SPA [needs it pre-rooted]."*
Do **not** reuse the Application-layer Mappers used by Blazor (e.g. `MediaCards`/`Television`
mappers) for new API DTOs — those still return the old Blazor-convention relative paths. Map from
the domain/VM directly and root the path yourself, following the PR #181 pattern.
## 5. OpenAPI regeneration — commit both generated artifacts
After any controller/DTO change:
1. `dotnet build ErsatzTV.sln` (normal build first).
2. `./scripts/update-openapi.sh` — runs `dotnet build -t:GenerateOpenApiDocuments` from `ErsatzTV/`,
regenerating `ErsatzTV/wwwroot/openapi/v1.json`.
3. `cd web && npm run generate:api` — runs `scripts/generate-openapi-types.mjs`, regenerating
`web/src/api/generated/v1.d.ts`.
**Commit both** `ErsatzTV/wwwroot/openapi/v1.json` and `web/src/api/generated/v1.d.ts` — the `.d.ts`
file is what the SPA actually imports (`web/src/api/*.ts` files do
`import type { components } from './generated/v1'`), and CI enforces it stays in sync
(`npm run check:api` = regenerate + `git diff --exit-code`).
**Known type-generation wart**: `DayOfWeek` serializes as an integer in the OpenAPI schema but the
runtime JSON payload is actually the enum's **name string** ("Sunday".."Saturday") — the generator
gets this wrong. The SPA works around it with a manual override type; see `web/src/api/playouts.ts`
(`type WithDayNames<T> = Omit<T, 'daysOfWeek'> & { daysOfWeek: DayOfWeek[] }`, applied to
`PlayoutAlternateSchedule`, `PlayoutTemplate`, and their request types). Copy this pattern for any
new DTO with a `DayOfWeek` (or `DayOfWeek[]`) member — don't trust the generated numeric type.
### 5a. Runtime JSON casing vs the generated spec (the `ffmpegProfileId` wart)
Runtime `/api/*` JSON is serialized by **Newtonsoft** (`AddNewtonsoftJson` in `Startup.cs`), using
`ErsatzTV/Serialization/CustomContractResolver.cs``CustomNamingStrategy` (camelCase **plus** a
special case mapping any `FFmpegProfileId` member to `"ffmpegProfileId"`, and honoring any
`[JsonProperty("...")]` attribute, e.g. `ChannelResponseModel.FFmpegProfile`
`[JsonProperty("ffmpegProfile")]`). The OpenAPI document, however, is generated from
**System.Text.Json** metadata, whose camelCase can differ (it emitted `fFmpegProfileId` /
`fFmpegProfile`). That drift silently gave the SPA the wrong key to read (issue #198).
Fix (do not remove): `ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs` is an OpenAPI
**schema transformer** registered on all three documents (`options.AddSchemaTransformer(...)` in
`Startup.cs`). For each object schema it resolves the CLR type's Newtonsoft `JsonObjectContract`
through the *same* `CustomContractResolver` the runtime uses and renames `schema.Properties` (and
`schema.Required`) keys to the exact names Newtonsoft would emit. This mirrors the wire format **by
construction**, so future naming-strategy special cases or `[JsonProperty]` renames can't drift.
Guard: `ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs` serializes fully-populated
DTOs (ChannelViewModel, FFmpegSettingsResponseModel, WatermarkViewModel,
MediaItemInfoResponseModel) through the runtime Newtonsoft settings and asserts the emitted top-level
keys equal the corresponding `v1.json` schema's property set. It fails if spec generation ever drifts
from the MVC serializer again. Note: only exact-match `FFmpegProfileId` gets the special case —
`DefaultFFmpegProfileId` stays `defaultFFmpegProfileId` under both serializers, and non-acronym or
single-leading-cap names (`fFmpegPath`, `fFprobePath`, `zIndex`, `rFrameRate`) already agree.
## 6. Tests
- **Controller tests**: `ErsatzTV.Tests/Controllers/<Domain>ControllerTests.cs`. NUnit + Shouldly +
NSubstitute (mock `IMediator`). Exemplar: `ErsatzTV.Tests/Controllers/TemplateControllerTests.cs`
— asserts every route via a `ShouldHaveActionRoute(actionName, verb, path)` helper (route-table
regression net), then per-action tests asserting the DTO shape returned and the exact
`mediator.Received(1).Send(Arg.Is<Command>(...))` call. `PlayoutControllerTests.cs` is the same
pattern for a larger, mixed-verb controller — use it as the template for a new controller with
many actions.
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): reflects over the
`ErsatzTV.Controllers.Api` namespace to find every concrete, `[ApiController]`-marked
controller class — no manual registry to maintain; a new controller is covered
automatically. (It intentionally does **not** filter on `ControllerBase`: several API
controllers — including the lone exempt `ScannerController` — do not derive from it, and a
`ControllerBase` filter would silently drop them.) The test walks every mutating (POST/PUT/PATCH/DELETE) action on each scanned
controller and asserts it's covered by the global `ApiKeyAuthorizationFilter` (or has an
explicit `[SkipApiKeyAuthorizationAttribute]` exemption; only `ScannerController` is exempt
today). A minimum-count guard asserts the scan found a sane number of controllers, so a
namespace rename can't silently make the scan match nothing and give this test a false pass.
- **Handler tests** (business logic behind the controller) use the shared in-memory SQLite fixture:
`ErsatzTV.Tests/Support/InMemoryTvContext.cs`. Pattern: `SqliteConnection("Data Source=:memory:;Foreign
Keys=False")` kept open for the fixture's lifetime, `EnsureCreatedAsync()` (not full migration
replay), then `PRAGMA foreign_keys=OFF` so partial object graphs can be seeded without satisfying
every FK. Exemplar: `ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs`
(`_db = await InMemoryTvContext.CreateAsync();` in `[SetUp]`, `_db.CreateContext()` per test body,
`_db.Factory` where an `IDbContextFactory<TvContext>` is needed by the handler under test).
## 7. PUT-replace list endpoints
For "replace the whole list" endpoints (PUT semantics over a collection, e.g. schedule/template
items), index items from **array order** in the request body rather than trusting a client-supplied
index/order field. Exemplar: `ReplaceScheduleItemsRequest.ToCommand(scheduleId)`
`Items.Select((item, index) => item.ToReplaceCommand(index))`.
## 8. Known API warts (don't "fix" without discussion — they're deliberate synthesized rows)
`GET /api/blocks` and `GET /api/templates` (via `GetAllBlocksHandler` / `GetAllTemplatesHandler` in
`ErsatzTV.Application/Scheduling/Queries/`) synthesize a fake **negative-id "(none)" group row**
for items that have no group, so the SPA can render an "ungrouped" bucket
(`Id = unusedGroup.Id * -1`, `Name = "(none)"`). See issue #172. If you add a similar "ungrouped"
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
reliable real-entity id for those synthetic rows.