fix(openapi): spec property naming now mirrors the runtime Newtonsoft serializer (#198) #201

Merged
timothy merged 4 commits from fix/198-openapi-casing into main 2026-07-09 20:19:21 +02:00
Owner

Closes #198.

Root cause

Runtime MVC JSON is serialized by Newtonsoft with CustomNamingStrategy (camelCase + a special case: FFmpegProfileId → ffmpegProfileId), but the OpenAPI generator (Microsoft.AspNetCore.OpenApi) names schema properties with STJ camelCase. The spec — and the generated TS types — documented fFmpegProfileId, a key the server never emits, so SPA reads returned undefined. Live impact: with >1 ffmpeg profile, ChannelEditScreen showed (and silently saved) the wrong profile.

Changes

  • Root fix: new NewtonsoftSchemaNamingTransformer — an OpenAPI schema transformer that renames every schema property (and Required entry) through the same CustomContractResolver the runtime uses, registered on all three OpenAPI documents. Spec now mirrors runtime by construction (naming-strategy special cases and [JsonProperty] overrides included); rename collisions fail the generator loudly.
  • Second drift found by the transformer: ChannelResponseModel.FFmpegProfile carries [JsonProperty("ffmpegProfile")] — spec said fFmpegProfile. This exposed a latent live bug: the channel-list FFmpeg column in App.tsx always showed "Unassigned". Fixed.
  • Regenerated v1.json + v1.d.ts (diff is exactly the ffmpegProfileId/ffmpegProfile renames; endpoint-index.md unaffected).
  • SPA: fixed all readers/mocks to runtime casing (ChannelEditScreen, ChannelBuilder, App.tsx + tests); removed the #145 escape hatch in PlaybackTroubleshootingScreen (its runtime-cased test mock stays as the regression pin).
  • CI guard: new OpenApiSerializerContractTests — serializes 5 fully-populated DTOs (incl. the [JsonProperty] case) through the exact runtime Newtonsoft settings and asserts the emitted keys equal the committed v1.json schema properties.
  • Docs (same PR): api-conventions §5a (runtime-naming/spec-mirroring + guard test), decisions.md entry, spa-conventions §4 (escape-hatch note removed/updated).

Non-drifted fields confirmed live and left alone: fFmpegPath, fFprobePath, zIndex, rFrameRate (identical under both serializers).

Verification

  • ErsatzTV.Tests 932 passed (+5 new contract tests), Core.Tests 493+1 skip, web 385 passed; lint/typecheck/vite build clean; check:api idempotent.
  • Live curl: GET /api/channels/{id} emits ffmpegProfileId; GET /api/channels emits ffmpegProfile; settings endpoint confirms fFmpegPath/fFprobePath never drifted.
  • Live E2E (Playwright, scratch instance, 2 profiles): channel editor shows profile 2 and save does not rewrite it (ffmpegProfileId: 2 after save); troubleshooting screen preselects profile 2; channel list shows the profile name instead of "Unassigned".
  • Adversarial review fork: no MUST-FIX; SHOULD + NIT addressed in commit e0d30c9b.

🤖 Generated with Claude Code

Closes #198. ## Root cause Runtime MVC JSON is serialized by Newtonsoft with `CustomNamingStrategy` (camelCase + a special case: `FFmpegProfileId` → `ffmpegProfileId`), but the OpenAPI generator (Microsoft.AspNetCore.OpenApi) names schema properties with STJ camelCase. The spec — and the generated TS types — documented `fFmpegProfileId`, a key the server never emits, so SPA reads returned `undefined`. Live impact: with >1 ffmpeg profile, ChannelEditScreen showed (and silently saved) the wrong profile. ## Changes - **Root fix**: new `NewtonsoftSchemaNamingTransformer` — an OpenAPI schema transformer that renames every schema property (and `Required` entry) through the *same* `CustomContractResolver` the runtime uses, registered on all three OpenAPI documents. Spec now mirrors runtime **by construction** (naming-strategy special cases and `[JsonProperty]` overrides included); rename collisions fail the generator loudly. - **Second drift found by the transformer**: `ChannelResponseModel.FFmpegProfile` carries `[JsonProperty("ffmpegProfile")]` — spec said `fFmpegProfile`. This exposed a latent live bug: the channel-list FFmpeg column in App.tsx always showed "Unassigned". Fixed. - **Regenerated** `v1.json` + `v1.d.ts` (diff is exactly the `ffmpegProfileId`/`ffmpegProfile` renames; `endpoint-index.md` unaffected). - **SPA**: fixed all readers/mocks to runtime casing (ChannelEditScreen, ChannelBuilder, App.tsx + tests); removed the #145 escape hatch in PlaybackTroubleshootingScreen (its runtime-cased test mock stays as the regression pin). - **CI guard**: new `OpenApiSerializerContractTests` — serializes 5 fully-populated DTOs (incl. the `[JsonProperty]` case) through the exact runtime Newtonsoft settings and asserts the emitted keys equal the committed v1.json schema properties. - **Docs** (same PR): api-conventions §5a (runtime-naming/spec-mirroring + guard test), decisions.md entry, spa-conventions §4 (escape-hatch note removed/updated). Non-drifted fields confirmed live and left alone: `fFmpegPath`, `fFprobePath`, `zIndex`, `rFrameRate` (identical under both serializers). ## Verification - ErsatzTV.Tests **932** passed (+5 new contract tests), Core.Tests 493+1 skip, web **385** passed; lint/typecheck/vite build clean; `check:api` idempotent. - Live curl: `GET /api/channels/{id}` emits `ffmpegProfileId`; `GET /api/channels` emits `ffmpegProfile`; settings endpoint confirms `fFmpegPath`/`fFprobePath` never drifted. - Live E2E (Playwright, scratch instance, 2 profiles): channel editor shows profile 2 and save does **not** rewrite it (`ffmpegProfileId: 2` after save); troubleshooting screen preselects profile 2; channel list shows the profile name instead of "Unassigned". - Adversarial review fork: no MUST-FIX; SHOULD + NIT addressed in commit e0d30c9b. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 4 commits 2026-07-09 20:07:50 +02:00
The OpenAPI document is generated from System.Text.Json metadata, whose
camelCase drifted from the runtime MVC serializer (Newtonsoft via
CustomContractResolver/CustomNamingStrategy): the spec said "fFmpegProfileId"
and "fFmpegProfile" while the wire emits "ffmpegProfileId" (naming-strategy
special case) and "ffmpegProfile" (ChannelResponseModel's [JsonProperty]
override). That fed the SPA the wrong keys.

Add NewtonsoftSchemaNamingTransformer, an OpenAPI schema transformer registered
on all three documents that renames each object schema's Properties (and
Required) keys through the SAME Newtonsoft contract resolver the runtime uses,
so the spec matches the wire format by construction. Regenerate v1.json.

Guard with OpenApiSerializerContractTests: serializes fully-populated DTOs
through the runtime Newtonsoft settings and pins the v1.json schema property
sets to the emitted keys, failing if generation drifts again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regenerate v1.d.ts from the aligned spec (fFmpegProfileId->ffmpegProfileId,
fFmpegProfile->ffmpegProfile) and update every SPA reader/writer and test mock
to the runtime casing:

- ChannelEditScreen: read channel.ffmpegProfileId; draft/set/select use
  ffmpegProfileId.
- ChannelBuilder: template reads/writes + ADVANCED_KEYS use ffmpegProfileId.
- App.tsx channel list: read channel.ffmpegProfile (was fFmpegProfile, a latent
  bug that always rendered "Unassigned" since the runtime never sent that key).
- PlaybackTroubleshootingScreen: drop the #198 escape hatch (rawChannel cast +
  dual-read) and read data.channel.ffmpegProfileId directly.
- Test mocks now use runtime casing; pinning comments updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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>
test(api): cover [JsonProperty] path + fail loudly on rename collision (#198, review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m59s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
e0d30c9b7b
Adversarial-review follow-ups:

- OpenApiSerializerContractTests: add a fifth case, a fully-populated
  ChannelResponseModel — the only DTO with a [JsonProperty("ffmpegProfile")]
  override, i.e. the attribute path of NewtonsoftSchemaNamingTransformer that
  the existing four cases never exercised.
- NewtonsoftSchemaNamingTransformer: a rename colliding with an existing schema
  key now throws InvalidOperationException (naming the type and keys) instead of
  silently overwriting/dropping a property — the generator must never emit a
  lossy spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy merged commit 2b91837174 into main 2026-07-09 20:19:21 +02:00
timothy deleted branch fix/198-openapi-casing 2026-07-09 20:19:21 +02:00
Sign in to join this conversation.