Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
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, withName = "..."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 inTemplateController.csandLogsController.cshas 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/ToErrorResultusage. - Paged GET with clamped params:
ErsatzTV/Controllers/Api/LogsController.cs—pageNumclamped viaMath.Max(0, pageNum),pageSizeviaMath.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 arerecords 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 underErsatzTV.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. CheckErsatzTV.Core/ErsatzTV.Core.csproj— the project sets<Nullable>disable</Nullable>— so add#nullable enableat the top of any new response-model file that has an optional (nullable) member; don't rely on the project default.
- Most response-model files start with
- Request DTOs:
ErsatzTV/Controllers/Api/Requests/*Request.cs. No#nullable enablepragma — match the existing files (e.g.CreateFFmpegProfileRequest.cs,ReplaceScheduleItemsRequest.cs). A request record typically carries aToCommand()(orToCommand(int parentId),ToReplaceCommand(int index)) method that maps it to the Application-layer command type. ErsatzTV.Applicationhas no nullable context (no<Nullable>= C# defaultdisablefor 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 validationBaseError(422). - Dictionary-indexer lookups (
dict[key]) that can throwKeyNotFoundException→ guard or useTryGetValue. - 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/TimeSpaninputs 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:
dotnet build ErsatzTV.sln(normal build first)../scripts/update-openapi.sh— runsdotnet build -t:GenerateOpenApiDocumentsfromErsatzTV/, regeneratingErsatzTV/wwwroot/openapi/v1.json.cd web && npm run generate:api— runsscripts/generate-openapi-types.mjs, regeneratingweb/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.
6. Tests
- Controller tests:
ErsatzTV.Tests/Controllers/<Domain>ControllerTests.cs. NUnit + Shouldly + NSubstitute (mockIMediator). Exemplar:ErsatzTV.Tests/Controllers/TemplateControllerTests.cs— asserts every route via aShouldHaveActionRoute(actionName, verb, path)helper (route-table regression net), then per-action tests asserting the DTO shape returned and the exactmediator.Received(1).Send(Arg.Is<Command>(...))call.PlayoutControllerTests.csis 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 theErsatzTV.Controllers.Apinamespace to find every concrete,[ApiController]-marked controller class — no manual registry to maintain; a new controller is covered automatically. (It intentionally does not filter onControllerBase: several API controllers — including the lone exemptScannerController— do not derive from it, and aControllerBasefilter 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 globalApiKeyAuthorizationFilter(or has an explicit[SkipApiKeyAuthorizationAttribute]exemption; onlyScannerControlleris 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), thenPRAGMA foreign_keys=OFFso 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.Factorywhere anIDbContextFactory<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.