Files
weirsoe-party-protocol/frontend/src/api/angular-client.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

263 lines
8.1 KiB
TypeScript

import {
mapCalculateScoresResponse,
mapFinishGameResponse,
mapHealthResponse,
mapJoinSessionResponse,
mapMixAnswersResponse,
mapScoreboardResponse,
mapSessionDetailResponse,
mapShowQuestionResponse,
mapStartNextRoundResponse,
mapStartRoundResponse,
mapSubmitGuessResponse,
mapSubmitLieResponse
} from './mappers';
import type {
ApiFailure,
ApiResult,
CalculateScoresResponse,
FinishGameResponse,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
StartRoundRequest,
StartRoundResponse,
SubmitGuessRequest,
SubmitGuessResponse,
SubmitLieRequest,
SubmitLieResponse
} from './types';
export interface AngularHttpError {
status?: number;
message?: string;
error?: unknown;
}
export interface AngularHttpClientLike {
get<T>(url: string, options?: { withCredentials?: boolean }): Promise<T>;
post<T>(url: string, body: unknown, options?: { withCredentials?: boolean }): Promise<T>;
}
export interface AngularApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>;
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
submitGuess(
code: string,
roundQuestionId: number,
payload: SubmitGuessRequest
): Promise<ApiResult<SubmitGuessResponse>>;
}
function toFailure(error: unknown): ApiFailure {
const candidate = (error ?? {}) as AngularHttpError;
const status = typeof candidate.status === 'number' ? candidate.status : 0;
const payload = candidate.error;
if (status === 0) {
return {
kind: 'network',
status: 0,
message: candidate.message ?? 'Network error while contacting API'
};
}
return {
kind: 'http',
status,
message: candidate.message ?? `HTTP ${status}`,
...(payload === undefined ? {} : { payload })
};
}
function normalizeCode(code: string): string {
return code.trim().toUpperCase();
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
}
function buildUrl(baseUrl: string, path: string): string {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
async function wrap<T>(call: () => Promise<unknown>, mapper: (payload: unknown) => T): Promise<ApiResult<T>> {
let payload: unknown;
try {
payload = await call();
} catch (error: unknown) {
return {
ok: false,
status: typeof (error as AngularHttpError)?.status === 'number' ? (error as AngularHttpError).status! : 0,
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 }), mapHealthResponse),
getSession: (code: string) =>
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 }
),
mapJoinSessionResponse
),
startRound: (code: string, payload: StartRoundRequest) =>
wrap(
() =>
http.post<StartRoundResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`),
payload,
{ withCredentials: true }
),
mapStartRoundResponse
),
showQuestion: (code: string) =>
wrap(
() =>
http.post<ShowQuestionResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/show`),
{},
{ withCredentials: true }
),
mapShowQuestionResponse
),
mixAnswers: (code: string, roundQuestionId: number) =>
wrap(
() =>
http.post<MixAnswersResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/answers/mix`
),
{},
{ withCredentials: true }
),
mapMixAnswersResponse
),
calculateScores: (code: string, roundQuestionId: number) =>
wrap(
() =>
http.post<CalculateScoresResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/scores/calculate`
),
{},
{ withCredentials: true }
),
mapCalculateScoresResponse
),
getScoreboard: (code: string) =>
wrap(
() =>
http.get<ScoreboardResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/scoreboard`),
{ withCredentials: true }
),
mapScoreboardResponse
),
startNextRound: (code: string) =>
wrap(
() =>
http.post<StartNextRoundResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`),
{},
{ withCredentials: true }
),
mapStartNextRoundResponse
),
finishGame: (code: string) =>
wrap(
() =>
http.post<FinishGameResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/finish`),
{},
{ withCredentials: true }
),
mapFinishGameResponse
),
submitLie: (code: string, roundQuestionId: number, payload: SubmitLieRequest) =>
wrap(
() =>
http.post<SubmitLieResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/lies/submit`
),
{
player_id: payload.player_id,
session_token: payload.session_token,
text: payload.text
},
{ withCredentials: true }
),
mapSubmitLieResponse
),
submitGuess: (code: string, roundQuestionId: number, payload: SubmitGuessRequest) =>
wrap(
() =>
http.post<SubmitGuessResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/guesses/submit`
),
{
player_id: payload.player_id,
session_token: payload.session_token,
selected_text: payload.selected_text
},
{ withCredentials: true }
),
mapSubmitGuessResponse
)
};
}