Files
weirsoe-party-protocol/frontend/src/api/mappers.ts
DEV-bot fb782432ea
All checks were successful
CI / test-and-quality (push) Successful in 2m22s
CI / test-and-quality (pull_request) Successful in 1m56s
feat(spa): guard angular host/player api contracts
2026-03-01 16:40:34 +00:00

360 lines
15 KiB
TypeScript

import type {
CalculateScoresResponse,
FinishGameResponse,
HealthResponse,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
StartRoundResponse,
SubmitGuessResponse,
SubmitLieResponse
} 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')
}
}
};
}
function mapLeaderboardEntry(payload: unknown, path: string): { id: number; nickname: string; score: number } {
const record = asRecord(payload, path);
return {
id: readNumber(record, 'id', path),
nickname: readString(record, 'nickname', path),
score: readNumber(record, 'score', path)
};
}
function mapSessionState(payload: unknown, path: string): { code: string; status: string; current_round: number } {
const session = asRecord(payload, path);
return {
code: readString(session, 'code', path),
status: readString(session, 'status', path),
current_round: readNumber(session, 'current_round', path)
};
}
export function mapShowQuestionResponse(payload: unknown): ShowQuestionResponse {
const root = asRecord(payload, 'show_question');
const roundQuestion = asRecord(root.round_question, 'show_question.round_question');
const config = asRecord(root.config, 'show_question.config');
return {
round_question: {
id: readNumber(roundQuestion, 'id', 'show_question.round_question'),
prompt: readString(roundQuestion, 'prompt', 'show_question.round_question'),
round_number: readNumber(roundQuestion, 'round_number', 'show_question.round_question'),
shown_at: readString(roundQuestion, 'shown_at', 'show_question.round_question'),
lie_deadline_at: readString(roundQuestion, 'lie_deadline_at', 'show_question.round_question')
},
config: {
lie_seconds: readNumber(config, 'lie_seconds', 'show_question.config')
}
};
}
export function mapMixAnswersResponse(payload: unknown): MixAnswersResponse {
const root = asRecord(payload, 'mix_answers');
const roundQuestion = asRecord(root.round_question, 'mix_answers.round_question');
const answersRaw = root.answers;
if (!Array.isArray(answersRaw)) {
throw new Error('Invalid API contract: expected array at mix_answers.answers');
}
return {
session: mapSessionState(root.session, 'mix_answers.session'),
round_question: {
id: readNumber(roundQuestion, 'id', 'mix_answers.round_question'),
round_number: readNumber(roundQuestion, 'round_number', 'mix_answers.round_question')
},
answers: answersRaw.map((answer, index) => {
const record = asRecord(answer, `mix_answers.answers[${index}]`);
return { text: readString(record, 'text', `mix_answers.answers[${index}]`) };
})
};
}
export function mapCalculateScoresResponse(payload: unknown): CalculateScoresResponse {
const root = asRecord(payload, 'calculate_scores');
const roundQuestion = asRecord(root.round_question, 'calculate_scores.round_question');
const leaderboardRaw = root.leaderboard;
if (!Array.isArray(leaderboardRaw)) {
throw new Error('Invalid API contract: expected array at calculate_scores.leaderboard');
}
return {
session: mapSessionState(root.session, 'calculate_scores.session'),
round_question: {
id: readNumber(roundQuestion, 'id', 'calculate_scores.round_question'),
round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question')
},
events_created: readNumber(root, 'events_created', 'calculate_scores'),
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`))
};
}
export function mapScoreboardResponse(payload: unknown): ScoreboardResponse {
const root = asRecord(payload, 'scoreboard');
const leaderboardRaw = root.leaderboard;
if (!Array.isArray(leaderboardRaw)) {
throw new Error('Invalid API contract: expected array at scoreboard.leaderboard');
}
return {
session: mapSessionState(root.session, 'scoreboard.session'),
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `scoreboard.leaderboard[${index}]`))
};
}
export function mapStartNextRoundResponse(payload: unknown): StartNextRoundResponse {
const root = asRecord(payload, 'start_next_round');
return { session: mapSessionState(root.session, 'start_next_round.session') };
}
export function mapFinishGameResponse(payload: unknown): FinishGameResponse {
const root = asRecord(payload, 'finish_game');
const leaderboardRaw = root.leaderboard;
if (!Array.isArray(leaderboardRaw)) {
throw new Error('Invalid API contract: expected array at finish_game.leaderboard');
}
const winnerRaw = root.winner;
return {
session: mapSessionState(root.session, 'finish_game.session'),
winner: winnerRaw === null ? null : mapLeaderboardEntry(winnerRaw, 'finish_game.winner'),
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `finish_game.leaderboard[${index}]`))
};
}
export function mapSubmitLieResponse(payload: unknown): SubmitLieResponse {
const root = asRecord(payload, 'submit_lie');
const lie = asRecord(root.lie, 'submit_lie.lie');
const window = asRecord(root.window, 'submit_lie.window');
return {
lie: {
id: readNumber(lie, 'id', 'submit_lie.lie'),
player_id: readNumber(lie, 'player_id', 'submit_lie.lie'),
round_question_id: readNumber(lie, 'round_question_id', 'submit_lie.lie'),
text: readString(lie, 'text', 'submit_lie.lie'),
created_at: readString(lie, 'created_at', 'submit_lie.lie')
},
window: {
lie_deadline_at: readString(window, 'lie_deadline_at', 'submit_lie.window')
}
};
}
export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse {
const root = asRecord(payload, 'submit_guess');
const guess = asRecord(root.guess, 'submit_guess.guess');
const window = asRecord(root.window, 'submit_guess.window');
const fooledPlayerId = guess.fooled_player_id;
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
throw new Error('Invalid API contract: expected number|null at submit_guess.guess.fooled_player_id');
}
return {
guess: {
id: readNumber(guess, 'id', 'submit_guess.guess'),
player_id: readNumber(guess, 'player_id', 'submit_guess.guess'),
round_question_id: readNumber(guess, 'round_question_id', 'submit_guess.guess'),
selected_text: readString(guess, 'selected_text', 'submit_guess.guess'),
is_correct: readBoolean(guess, 'is_correct', 'submit_guess.guess'),
fooled_player_id: fooledPlayerId,
created_at: readString(guess, 'created_at', 'submit_guess.guess')
},
window: {
guess_deadline_at: readString(window, 'guess_deadline_at', 'submit_guess.window')
}
};
}