diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 33c68a2d8..2a89fe5b0 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -121,6 +121,31 @@ gets this wrong. The SPA works around it with a manual override type; see `web/s `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/ControllerTests.cs`. NUnit + Shouldly + diff --git a/docs/decisions.md b/docs/decisions.md index cb237a70f..2242b9ccc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -127,3 +127,19 @@ Sample** (alongside Download Results) while a troubleshooting session is startin only gated Download Results. Both downloads compete with the live transcode for I/O and the sample archiver reads the same media file, so gating both during a session is strictly safer and costs nothing (sessions are short). + +## 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198) + +The generated OpenAPI document is made to follow the **runtime** JSON contract, not the reverse. Runtime +`/api/*` responses are serialized by Newtonsoft via `CustomContractResolver`/`CustomNamingStrategy` +(camelCase + a `FFmpegProfileId`→`ffmpegProfileId` special case + `[JsonProperty]` overrides such as +`ChannelResponseModel.FFmpegProfile`→`ffmpegProfile`), while `Microsoft.AspNetCore.OpenApi` generates the +spec from System.Text.Json metadata, whose camelCase drifted (`fFmpegProfileId`, `fFmpegProfile`). That +drift fed the SPA the wrong key. Rather than hand-patch the spec or change the wire format (breaking clients), +we added `NewtonsoftSchemaNamingTransformer` — an OpenAPI schema transformer registered on all three +documents that renames each schema property through the *same* Newtonsoft contract resolver the runtime uses, +so the spec matches the wire format by construction. A contract test +(`OpenApiSerializerContractTests`) serializes representative DTOs through the real runtime settings and pins +the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the +real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read +`fFmpegProfile` and always showed "Unassigned"). Issue #198. diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 426f53fa7..ffe2d810b 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -76,6 +76,15 @@ One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts - `web/src/api/index.ts` re-exports everything so screens import from `'../api'`, not from the individual domain file directly. +**Trust the generated key casing — it mirrors the runtime.** Since #198 the OpenAPI spec is generated +to match the runtime Newtonsoft serializer exactly (a schema transformer runs the same contract +resolver; see `api-conventions.md` §5a), so the generated types carry the real wire keys — including +Newtonsoft's acronym quirks like `ffmpegProfileId` (channel FFmpeg-profile id) and `ffmpegProfile` +(channel FFmpeg-profile display name). **Do not** hand-cast responses to "fix" a key or dual-read a +spec-cased vs runtime-cased key (the old `PlaybackTroubleshootingScreen` `#198` escape hatch that read +`data.channel.fFmpegProfileId` has been removed — read `ffmpegProfileId` straight off the typed +response). When you mock an API response in a test, use the generated (runtime) casing. + ## 5. Artwork rendering Render `item.artwork` / `item.poster` (or whatever the DTO field is named) **directly as an `