feat(spa): add typed API response mappers and contract guards
All checks were successful
CI / test-and-quality (push) Successful in 2m29s
CI / test-and-quality (pull_request) Successful in 2m20s

This commit is contained in:
2026-03-01 15:32:26 +00:00
parent 634bd617e7
commit de5007943e
5 changed files with 319 additions and 31 deletions

View File

@@ -1,3 +1,9 @@
import {
mapHealthResponse,
mapJoinSessionResponse,
mapSessionDetailResponse,
mapStartRoundResponse
} from './mappers';
import type {
ApiFailure,
ApiResult,
@@ -60,10 +66,10 @@ function buildUrl(baseUrl: string, path: string): string {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
async function wrap<T>(call: () => Promise<T>): Promise<ApiResult<T>> {
async function wrap<T>(call: () => Promise<unknown>, mapper: (payload: unknown) => T): Promise<ApiResult<T>> {
let payload: unknown;
try {
const data = await call();
return { ok: true, status: 200, data };
payload = await call();
} catch (error: unknown) {
return {
ok: false,
@@ -71,35 +77,57 @@ async function wrap<T>(call: () => Promise<T>): Promise<ApiResult<T>> {
error: toFailure(error)
};
}
try {
return { ok: true, status: 200, data: mapper(payload) };
} catch (error: unknown) {
return {
ok: false,
status: 200,
error: {
kind: 'parse',
status: 200,
message: error instanceof Error ? error.message : 'Invalid API response contract',
payload
}
};
}
}
export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient {
return {
health: () => wrap(() => http.get<HealthResponse>(buildUrl(baseUrl, '/healthz'), { withCredentials: true })),
health: () =>
wrap(() => http.get<HealthResponse>(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse),
getSession: (code: string) =>
wrap(() =>
http.get<SessionDetailResponse>(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), {
withCredentials: true
})
wrap(
() =>
http.get<SessionDetailResponse>(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), {
withCredentials: true
}),
mapSessionDetailResponse
),
joinSession: (payload: JoinSessionRequest) =>
wrap(() =>
http.post<JoinSessionResponse>(
buildUrl(baseUrl, '/lobby/sessions/join'),
{
code: normalizeCode(payload.code),
nickname: payload.nickname.trim()
},
{ withCredentials: true }
)
wrap(
() =>
http.post<JoinSessionResponse>(
buildUrl(baseUrl, '/lobby/sessions/join'),
{
code: normalizeCode(payload.code),
nickname: payload.nickname.trim()
},
{ withCredentials: true }
),
mapJoinSessionResponse
),
startRound: (code: string, payload: StartRoundRequest) =>
wrap(() =>
http.post<StartRoundResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`),
payload,
{ withCredentials: true }
)
wrap(
() =>
http.post<StartRoundResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`),
payload,
{ withCredentials: true }
),
mapStartRoundResponse
)
};
}

View File

@@ -1,3 +1,9 @@
import {
mapHealthResponse,
mapJoinSessionResponse,
mapSessionDetailResponse,
mapStartRoundResponse
} from './mappers';
import type {
ApiResult,
HealthResponse,
@@ -16,7 +22,12 @@ export interface ApiClient {
}
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
async function request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<ApiResult<T>> {
async function request<T>(
path: string,
method: 'GET' | 'POST',
mapper: (payload: unknown) => T,
payload?: unknown
): Promise<ApiResult<T>> {
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
@@ -59,22 +70,45 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
};
}
return { ok: true, status: response.status, data: responsePayload as T };
try {
return { ok: true, status: response.status, data: mapper(responsePayload) };
} catch (error) {
return {
ok: false,
status: response.status,
error: {
kind: 'parse',
status: response.status,
message: error instanceof Error ? error.message : 'Invalid API response contract',
payload: responsePayload
}
};
}
}
return {
health: () => request<HealthResponse>('/healthz', 'GET'),
health: () => request<HealthResponse>('/healthz', 'GET', mapHealthResponse),
getSession: (code: string) =>
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, 'GET'),
request<SessionDetailResponse>(
`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`,
'GET',
mapSessionDetailResponse
),
joinSession: (payload: JoinSessionRequest) =>
request<JoinSessionResponse>('/lobby/sessions/join', 'POST', {
code: payload.code.trim().toUpperCase(),
nickname: payload.nickname.trim()
}),
request<JoinSessionResponse>(
'/lobby/sessions/join',
'POST',
mapJoinSessionResponse,
{
code: payload.code.trim().toUpperCase(),
nickname: payload.nickname.trim()
}
),
startRound: (code: string, payload: StartRoundRequest) =>
request<StartRoundResponse>(
`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`,
'POST',
mapStartRoundResponse,
payload
)
};

191
frontend/src/api/mappers.ts Normal file
View File

@@ -0,0 +1,191 @@
import type { HealthResponse, JoinSessionResponse, SessionDetailResponse, StartRoundResponse } from './types';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function asRecord(value: unknown, path: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`Invalid API contract: expected object at ${path}`);
}
return value;
}
function readString(record: Record<string, unknown>, key: string, path: string): string {
const value = record[key];
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.${key}`);
}
return value;
}
function readNumber(record: Record<string, unknown>, key: string, path: string): number {
const value = record[key];
if (!isNumber(value)) {
throw new Error(`Invalid API contract: expected number at ${path}.${key}`);
}
return value;
}
function readBoolean(record: Record<string, unknown>, key: string, path: string): boolean {
const value = record[key];
if (!isBoolean(value)) {
throw new Error(`Invalid API contract: expected boolean at ${path}.${key}`);
}
return value;
}
export function mapHealthResponse(payload: unknown): HealthResponse {
const root = asRecord(payload, 'health');
return {
ok: readBoolean(root, 'ok', 'health'),
service: readString(root, 'service', 'health')
};
}
function mapSessionDetail(payload: unknown): SessionDetailResponse {
const root = asRecord(payload, 'session_detail');
const session = asRecord(root.session, 'session_detail.session');
const players = root.players;
if (!Array.isArray(players)) {
throw new Error('Invalid API contract: expected array at session_detail.players');
}
const roundQuestionRaw = root.round_question;
let roundQuestion: SessionDetailResponse['round_question'] = null;
if (roundQuestionRaw !== null) {
const roundQuestionRecord = asRecord(roundQuestionRaw, 'session_detail.round_question');
const answersRaw = roundQuestionRecord.answers;
if (!Array.isArray(answersRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.round_question.answers');
}
roundQuestion = {
id: readNumber(roundQuestionRecord, 'id', 'session_detail.round_question'),
round_number: readNumber(roundQuestionRecord, 'round_number', 'session_detail.round_question'),
prompt: readString(roundQuestionRecord, 'prompt', 'session_detail.round_question'),
shown_at: readString(roundQuestionRecord, 'shown_at', 'session_detail.round_question'),
answers: answersRaw.map((answer, index) => {
const answerRecord = asRecord(answer, `session_detail.round_question.answers[${index}]`);
return { text: readString(answerRecord, 'text', `session_detail.round_question.answers[${index}]`) };
})
};
}
const phase = asRecord(root.phase_view_model, 'session_detail.phase_view_model');
const constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints');
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
const player = asRecord(phase.player, 'session_detail.phase_view_model.player');
return {
session: {
code: readString(session, 'code', 'session_detail.session'),
status: readString(session, 'status', 'session_detail.session'),
host_id: (() => {
const hostId = session.host_id;
if (hostId === null) {
return null;
}
if (!isNumber(hostId)) {
throw new Error('Invalid API contract: expected number|null at session_detail.session.host_id');
}
return hostId;
})(),
current_round: readNumber(session, 'current_round', 'session_detail.session'),
players_count: readNumber(session, 'players_count', 'session_detail.session')
},
players: players.map((item, index) => {
const record = asRecord(item, `session_detail.players[${index}]`);
return {
id: readNumber(record, 'id', `session_detail.players[${index}]`),
nickname: readString(record, 'nickname', `session_detail.players[${index}]`),
score: readNumber(record, 'score', `session_detail.players[${index}]`),
is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`)
};
}),
round_question: roundQuestion,
phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'),
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
constraints: {
min_players_to_start: readNumber(constraints, 'min_players_to_start', 'session_detail.phase_view_model.constraints'),
max_players_mvp: readNumber(constraints, 'max_players_mvp', 'session_detail.phase_view_model.constraints'),
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
},
host: {
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
can_mix_answers: readBoolean(host, 'can_mix_answers', 'session_detail.phase_view_model.host'),
can_calculate_scores: readBoolean(host, 'can_calculate_scores', 'session_detail.phase_view_model.host'),
can_reveal_scoreboard: readBoolean(host, 'can_reveal_scoreboard', 'session_detail.phase_view_model.host'),
can_start_next_round: readBoolean(host, 'can_start_next_round', 'session_detail.phase_view_model.host'),
can_finish_game: readBoolean(host, 'can_finish_game', 'session_detail.phase_view_model.host')
},
player: {
can_join: readBoolean(player, 'can_join', 'session_detail.phase_view_model.player'),
can_submit_lie: readBoolean(player, 'can_submit_lie', 'session_detail.phase_view_model.player'),
can_submit_guess: readBoolean(player, 'can_submit_guess', 'session_detail.phase_view_model.player'),
can_view_final_result: readBoolean(player, 'can_view_final_result', 'session_detail.phase_view_model.player')
}
}
};
}
export function mapSessionDetailResponse(payload: unknown): SessionDetailResponse {
return mapSessionDetail(payload);
}
export function mapJoinSessionResponse(payload: unknown): JoinSessionResponse {
const root = asRecord(payload, 'join_session');
const player = asRecord(root.player, 'join_session.player');
const session = asRecord(root.session, 'join_session.session');
return {
player: {
id: readNumber(player, 'id', 'join_session.player'),
nickname: readString(player, 'nickname', 'join_session.player'),
session_token: readString(player, 'session_token', 'join_session.player'),
score: readNumber(player, 'score', 'join_session.player')
},
session: {
code: readString(session, 'code', 'join_session.session'),
status: readString(session, 'status', 'join_session.session')
}
};
}
export function mapStartRoundResponse(payload: unknown): StartRoundResponse {
const root = asRecord(payload, 'start_round');
const session = asRecord(root.session, 'start_round.session');
const round = asRecord(root.round, 'start_round.round');
const category = asRecord(round.category, 'start_round.round.category');
return {
session: {
code: readString(session, 'code', 'start_round.session'),
status: readString(session, 'status', 'start_round.session'),
current_round: readNumber(session, 'current_round', 'start_round.session')
},
round: {
number: readNumber(round, 'number', 'start_round.round'),
category: {
slug: readString(category, 'slug', 'start_round.round.category'),
name: readString(category, 'name', 'start_round.round.category')
}
}
};
}

View File

@@ -189,6 +189,23 @@ describe('createAngularApiClient', () => {
);
});
it('returns parse error when successful payload breaks typed contract', async () => {
const http = {
get: vi.fn<AngularHttpClientLike['get']>(async <T>() => ({ ok: true } as T)),
post: vi.fn<AngularHttpClientLike['post']>(async <T>() => ({ ok: true } as T))
};
const client = createAngularApiClient(http as AngularHttpClientLike);
const session = await client.getSession('ABCD12');
expect(session.ok).toBe(false);
if (!session.ok) {
expect(session.status).toBe(200);
expect(session.error.kind).toBe('parse');
expect(session.error.message).toContain('Invalid API contract');
}
});
it('maps HttpErrorResponse-style failures to ApiResult errors', async () => {
const http = {
get: vi.fn<AngularHttpClientLike['get']>(async () => {

View File

@@ -74,6 +74,12 @@ beforeAll(async () => {
return;
}
if (req.url === '/lobby/sessions/BADMAP' && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ session: { code: 'BADMAP' } }));
return;
}
if (req.url?.startsWith('/lobby/sessions/')) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
@@ -123,6 +129,18 @@ describe('createApiClient', () => {
}
});
it('returns parse error when response violates typed contract', async () => {
const client = createApiClient(baseUrl);
const invalid = await client.getSession('badmap');
expect(invalid.ok).toBe(false);
if (!invalid.ok) {
expect(invalid.status).toBe(200);
expect(invalid.error.kind).toBe('parse');
expect(invalid.error.message).toContain('Invalid API contract');
}
});
it('returns consistent HTTP error shape for 4xx/5xx', async () => {
const client = createApiClient(baseUrl);