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 sameCustomContractResolver 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.
Regeneratedv1.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.
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.
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)
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>
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 main2026-07-09 20:19:21 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 — documentedfFmpegProfileId, a key the server never emits, so SPA reads returnedundefined. Live impact: with >1 ffmpeg profile, ChannelEditScreen showed (and silently saved) the wrong profile.Changes
NewtonsoftSchemaNamingTransformer— an OpenAPI schema transformer that renames every schema property (andRequiredentry) through the sameCustomContractResolverthe 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.ChannelResponseModel.FFmpegProfilecarries[JsonProperty("ffmpegProfile")]— spec saidfFmpegProfile. This exposed a latent live bug: the channel-list FFmpeg column in App.tsx always showed "Unassigned". Fixed.v1.json+v1.d.ts(diff is exactly theffmpegProfileId/ffmpegProfilerenames;endpoint-index.mdunaffected).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.Non-drifted fields confirmed live and left alone:
fFmpegPath,fFprobePath,zIndex,rFrameRate(identical under both serializers).Verification
check:apiidempotent.GET /api/channels/{id}emitsffmpegProfileId;GET /api/channelsemitsffmpegProfile; settings endpoint confirmsfFmpegPath/fFprobePathnever drifted.ffmpegProfileId: 2after save); troubleshooting screen preselects profile 2; channel list shows the profile name instead of "Unassigned".e0d30c9b.🤖 Generated with Claude Code
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>