Files
ersatztv/web/src/api/client.ts
T
timothyandClaude Opus 4.8 94ebf34ccd
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): optimistic-concurrency contract for replace-all PUTs — PR1 infra + Block reference (#253)
Adds the shared optimistic-concurrency contract so a stale second tab can no longer
silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate;
PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472).

Contract
- `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule,
  Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection,
  RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration
  `AddAggregateVersions` (nullable:false, default 0).
- Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch →
  412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag.
- `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`;
  `ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent =
  Phase-1 force-write.

Block reference wiring
- Handler: standalone `Either` via `CheckVersion` AFTER validation (never through
  `Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`,
  `SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412).
- `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the
  ETag on GET items and on the successful PUT.
- SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock`
  If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412
  opens a blocking "changed elsewhere — reload" dialog.

Tests
- Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success
  + bump, no-op save still bumps, and a two-context racing save → 412; proven
  non-vacuous (drop `.IsConcurrencyToken()` → the race test fails).
- Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on
  GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog.

Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log.

Refs #253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:51:59 +02:00

162 lines
4.3 KiB
TypeScript

import { getStoredApiKey } from './auth';
import type { components } from './generated/v1';
type ProblemDetails = components['schemas']['ProblemDetails'];
type RequestBody = BodyInit | Record<string, unknown> | null;
export class ApiError extends Error {
readonly detail?: string | null;
readonly problem?: ProblemDetails;
readonly status: number;
constructor(status: number, problem?: ProblemDetails) {
super(problem?.title ?? `Request failed with status ${status}`);
this.name = 'ApiError';
this.detail = problem?.detail;
this.problem = problem;
this.status = status;
}
}
export interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: RequestBody;
}
const mutatingMethods = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
export interface ResponseWithMeta<T> {
data: T;
/** Strong ETag of the resource's version, when the endpoint emits one (issue #253). */
etag: string | null;
}
/**
* Like {@link request} but also returns the response's ETag. Used by the optimistic-concurrency
* editors: read the ETag from the load GET, send it back as `If-Match` on the replace PUT, and
* replace it from the PUT response's ETag on every successful save (issue #253 / spa-conventions).
*/
export async function requestWithMeta<TResponse = unknown>(
path: string,
options: ApiRequestOptions = {}
): Promise<ResponseWithMeta<TResponse>> {
const method = (options.method ?? 'GET').toUpperCase();
const headers = normalizeHeaders({
Accept: 'application/json',
...headersToRecord(options.headers)
});
const body = serializeBody(options.body, headers);
const apiKey = getStoredApiKey();
if (apiKey && mutatingMethods.has(method)) {
headers['X-Api-Key'] = apiKey;
}
const response = await fetch(path, {
...options,
body,
headers,
method
});
if (!response.ok) {
throw new ApiError(response.status, await readProblemDetails(response));
}
const etag = response.headers.get('ETag');
if (response.status === 204) {
return { data: undefined as TResponse, etag };
}
return { data: (await readJsonResponse(response)) as TResponse, etag };
}
export async function request<TResponse = unknown>(
path: string,
options: ApiRequestOptions = {}
): Promise<TResponse> {
const { data } = await requestWithMeta<TResponse>(path, options);
return data;
}
function normalizeHeaders(headers: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [canonicalHeaderName(key), value])
);
}
function canonicalHeaderName(header: string): string {
return header
.toLowerCase()
.split('-')
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join('-');
}
function headersToRecord(headers?: HeadersInit): Record<string, string> {
if (!headers) {
return {};
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return headers;
}
function serializeBody(body: RequestBody | undefined, headers: Record<string, string>): BodyInit | null | undefined {
if (body === undefined || body === null) {
return body;
}
if (body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob) {
return body;
}
if (typeof body === 'string') {
return body;
}
headers['Content-Type'] ??= 'application/json';
return JSON.stringify(body);
}
async function readProblemDetails(response: Response): Promise<ProblemDetails | undefined> {
const contentType = response.headers.get('Content-Type') ?? '';
if (!isJsonContentType(contentType)) {
return undefined;
}
return await response.json() as ProblemDetails;
}
async function readJsonResponse(response: Response): Promise<unknown> {
const contentLength = response.headers.get('Content-Length');
const contentType = response.headers.get('Content-Type') ?? '';
if (contentLength === '0' || !isJsonContentType(contentType)) {
return undefined;
}
try {
return await response.json();
} catch (error) {
if (error instanceof SyntaxError) {
return undefined;
}
throw error;
}
}
function isJsonContentType(contentType: string): boolean {
return contentType.includes('application/json') || contentType.includes('problem+json');
}