diff --git a/README.md b/README.md index ad347be..5a61bf7 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,13 @@ Fresh full-stack starter scaffold: - Skills: create/update with ruleset-aware dice expression validation - Rolls: public/private skill rolls with append-only campaign log - State stream: SSE endpoint for campaign version updates + +## Implemented Frontend Scope + +- TypeScript-driven UI for: + - registration, login, logout + - campaign creation and selection + - character creation and activation + - skill creation and editing + - public/private rolling and campaign log viewing +- SSE-backed live refresh for selected campaign state/log diff --git a/RpgRoller/frontend/app.ts b/RpgRoller/frontend/app.ts index 23f255f..715f2a4 100644 --- a/RpgRoller/frontend/app.ts +++ b/RpgRoller/frontend/app.ts @@ -1,25 +1,509 @@ -import { getHealth, rollDice } from "./generated/api-client.js"; +import { + activateCharacter, + type CampaignDetails, + type CampaignLogEntry, + type CampaignSummary, + createCampaign, + createCharacter, + createSkill, + getCampaign, + getCampaignLog, + getCampaigns, + getHealth, + getMe, + getRulesets, + loginUser, + logoutUser, + registerUser, + rollSkill, + type RulesetDefinition, + type SkillSummary, + type UserSummary, + updateSkill +} from "./generated/api-client.js"; -const healthElementRaw = document.getElementById("health"); -const resultElementRaw = document.getElementById("result"); -const formElementRaw = document.getElementById("roll-form"); -const sidesInputRaw = document.getElementById("sides"); +const healthElement = mustElement("health"); +const messageElement = mustElement("message"); +const campaignMetaElement = mustElement("campaign-meta"); +const campaignDetailsElement = mustElement("campaign-details"); +const rollResultElement = mustElement("roll-result"); +const campaignLogElement = mustElement("campaign-log"); -if ( - !(healthElementRaw instanceof HTMLElement) || - !(resultElementRaw instanceof HTMLElement) || - !(formElementRaw instanceof HTMLFormElement) || - !(sidesInputRaw instanceof HTMLInputElement) -) { - throw new Error("Required UI elements are missing from index.html."); +const registerForm = mustForm("register-form"); +const loginForm = mustForm("login-form"); +const logoutButton = mustButton("logout-button"); + +const registerUsername = mustInput("register-username"); +const registerDisplayName = mustInput("register-display-name"); +const registerPassword = mustInput("register-password"); +const loginUsername = mustInput("login-username"); +const loginPassword = mustInput("login-password"); + +const campaignForm = mustForm("campaign-form"); +const campaignNameInput = mustInput("campaign-name"); +const campaignRulesetSelect = mustSelect("campaign-ruleset"); +const campaignSelect = mustSelect("campaign-select"); +const refreshCampaignButton = mustButton("refresh-campaign-button"); + +const characterForm = mustForm("character-form"); +const characterNameInput = mustInput("character-name"); +const characterSelect = mustSelect("character-select"); +const activateCharacterButton = mustButton("activate-character-button"); + +const skillForm = mustForm("skill-form"); +const skillNameInput = mustInput("skill-name"); +const skillExpressionInput = mustInput("skill-expression"); +const createSkillButton = mustButton("create-skill-button"); +const updateSkillButton = mustButton("update-skill-button"); +const skillSelect = mustSelect("skill-select"); + +const rollForm = mustForm("roll-form"); +const rollVisibilitySelect = mustSelect("roll-visibility"); + +type AppState = { + user: UserSummary | null; + campaigns: CampaignSummary[]; + selectedCampaignId: string | null; + selectedCampaign: CampaignDetails | null; + campaignLog: CampaignLogEntry[]; + rulesets: RulesetDefinition[]; + eventSource: EventSource | null; +}; + +const state: AppState = { + user: null, + campaigns: [], + selectedCampaignId: null, + selectedCampaign: null, + campaignLog: [], + rulesets: [], + eventSource: null +}; + +registerForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + await runAction(async () => { + await registerUser({ + username: registerUsername.value.trim(), + displayName: registerDisplayName.value.trim(), + password: registerPassword.value + }); + + registerPassword.value = ""; + setMessage("Registration successful. You can log in now.", false); + }); +}); + +loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + await runAction(async () => { + await loginUser({ + username: loginUsername.value.trim(), + password: loginPassword.value + }); + + loginPassword.value = ""; + await reloadAll(); + setMessage("Logged in.", false); + }); +}); + +logoutButton.addEventListener("click", async () => { + await runAction(async () => { + await logoutUser(); + resetStateAfterLogout(); + renderAll(); + setMessage("Logged out.", false); + }); +}); + +campaignForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + await runAction(async () => { + const createdCampaign = await createCampaign({ + name: campaignNameInput.value.trim(), + rulesetId: campaignRulesetSelect.value + }); + + campaignNameInput.value = ""; + await reloadCampaigns(createdCampaign.id); + setMessage("Campaign created.", false); + }); +}); + +campaignSelect.addEventListener("change", async () => { + await runAction(async () => { + const selected = campaignSelect.value; + state.selectedCampaignId = selected.length > 0 ? selected : null; + await reloadSelectedCampaign(); + connectStateEvents(); + renderAll(); + }); +}); + +refreshCampaignButton.addEventListener("click", async () => { + await runAction(async () => { + await reloadSelectedCampaign(); + renderAll(); + setMessage("Campaign refreshed.", false); + }); +}); + +characterForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + await runAction(async () => { + if (!state.selectedCampaignId) { + throw new Error("Select a campaign first."); + } + + await createCharacter({ + name: characterNameInput.value.trim(), + campaignId: state.selectedCampaignId + }); + + characterNameInput.value = ""; + await reloadSelectedCampaign(); + await reloadCampaignLog(); + setMessage("Character created.", false); + }); +}); + +activateCharacterButton.addEventListener("click", async () => { + await runAction(async () => { + const characterId = characterSelect.value; + if (characterId.length === 0) { + throw new Error("Select a character to activate."); + } + + await activateCharacter(characterId); + await reloadAll(); + setMessage("Active character updated.", false); + }); +}); + +skillForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + await runAction(async () => { + const characterId = characterSelect.value; + if (characterId.length === 0) { + throw new Error("Select a character first."); + } + + await createSkill(characterId, { + name: skillNameInput.value.trim(), + diceRollDefinition: skillExpressionInput.value.trim() + }); + + skillNameInput.value = ""; + skillExpressionInput.value = ""; + await reloadSelectedCampaign(); + setMessage("Skill created.", false); + }); +}); + +updateSkillButton.addEventListener("click", async () => { + await runAction(async () => { + const selectedSkillId = skillSelect.value; + if (selectedSkillId.length === 0) { + throw new Error("Select a skill to update."); + } + + await updateSkill(selectedSkillId, { + name: skillNameInput.value.trim(), + diceRollDefinition: skillExpressionInput.value.trim() + }); + + await reloadSelectedCampaign(); + setMessage("Skill updated.", false); + }); +}); + +rollForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + await runAction(async () => { + const selectedSkillId = skillSelect.value; + if (selectedSkillId.length === 0) { + throw new Error("Select a skill to roll."); + } + + const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value }); + rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; + + await reloadCampaignLog(); + setMessage("Roll recorded.", false); + }); +}); + +await runAction(async () => { + await refreshHealth(); + await reloadAll(); + setMessage("Ready.", false); +}); + +async function refreshHealth(): Promise { + const health = await getHealth(); + healthElement.textContent = `API status: ${health.status}`; } -const healthElement = healthElementRaw; -const resultElement = resultElementRaw; -const formElement = formElementRaw; -const sidesInput = sidesInputRaw; +async function reloadAll(): Promise { + await ensureRulesets(); + await reloadSession(); + if (state.user) { + await reloadCampaigns(state.selectedCampaignId); + await reloadSelectedCampaign(); + await reloadCampaignLog(); + connectStateEvents(); + } + else { + resetAuthenticatedState(); + } -function errorMessage(error: unknown): string { + renderAll(); +} + +async function reloadSession(): Promise { + try { + const me = await getMe(); + state.user = me.user; + state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId; + } + catch { + state.user = null; + } +} + +async function ensureRulesets(): Promise { + if (state.rulesets.length > 0) { + return; + } + + state.rulesets = await getRulesets(); + campaignRulesetSelect.innerHTML = state.rulesets + .map((ruleset) => ``) + .join(""); +} + +async function reloadCampaigns(preferredCampaignId: string | null): Promise { + state.campaigns = await getCampaigns(); + + if (state.campaigns.length === 0) { + state.selectedCampaignId = null; + return; + } + + const availableIds = new Set(state.campaigns.map((campaign) => campaign.id)); + + if (preferredCampaignId && availableIds.has(preferredCampaignId)) { + state.selectedCampaignId = preferredCampaignId; + return; + } + + if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) { + return; + } + + state.selectedCampaignId = state.campaigns[0].id; +} + +async function reloadSelectedCampaign(): Promise { + if (!state.selectedCampaignId) { + state.selectedCampaign = null; + return; + } + + state.selectedCampaign = await getCampaign(state.selectedCampaignId); +} + +async function reloadCampaignLog(): Promise { + if (!state.selectedCampaignId) { + state.campaignLog = []; + return; + } + + state.campaignLog = await getCampaignLog(state.selectedCampaignId); +} + +function connectStateEvents(): void { + if (!state.selectedCampaignId || !state.user) { + closeStateEvents(); + return; + } + + if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) { + return; + } + + closeStateEvents(); + state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`); + + state.eventSource.addEventListener("state", () => { + void runAction(async () => { + await reloadSelectedCampaign(); + await reloadCampaignLog(); + renderAll(); + }); + }); + + state.eventSource.onerror = () => { + closeStateEvents(); + }; +} + +function closeStateEvents(): void { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } +} + +function resetStateAfterLogout(): void { + state.user = null; + resetAuthenticatedState(); + closeStateEvents(); +} + +function resetAuthenticatedState(): void { + state.campaigns = []; + state.selectedCampaignId = null; + state.selectedCampaign = null; + state.campaignLog = []; +} + +function renderAll(): void { + renderCampaignSelect(); + renderCampaignMeta(); + renderCampaignDetails(); + renderCharacterSelect(); + renderSkillSelect(); + renderCampaignLog(); +} + +function renderCampaignSelect(): void { + campaignSelect.innerHTML = state.campaigns + .map((campaign) => { + const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; + return ``; + }) + .join(""); +} + +function renderCampaignMeta(): void { + if (!state.user) { + campaignMetaElement.textContent = "Log in to manage campaigns."; + return; + } + + if (!state.selectedCampaign) { + campaignMetaElement.textContent = "No campaign selected."; + return; + } + + const isGm = state.selectedCampaign.gm.id === state.user.id; + campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}`; +} + +function renderCampaignDetails(): void { + if (!state.selectedCampaign) { + campaignDetailsElement.textContent = "No details available."; + return; + } + + const characters = state.selectedCampaign.characters + .map((character) => `
  • ${character.name} (${character.id})
  • `) + .join(""); + + const skills = state.selectedCampaign.skills + .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) + .join(""); + + campaignDetailsElement.innerHTML = ` +

    GM: ${state.selectedCampaign.gm.displayName}

    +

    Characters visible to you: ${state.selectedCampaign.characters.length}

    +
      ${characters}
    +

    Skills visible to you: ${state.selectedCampaign.skills.length}

    +
      ${skills}
    + `; +} + +function renderCharacterSelect(): void { + if (!state.selectedCampaign) { + characterSelect.innerHTML = ""; + return; + } + + const options = state.selectedCampaign.characters + .map((character) => ``) + .join(""); + + characterSelect.innerHTML = options; +} + +function renderSkillSelect(): void { + if (!state.selectedCampaign) { + skillSelect.innerHTML = ""; + return; + } + + const options = state.selectedCampaign.skills + .map((skill) => ``) + .join(""); + + skillSelect.innerHTML = options; + + const selectedSkill = selectedSkillFromCampaign(); + if (selectedSkill) { + skillNameInput.value = selectedSkill.name; + skillExpressionInput.value = selectedSkill.diceRollDefinition; + } +} + +function selectedSkillFromCampaign(): SkillSummary | null { + if (!state.selectedCampaign) { + return null; + } + + const selectedSkillId = skillSelect.value; + return state.selectedCampaign.skills.find((skill) => skill.id === selectedSkillId) ?? null; +} + +function renderCampaignLog(): void { + if (state.campaignLog.length === 0) { + campaignLogElement.innerHTML = "
  • No rolls yet.
  • "; + return; + } + + campaignLogElement.innerHTML = state.campaignLog + .map((entry) => ` +
  • + ${entry.visibility.toUpperCase()} + ${entry.breakdown} + ${new Date(entry.timestampUtc).toLocaleString()} +
  • + `) + .join(""); +} + +async function runAction(action: () => Promise): Promise { + try { + await action(); + } + catch (error: unknown) { + setMessage(formatError(error), true); + } +} + +function setMessage(message: string, isError: boolean): void { + messageElement.textContent = message; + messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8"; +} + +function formatError(error: unknown): string { if (error instanceof Error) { return error.message; } @@ -27,27 +511,47 @@ function errorMessage(error: unknown): string { return String(error); } -async function refreshHealth(): Promise { - try { - const health = await getHealth(); - healthElement.textContent = `API status: ${health.status}`; - } - catch (error: unknown) { - healthElement.textContent = `API status check failed: ${errorMessage(error)}`; +function mustElement(id: string): HTMLElement { + const element = document.getElementById(id); + if (!(element instanceof HTMLElement)) { + throw new Error(`Missing HTMLElement: ${id}`); } + + return element; } -formElement.addEventListener("submit", async (event: SubmitEvent) => { - event.preventDefault(); - - const sides = Number.parseInt(sidesInput.value, 10); - try { - const roll = await rollDice(sides); - resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`; +function mustInput(id: string): HTMLInputElement { + const element = document.getElementById(id); + if (!(element instanceof HTMLInputElement)) { + throw new Error(`Missing HTMLInputElement: ${id}`); } - catch (error: unknown) { - resultElement.textContent = `Roll failed: ${errorMessage(error)}`; - } -}); -await refreshHealth(); + return element; +} + +function mustSelect(id: string): HTMLSelectElement { + const element = document.getElementById(id); + if (!(element instanceof HTMLSelectElement)) { + throw new Error(`Missing HTMLSelectElement: ${id}`); + } + + return element; +} + +function mustForm(id: string): HTMLFormElement { + const element = document.getElementById(id); + if (!(element instanceof HTMLFormElement)) { + throw new Error(`Missing HTMLFormElement: ${id}`); + } + + return element; +} + +function mustButton(id: string): HTMLButtonElement { + const element = document.getElementById(id); + if (!(element instanceof HTMLButtonElement)) { + throw new Error(`Missing HTMLButtonElement: ${id}`); + } + + return element; +} diff --git a/RpgRoller/frontend/generated/api-client.ts b/RpgRoller/frontend/generated/api-client.ts index 9644ee7..b731a1e 100644 --- a/RpgRoller/frontend/generated/api-client.ts +++ b/RpgRoller/frontend/generated/api-client.ts @@ -4,36 +4,190 @@ export interface ApiError { error: string; } +export interface CampaignDetails { + id: string; + name: string; + rulesetId: string; + gm: UserSummary; + characters: Array; + skills: Array; +} + +export interface CampaignLogEntry { + rollId: string; + campaignId: string; + characterId: string; + skillId: string; + rollerUserId: string; + visibility: string; + result: number; + breakdown: string; + timestampUtc: string; +} + +export interface CampaignSummary { + id: string; + name: string; + rulesetId: string; + gmUserId: string; +} + +export interface CharacterSummary { + id: string; + name: string; + ownerUserId: string; + campaignId: string; +} + +export interface CreateCampaignRequest { + name: string; + rulesetId: string; +} + +export interface CreateCharacterRequest { + name: string; + campaignId: string; +} + +export interface CreateSkillRequest { + name: string; + diceRollDefinition: string; +} + export interface HealthResponse { status: string; } -export interface RollResponse { - sides: number; - value: number; +export interface LoginRequest { + username: string; + password: string; +} + +export interface MeResponse { + user: UserSummary; + activeCharacterId?: string; + currentCampaignId?: string; +} + +export interface RegisterRequest { + username: string; + password: string; + displayName: string; +} + +export interface RollResult { + rollId: string; + campaignId: string; + characterId: string; + skillId: string; + rollerUserId: string; + visibility: string; + result: number; + breakdown: string; + timestampUtc: string; +} + +export interface RollSkillRequest { + visibility: string; +} + +export interface RulesetDefinition { + id: string; + name: string; + diceSyntax: string; +} + +export interface SkillSummary { + id: string; + characterId: string; + name: string; + diceRollDefinition: string; +} + +export interface UpdateCharacterRequest { + name: string; + campaignId: string; +} + +export interface UpdateSkillRequest { + name: string; + diceRollDefinition: string; +} + +export interface UserSummary { + id: string; + username: string; + displayName: string; } type ApiOperation = { method: string; path: string; + expectsJson: boolean; +}; + +type RequestOptions = { + pathParams?: Record; + query?: Record; + body?: unknown; }; export const apiOperations = { - getHealth: { method: "GET", path: "/api/health" }, - rollDice: { method: "GET", path: "/api/roll/{sides}" } + activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true }, + createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true }, + createCharacter: { method: "POST", path: "/api/characters", expectsJson: true }, + createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true }, + getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true }, + getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true }, + getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true }, + getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true }, + getHealth: { method: "GET", path: "/api/health", expectsJson: true }, + getMe: { method: "GET", path: "/api/me", expectsJson: true }, + getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true }, + loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true }, + logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false }, + registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true }, + rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true }, + updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true }, + updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true } } as const satisfies Record; -async function send(operation: ApiOperation, pathParams: Record = {}): Promise { - let resolvedPath = operation.path; +function withPathParams(pathTemplate: string, pathParams: Record): string { + let pathValue = pathTemplate; for (const [key, value] of Object.entries(pathParams)) { - resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value))); + pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value))); + } + + return pathValue; +} + +function withQuery(pathValue: string, query: Record): string { + const entries = Object.entries(query).filter(([, value]) => value !== undefined); + if (entries.length === 0) { + return pathValue; + } + + const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString(); + return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`; +} + +async function send(operation: ApiOperation, options: RequestOptions): Promise { + const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {}); + + const headers: Record = { + "Accept": "application/json" + }; + + let body: string | undefined; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(options.body); } const response = await fetch(resolvedPath, { method: operation.method, - headers: { - "Accept": "application/json" - } + headers, + body }); if (!response.ok) { @@ -45,13 +199,145 @@ async function send(operation: ApiOperation, pathParams: Record; } -export async function getHealth(): Promise { - return send(apiOperations.getHealth, {}); +export async function activateCharacter(characterId: string): Promise { + return send(apiOperations.activateCharacter, { + pathParams: { characterId: characterId }, + query: undefined, + body: undefined + }); } -export async function rollDice(sides: number): Promise { - return send(apiOperations.rollDice, { sides: sides }); +export async function createCampaign(body: CreateCampaignRequest): Promise { + return send(apiOperations.createCampaign, { + pathParams: {}, + query: undefined, + body: body + }); +} + +export async function createCharacter(body: CreateCharacterRequest): Promise { + return send(apiOperations.createCharacter, { + pathParams: {}, + query: undefined, + body: body + }); +} + +export async function createSkill(characterId: string, body: CreateSkillRequest): Promise { + return send(apiOperations.createSkill, { + pathParams: { characterId: characterId }, + query: undefined, + body: body + }); +} + +export async function getCampaign(campaignId: string): Promise { + return send(apiOperations.getCampaign, { + pathParams: { campaignId: campaignId }, + query: undefined, + body: undefined + }); +} + +export async function getCampaignLog(campaignId: string): Promise> { + return send>(apiOperations.getCampaignLog, { + pathParams: { campaignId: campaignId }, + query: undefined, + body: undefined + }); +} + +export async function getCampaigns(): Promise> { + return send>(apiOperations.getCampaigns, { + pathParams: {}, + query: undefined, + body: undefined + }); +} + +export async function getCurrentCampaignCharacters(): Promise> { + return send>(apiOperations.getCurrentCampaignCharacters, { + pathParams: {}, + query: undefined, + body: undefined + }); +} + +export async function getHealth(): Promise { + return send(apiOperations.getHealth, { + pathParams: {}, + query: undefined, + body: undefined + }); +} + +export async function getMe(): Promise { + return send(apiOperations.getMe, { + pathParams: {}, + query: undefined, + body: undefined + }); +} + +export async function getRulesets(): Promise> { + return send>(apiOperations.getRulesets, { + pathParams: {}, + query: undefined, + body: undefined + }); +} + +export async function loginUser(body: LoginRequest): Promise { + return send(apiOperations.loginUser, { + pathParams: {}, + query: undefined, + body: body + }); +} + +export async function logoutUser(): Promise { + return send(apiOperations.logoutUser, { + pathParams: {}, + query: undefined, + body: undefined + }); +} + +export async function registerUser(body: RegisterRequest): Promise { + return send(apiOperations.registerUser, { + pathParams: {}, + query: undefined, + body: body + }); +} + +export async function rollSkill(skillId: string, body: RollSkillRequest): Promise { + return send(apiOperations.rollSkill, { + pathParams: { skillId: skillId }, + query: undefined, + body: body + }); +} + +export async function updateCharacter(characterId: string, body: UpdateCharacterRequest): Promise { + return send(apiOperations.updateCharacter, { + pathParams: { characterId: characterId }, + query: undefined, + body: body + }); +} + +export async function updateSkill(skillId: string, body: UpdateSkillRequest): Promise { + return send(apiOperations.updateSkill, { + pathParams: { skillId: skillId }, + query: undefined, + body: body + }); } diff --git a/RpgRoller/wwwroot/app.js b/RpgRoller/wwwroot/app.js index 28429eb..db3b52d 100644 --- a/RpgRoller/wwwroot/app.js +++ b/RpgRoller/wwwroot/app.js @@ -1,42 +1,429 @@ -import { getHealth, rollDice } from "./generated/api-client.js"; -const healthElementRaw = document.getElementById("health"); -const resultElementRaw = document.getElementById("result"); -const formElementRaw = document.getElementById("roll-form"); -const sidesInputRaw = document.getElementById("sides"); -if (!(healthElementRaw instanceof HTMLElement) || - !(resultElementRaw instanceof HTMLElement) || - !(formElementRaw instanceof HTMLFormElement) || - !(sidesInputRaw instanceof HTMLInputElement)) { - throw new Error("Required UI elements are missing from index.html."); +import { activateCharacter, createCampaign, createCharacter, createSkill, getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js"; +const healthElement = mustElement("health"); +const messageElement = mustElement("message"); +const campaignMetaElement = mustElement("campaign-meta"); +const campaignDetailsElement = mustElement("campaign-details"); +const rollResultElement = mustElement("roll-result"); +const campaignLogElement = mustElement("campaign-log"); +const registerForm = mustForm("register-form"); +const loginForm = mustForm("login-form"); +const logoutButton = mustButton("logout-button"); +const registerUsername = mustInput("register-username"); +const registerDisplayName = mustInput("register-display-name"); +const registerPassword = mustInput("register-password"); +const loginUsername = mustInput("login-username"); +const loginPassword = mustInput("login-password"); +const campaignForm = mustForm("campaign-form"); +const campaignNameInput = mustInput("campaign-name"); +const campaignRulesetSelect = mustSelect("campaign-ruleset"); +const campaignSelect = mustSelect("campaign-select"); +const refreshCampaignButton = mustButton("refresh-campaign-button"); +const characterForm = mustForm("character-form"); +const characterNameInput = mustInput("character-name"); +const characterSelect = mustSelect("character-select"); +const activateCharacterButton = mustButton("activate-character-button"); +const skillForm = mustForm("skill-form"); +const skillNameInput = mustInput("skill-name"); +const skillExpressionInput = mustInput("skill-expression"); +const createSkillButton = mustButton("create-skill-button"); +const updateSkillButton = mustButton("update-skill-button"); +const skillSelect = mustSelect("skill-select"); +const rollForm = mustForm("roll-form"); +const rollVisibilitySelect = mustSelect("roll-visibility"); +const state = { + user: null, + campaigns: [], + selectedCampaignId: null, + selectedCampaign: null, + campaignLog: [], + rulesets: [], + eventSource: null +}; +registerForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runAction(async () => { + await registerUser({ + username: registerUsername.value.trim(), + displayName: registerDisplayName.value.trim(), + password: registerPassword.value + }); + registerPassword.value = ""; + setMessage("Registration successful. You can log in now.", false); + }); +}); +loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runAction(async () => { + await loginUser({ + username: loginUsername.value.trim(), + password: loginPassword.value + }); + loginPassword.value = ""; + await reloadAll(); + setMessage("Logged in.", false); + }); +}); +logoutButton.addEventListener("click", async () => { + await runAction(async () => { + await logoutUser(); + resetStateAfterLogout(); + renderAll(); + setMessage("Logged out.", false); + }); +}); +campaignForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runAction(async () => { + const createdCampaign = await createCampaign({ + name: campaignNameInput.value.trim(), + rulesetId: campaignRulesetSelect.value + }); + campaignNameInput.value = ""; + await reloadCampaigns(createdCampaign.id); + setMessage("Campaign created.", false); + }); +}); +campaignSelect.addEventListener("change", async () => { + await runAction(async () => { + const selected = campaignSelect.value; + state.selectedCampaignId = selected.length > 0 ? selected : null; + await reloadSelectedCampaign(); + connectStateEvents(); + renderAll(); + }); +}); +refreshCampaignButton.addEventListener("click", async () => { + await runAction(async () => { + await reloadSelectedCampaign(); + renderAll(); + setMessage("Campaign refreshed.", false); + }); +}); +characterForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runAction(async () => { + if (!state.selectedCampaignId) { + throw new Error("Select a campaign first."); + } + await createCharacter({ + name: characterNameInput.value.trim(), + campaignId: state.selectedCampaignId + }); + characterNameInput.value = ""; + await reloadSelectedCampaign(); + await reloadCampaignLog(); + setMessage("Character created.", false); + }); +}); +activateCharacterButton.addEventListener("click", async () => { + await runAction(async () => { + const characterId = characterSelect.value; + if (characterId.length === 0) { + throw new Error("Select a character to activate."); + } + await activateCharacter(characterId); + await reloadAll(); + setMessage("Active character updated.", false); + }); +}); +skillForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runAction(async () => { + const characterId = characterSelect.value; + if (characterId.length === 0) { + throw new Error("Select a character first."); + } + await createSkill(characterId, { + name: skillNameInput.value.trim(), + diceRollDefinition: skillExpressionInput.value.trim() + }); + skillNameInput.value = ""; + skillExpressionInput.value = ""; + await reloadSelectedCampaign(); + setMessage("Skill created.", false); + }); +}); +updateSkillButton.addEventListener("click", async () => { + await runAction(async () => { + const selectedSkillId = skillSelect.value; + if (selectedSkillId.length === 0) { + throw new Error("Select a skill to update."); + } + await updateSkill(selectedSkillId, { + name: skillNameInput.value.trim(), + diceRollDefinition: skillExpressionInput.value.trim() + }); + await reloadSelectedCampaign(); + setMessage("Skill updated.", false); + }); +}); +rollForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runAction(async () => { + const selectedSkillId = skillSelect.value; + if (selectedSkillId.length === 0) { + throw new Error("Select a skill to roll."); + } + const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value }); + rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; + await reloadCampaignLog(); + setMessage("Roll recorded.", false); + }); +}); +await runAction(async () => { + await refreshHealth(); + await reloadAll(); + setMessage("Ready.", false); +}); +async function refreshHealth() { + const health = await getHealth(); + healthElement.textContent = `API status: ${health.status}`; } -const healthElement = healthElementRaw; -const resultElement = resultElementRaw; -const formElement = formElementRaw; -const sidesInput = sidesInputRaw; -function errorMessage(error) { +async function reloadAll() { + await ensureRulesets(); + await reloadSession(); + if (state.user) { + await reloadCampaigns(state.selectedCampaignId); + await reloadSelectedCampaign(); + await reloadCampaignLog(); + connectStateEvents(); + } + else { + resetAuthenticatedState(); + } + renderAll(); +} +async function reloadSession() { + try { + const me = await getMe(); + state.user = me.user; + state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId; + } + catch { + state.user = null; + } +} +async function ensureRulesets() { + if (state.rulesets.length > 0) { + return; + } + state.rulesets = await getRulesets(); + campaignRulesetSelect.innerHTML = state.rulesets + .map((ruleset) => ``) + .join(""); +} +async function reloadCampaigns(preferredCampaignId) { + state.campaigns = await getCampaigns(); + if (state.campaigns.length === 0) { + state.selectedCampaignId = null; + return; + } + const availableIds = new Set(state.campaigns.map((campaign) => campaign.id)); + if (preferredCampaignId && availableIds.has(preferredCampaignId)) { + state.selectedCampaignId = preferredCampaignId; + return; + } + if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) { + return; + } + state.selectedCampaignId = state.campaigns[0].id; +} +async function reloadSelectedCampaign() { + if (!state.selectedCampaignId) { + state.selectedCampaign = null; + return; + } + state.selectedCampaign = await getCampaign(state.selectedCampaignId); +} +async function reloadCampaignLog() { + if (!state.selectedCampaignId) { + state.campaignLog = []; + return; + } + state.campaignLog = await getCampaignLog(state.selectedCampaignId); +} +function connectStateEvents() { + if (!state.selectedCampaignId || !state.user) { + closeStateEvents(); + return; + } + if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) { + return; + } + closeStateEvents(); + state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`); + state.eventSource.addEventListener("state", () => { + void runAction(async () => { + await reloadSelectedCampaign(); + await reloadCampaignLog(); + renderAll(); + }); + }); + state.eventSource.onerror = () => { + closeStateEvents(); + }; +} +function closeStateEvents() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } +} +function resetStateAfterLogout() { + state.user = null; + resetAuthenticatedState(); + closeStateEvents(); +} +function resetAuthenticatedState() { + state.campaigns = []; + state.selectedCampaignId = null; + state.selectedCampaign = null; + state.campaignLog = []; +} +function renderAll() { + renderCampaignSelect(); + renderCampaignMeta(); + renderCampaignDetails(); + renderCharacterSelect(); + renderSkillSelect(); + renderCampaignLog(); +} +function renderCampaignSelect() { + campaignSelect.innerHTML = state.campaigns + .map((campaign) => { + const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; + return ``; + }) + .join(""); +} +function renderCampaignMeta() { + if (!state.user) { + campaignMetaElement.textContent = "Log in to manage campaigns."; + return; + } + if (!state.selectedCampaign) { + campaignMetaElement.textContent = "No campaign selected."; + return; + } + const isGm = state.selectedCampaign.gm.id === state.user.id; + campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}`; +} +function renderCampaignDetails() { + if (!state.selectedCampaign) { + campaignDetailsElement.textContent = "No details available."; + return; + } + const characters = state.selectedCampaign.characters + .map((character) => `
  • ${character.name} (${character.id})
  • `) + .join(""); + const skills = state.selectedCampaign.skills + .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) + .join(""); + campaignDetailsElement.innerHTML = ` +

    GM: ${state.selectedCampaign.gm.displayName}

    +

    Characters visible to you: ${state.selectedCampaign.characters.length}

    +
      ${characters}
    +

    Skills visible to you: ${state.selectedCampaign.skills.length}

    +
      ${skills}
    + `; +} +function renderCharacterSelect() { + if (!state.selectedCampaign) { + characterSelect.innerHTML = ""; + return; + } + const options = state.selectedCampaign.characters + .map((character) => ``) + .join(""); + characterSelect.innerHTML = options; +} +function renderSkillSelect() { + if (!state.selectedCampaign) { + skillSelect.innerHTML = ""; + return; + } + const options = state.selectedCampaign.skills + .map((skill) => ``) + .join(""); + skillSelect.innerHTML = options; + const selectedSkill = selectedSkillFromCampaign(); + if (selectedSkill) { + skillNameInput.value = selectedSkill.name; + skillExpressionInput.value = selectedSkill.diceRollDefinition; + } +} +function selectedSkillFromCampaign() { + if (!state.selectedCampaign) { + return null; + } + const selectedSkillId = skillSelect.value; + return state.selectedCampaign.skills.find((skill) => skill.id === selectedSkillId) ?? null; +} +function renderCampaignLog() { + if (state.campaignLog.length === 0) { + campaignLogElement.innerHTML = "
  • No rolls yet.
  • "; + return; + } + campaignLogElement.innerHTML = state.campaignLog + .map((entry) => ` +
  • + ${entry.visibility.toUpperCase()} + ${entry.breakdown} + ${new Date(entry.timestampUtc).toLocaleString()} +
  • + `) + .join(""); +} +async function runAction(action) { + try { + await action(); + } + catch (error) { + setMessage(formatError(error), true); + } +} +function setMessage(message, isError) { + messageElement.textContent = message; + messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8"; +} +function formatError(error) { if (error instanceof Error) { return error.message; } return String(error); } -async function refreshHealth() { - try { - const health = await getHealth(); - healthElement.textContent = `API status: ${health.status}`; - } - catch (error) { - healthElement.textContent = `API status check failed: ${errorMessage(error)}`; +function mustElement(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLElement)) { + throw new Error(`Missing HTMLElement: ${id}`); } + return element; } -formElement.addEventListener("submit", async (event) => { - event.preventDefault(); - const sides = Number.parseInt(sidesInput.value, 10); - try { - const roll = await rollDice(sides); - resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`; +function mustInput(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLInputElement)) { + throw new Error(`Missing HTMLInputElement: ${id}`); } - catch (error) { - resultElement.textContent = `Roll failed: ${errorMessage(error)}`; + return element; +} +function mustSelect(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLSelectElement)) { + throw new Error(`Missing HTMLSelectElement: ${id}`); } -}); -await refreshHealth(); + return element; +} +function mustForm(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLFormElement)) { + throw new Error(`Missing HTMLFormElement: ${id}`); + } + return element; +} +function mustButton(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLButtonElement)) { + throw new Error(`Missing HTMLButtonElement: ${id}`); + } + return element; +} diff --git a/RpgRoller/wwwroot/generated/api-client.js b/RpgRoller/wwwroot/generated/api-client.js index c709498..b83f2dd 100644 --- a/RpgRoller/wwwroot/generated/api-client.js +++ b/RpgRoller/wwwroot/generated/api-client.js @@ -1,18 +1,52 @@ /* This file is generated by scripts/generate-api-client.mjs. */ export const apiOperations = { - getHealth: { method: "GET", path: "/api/health" }, - rollDice: { method: "GET", path: "/api/roll/{sides}" } + activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true }, + createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true }, + createCharacter: { method: "POST", path: "/api/characters", expectsJson: true }, + createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true }, + getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true }, + getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true }, + getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true }, + getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true }, + getHealth: { method: "GET", path: "/api/health", expectsJson: true }, + getMe: { method: "GET", path: "/api/me", expectsJson: true }, + getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true }, + loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true }, + logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false }, + registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true }, + rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true }, + updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true }, + updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true } }; -async function send(operation, pathParams = {}) { - let resolvedPath = operation.path; +function withPathParams(pathTemplate, pathParams) { + let pathValue = pathTemplate; for (const [key, value] of Object.entries(pathParams)) { - resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value))); + pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value))); + } + return pathValue; +} +function withQuery(pathValue, query) { + const entries = Object.entries(query).filter(([, value]) => value !== undefined); + if (entries.length === 0) { + return pathValue; + } + const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString(); + return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`; +} +async function send(operation, options) { + const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {}); + const headers = { + "Accept": "application/json" + }; + let body; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(options.body); } const response = await fetch(resolvedPath, { method: operation.method, - headers: { - "Accept": "application/json" - } + headers, + body }); if (!response.ok) { const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." })); @@ -21,11 +55,127 @@ async function send(operation, pathParams = {}) { } throw new Error(`Request failed with status ${response.status}`); } + if (!operation.expectsJson) { + return undefined; + } return response.json(); } +export async function activateCharacter(characterId) { + return send(apiOperations.activateCharacter, { + pathParams: { characterId: characterId }, + query: undefined, + body: undefined + }); +} +export async function createCampaign(body) { + return send(apiOperations.createCampaign, { + pathParams: {}, + query: undefined, + body: body + }); +} +export async function createCharacter(body) { + return send(apiOperations.createCharacter, { + pathParams: {}, + query: undefined, + body: body + }); +} +export async function createSkill(characterId, body) { + return send(apiOperations.createSkill, { + pathParams: { characterId: characterId }, + query: undefined, + body: body + }); +} +export async function getCampaign(campaignId) { + return send(apiOperations.getCampaign, { + pathParams: { campaignId: campaignId }, + query: undefined, + body: undefined + }); +} +export async function getCampaignLog(campaignId) { + return send(apiOperations.getCampaignLog, { + pathParams: { campaignId: campaignId }, + query: undefined, + body: undefined + }); +} +export async function getCampaigns() { + return send(apiOperations.getCampaigns, { + pathParams: {}, + query: undefined, + body: undefined + }); +} +export async function getCurrentCampaignCharacters() { + return send(apiOperations.getCurrentCampaignCharacters, { + pathParams: {}, + query: undefined, + body: undefined + }); +} export async function getHealth() { - return send(apiOperations.getHealth, {}); + return send(apiOperations.getHealth, { + pathParams: {}, + query: undefined, + body: undefined + }); } -export async function rollDice(sides) { - return send(apiOperations.rollDice, { sides: sides }); +export async function getMe() { + return send(apiOperations.getMe, { + pathParams: {}, + query: undefined, + body: undefined + }); +} +export async function getRulesets() { + return send(apiOperations.getRulesets, { + pathParams: {}, + query: undefined, + body: undefined + }); +} +export async function loginUser(body) { + return send(apiOperations.loginUser, { + pathParams: {}, + query: undefined, + body: body + }); +} +export async function logoutUser() { + return send(apiOperations.logoutUser, { + pathParams: {}, + query: undefined, + body: undefined + }); +} +export async function registerUser(body) { + return send(apiOperations.registerUser, { + pathParams: {}, + query: undefined, + body: body + }); +} +export async function rollSkill(skillId, body) { + return send(apiOperations.rollSkill, { + pathParams: { skillId: skillId }, + query: undefined, + body: body + }); +} +export async function updateCharacter(characterId, body) { + return send(apiOperations.updateCharacter, { + pathParams: { characterId: characterId }, + query: undefined, + body: body + }); +} +export async function updateSkill(skillId, body) { + return send(apiOperations.updateSkill, { + pathParams: { skillId: skillId }, + query: undefined, + body: body + }); } diff --git a/RpgRoller/wwwroot/index.html b/RpgRoller/wwwroot/index.html index dc6dccb..8fcb494 100644 --- a/RpgRoller/wwwroot/index.html +++ b/RpgRoller/wwwroot/index.html @@ -10,14 +10,80 @@

    RpgRoller

    Checking API status...

    +

    -
    - - - -
    +
    +

    Authentication

    +
    + + + + +
    +
    + + + +
    + +
    -

    No roll yet.

    +
    +

    Campaigns

    +
    + + + +
    +
    + + +
    +

    +
    + +
    +

    Characters

    +
    + + +
    +
    + + +
    +
    + +
    +

    Skills

    +
    + + +
    + + +
    +
    +
    + + + +
    +

    No roll yet.

    +
    + +
    +

    Campaign Details

    +
    +
    + +
    +

    Campaign Log

    +
      +
      diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index 709db32..fd18b30 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -11,18 +11,34 @@ body { } .layout { - max-width: 32rem; + max-width: 56rem; margin: 0 auto; padding: 2.5rem 1.25rem; + display: grid; + gap: 1rem; } .panel { display: grid; - gap: 0.75rem; - margin-top: 1rem; + gap: 0.6rem; + background: rgba(255, 255, 255, 0.8); + border: 1px solid #c8d2e6; + border-radius: 0.6rem; + padding: 0.9rem; +} + +.grid-form { + display: grid; + gap: 0.5rem; +} + +.inline-controls { + display: flex; + gap: 0.5rem; } input, +select, button { font: inherit; padding: 0.6rem 0.75rem; @@ -40,3 +56,23 @@ button { .result { font-weight: 600; } + +.message { + min-height: 1.5rem; + color: #1d4ed8; + font-weight: 600; +} + +.log-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.35rem; +} + +.log-item { + padding: 0.45rem 0.55rem; + border-radius: 0.35rem; + background: #f6f8fc; +} diff --git a/TECH.md b/TECH.md index a3337df..ad2c019 100644 --- a/TECH.md +++ b/TECH.md @@ -11,6 +11,7 @@ - Generated client output: `RpgRoller/wwwroot/generated/api-client.js` - Local CI parity entrypoint: `scripts/ci-local.ps1` - Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates. +- Current frontend features: authenticated campaign workspace with live log updates and full roll workflow controls. ## 1) Stack and baseline choices diff --git a/openapi/RpgRoller.json b/openapi/RpgRoller.json index 74d078b..3ce7375 100644 --- a/openapi/RpgRoller.json +++ b/openapi/RpgRoller.json @@ -22,29 +22,46 @@ } } }, - "/api/roll/{sides}": { + "/api/rulesets": { "get": { - "operationId": "rollDice", - "parameters": [ - { - "name": "sides", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 2, - "maximum": 1000 - } - } - ], + "operationId": "getRulesets", "responses": { "200": { - "description": "Roll succeeded.", + "description": "Supported rulesets.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RollResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/RulesetDefinition" + } + } + } + } + } + } + } + }, + "/api/auth/register": { + "post": { + "operationId": "registerUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Registered user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSummary" } } } @@ -61,6 +78,557 @@ } } } + }, + "/api/auth/login": { + "post": { + "operationId": "loginUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Logged-in user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSummary" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "operationId": "logoutUser", + "responses": { + "204": { + "description": "Logged out." + } + } + } + }, + "/api/me": { + "get": { + "operationId": "getMe", + "responses": { + "200": { + "description": "Current user context.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/campaigns": { + "get": { + "operationId": "getCampaigns", + "responses": { + "200": { + "description": "Accessible campaigns.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignSummary" + } + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + }, + "post": { + "operationId": "createCampaign", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCampaignRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Created campaign.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignSummary" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/campaigns/{campaignId}": { + "get": { + "operationId": "getCampaign", + "parameters": [ + { + "name": "campaignId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Campaign details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignDetails" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/campaigns/{campaignId}/log": { + "get": { + "operationId": "getCampaignLog", + "parameters": [ + { + "name": "campaignId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Campaign log entries.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignLogEntry" + } + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/characters": { + "post": { + "operationId": "createCharacter", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCharacterRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Created character.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CharacterSummary" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/characters/{characterId}": { + "put": { + "operationId": "updateCharacter", + "parameters": [ + { + "name": "characterId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCharacterRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated character.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CharacterSummary" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/characters/{characterId}/activate": { + "post": { + "operationId": "activateCharacter", + "parameters": [ + { + "name": "characterId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Activation succeeded.", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/characters/current-campaign": { + "get": { + "operationId": "getCurrentCampaignCharacters", + "responses": { + "200": { + "description": "Current campaign characters owned by the user.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CharacterSummary" + } + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/characters/{characterId}/skills": { + "post": { + "operationId": "createSkill", + "parameters": [ + { + "name": "characterId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSkillRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Created skill.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillSummary" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/skills/{skillId}": { + "put": { + "operationId": "updateSkill", + "parameters": [ + { + "name": "skillId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSkillRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated skill.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillSummary" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, + "/api/skills/{skillId}/roll": { + "post": { + "operationId": "rollSkill", + "parameters": [ + { + "name": "skillId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RollSkillRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Roll result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RollResult" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } } }, "components": { @@ -76,23 +644,6 @@ "status" ] }, - "RollResponse": { - "type": "object", - "properties": { - "sides": { - "type": "integer", - "format": "int32" - }, - "value": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "sides", - "value" - ] - }, "ApiError": { "type": "object", "properties": { @@ -103,6 +654,402 @@ "required": [ "error" ] + }, + "RegisterRequest": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "displayName": { + "type": "string" + } + }, + "required": [ + "username", + "password", + "displayName" + ] + }, + "LoginRequest": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] + }, + "UserSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "displayName" + ] + }, + "MeResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserSummary" + }, + "activeCharacterId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "currentCampaignId": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "required": [ + "user" + ] + }, + "RulesetDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "diceSyntax": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "diceSyntax" + ] + }, + "CreateCampaignRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "rulesetId": { + "type": "string" + } + }, + "required": [ + "name", + "rulesetId" + ] + }, + "CampaignSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "rulesetId": { + "type": "string" + }, + "gmUserId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "name", + "rulesetId", + "gmUserId" + ] + }, + "CampaignDetails": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "rulesetId": { + "type": "string" + }, + "gm": { + "$ref": "#/components/schemas/UserSummary" + }, + "characters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CharacterSummary" + } + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillSummary" + } + } + }, + "required": [ + "id", + "name", + "rulesetId", + "gm", + "characters", + "skills" + ] + }, + "CreateCharacterRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "campaignId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "name", + "campaignId" + ] + }, + "UpdateCharacterRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "campaignId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "name", + "campaignId" + ] + }, + "CharacterSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "ownerUserId": { + "type": "string", + "format": "uuid" + }, + "campaignId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "name", + "ownerUserId", + "campaignId" + ] + }, + "CreateSkillRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "diceRollDefinition": { + "type": "string" + } + }, + "required": [ + "name", + "diceRollDefinition" + ] + }, + "UpdateSkillRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "diceRollDefinition": { + "type": "string" + } + }, + "required": [ + "name", + "diceRollDefinition" + ] + }, + "SkillSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "characterId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "diceRollDefinition": { + "type": "string" + } + }, + "required": [ + "id", + "characterId", + "name", + "diceRollDefinition" + ] + }, + "RollSkillRequest": { + "type": "object", + "properties": { + "visibility": { + "type": "string" + } + }, + "required": [ + "visibility" + ] + }, + "RollResult": { + "type": "object", + "properties": { + "rollId": { + "type": "string", + "format": "uuid" + }, + "campaignId": { + "type": "string", + "format": "uuid" + }, + "characterId": { + "type": "string", + "format": "uuid" + }, + "skillId": { + "type": "string", + "format": "uuid" + }, + "rollerUserId": { + "type": "string", + "format": "uuid" + }, + "visibility": { + "type": "string" + }, + "result": { + "type": "integer", + "format": "int32" + }, + "breakdown": { + "type": "string" + }, + "timestampUtc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "rollId", + "campaignId", + "characterId", + "skillId", + "rollerUserId", + "visibility", + "result", + "breakdown", + "timestampUtc" + ] + }, + "CampaignLogEntry": { + "type": "object", + "properties": { + "rollId": { + "type": "string", + "format": "uuid" + }, + "campaignId": { + "type": "string", + "format": "uuid" + }, + "characterId": { + "type": "string", + "format": "uuid" + }, + "skillId": { + "type": "string", + "format": "uuid" + }, + "rollerUserId": { + "type": "string", + "format": "uuid" + }, + "visibility": { + "type": "string" + }, + "result": { + "type": "integer", + "format": "int32" + }, + "breakdown": { + "type": "string" + }, + "timestampUtc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "rollId", + "campaignId", + "characterId", + "skillId", + "rollerUserId", + "visibility", + "result", + "breakdown", + "timestampUtc" + ] } } } diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs index 8682e79..d099571 100644 --- a/scripts/generate-api-client.mjs +++ b/scripts/generate-api-client.mjs @@ -47,7 +47,7 @@ function schemaRefName(schema) { return schema.$ref.substring(refPrefix.length); } -function toTypeScriptType(schema, components, forProperty = false) { +function toTypeScriptType(schema, forProperty = false) { if (!schema || typeof schema !== "object") { return "unknown"; } @@ -63,7 +63,7 @@ function toTypeScriptType(schema, components, forProperty = false) { } if (schema.type === "array") { - const itemType = toTypeScriptType(schema.items, components); + const itemType = toTypeScriptType(schema.items, true); return `Array<${itemType}>`; } @@ -75,7 +75,7 @@ function toTypeScriptType(schema, components, forProperty = false) { const required = new Set(Array.isArray(schema.required) ? schema.required : []); const fields = propertyEntries.map(([name, propertySchema]) => { - const typeName = toTypeScriptType(propertySchema, components, true); + const typeName = toTypeScriptType(propertySchema, true); const optional = required.has(name) ? "" : "?"; return `${name}${optional}: ${typeName};`; }); @@ -88,20 +88,20 @@ function toTypeScriptType(schema, components, forProperty = false) { } if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) { - return schema.oneOf.map((option) => toTypeScriptType(option, components, true)).join(" | "); + return schema.oneOf.map((option) => toTypeScriptType(option, true)).join(" | "); } if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) { - return schema.anyOf.map((option) => toTypeScriptType(option, components, true)).join(" | "); + return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | "); } return "unknown"; } -function schemaToInterface(name, schema, components) { +function schemaToInterface(name, schema) { const required = new Set(Array.isArray(schema.required) ? schema.required : []); const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => { - const typeName = toTypeScriptType(propertySchema, components, true); + const typeName = toTypeScriptType(propertySchema, true); const optional = required.has(propertyName) ? "" : "?"; return ` ${propertyName}${optional}: ${typeName};`; }); @@ -113,26 +113,98 @@ function schemaToInterface(name, schema, components) { return `export interface ${name} {\n${fields.join("\n")}\n}`; } -function collectOperations(document, components) { - const operations = []; - for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) { - const pathLevelParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : []; +function collectParameters(pathItem, operation) { + const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])]; + const deduped = []; + const keys = new Set(); + for (const parameter of rawParameters) { + if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") { + continue; + } + + const key = `${parameter.in}:${parameter.name}`; + if (keys.has(key)) { + continue; + } + + keys.add(key); + + const inferredType = mapSimpleType(parameter.schema) ?? "string"; + deduped.push({ + name: parameter.name, + location: parameter.in, + required: parameter.required === true, + type: inferredType + }); + } + + return deduped; +} + +function resolveRequestBodyType(operation) { + const bodySchema = operation.requestBody?.content?.["application/json"]?.schema; + if (!bodySchema) { + return null; + } + + return { + type: toTypeScriptType(bodySchema, true), + required: operation.requestBody.required === true + }; +} + +function resolveResponse(operation) { + const responses = operation.responses ?? {}; + const successCodes = Object.keys(responses) + .filter((code) => code.startsWith("2")) + .sort(); + + if (successCodes.length === 0) { + return { type: "void", expectsJson: false }; + } + + for (const code of successCodes) { + const jsonSchema = responses[code]?.content?.["application/json"]?.schema; + if (jsonSchema) { + return { type: toTypeScriptType(jsonSchema), expectsJson: true }; + } + + if (code === "204") { + return { type: "void", expectsJson: false }; + } + } + + return { type: "void", expectsJson: false }; +} + +function collectOperations(document) { + const operations = []; + + for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) { for (const [method, operation] of Object.entries(pathItem ?? {})) { - if (operation === null || typeof operation !== "object") { + if (operation === null || typeof operation !== "object" || typeof operation.operationId !== "string") { continue; } - if (typeof operation.operationId !== "string" || operation.operationId.length === 0) { + if (operation.operationId.length === 0) { throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`); } + const parameters = collectParameters(pathItem, operation); + const pathParameters = parameters.filter((parameter) => parameter.location === "path"); + const queryParameters = parameters.filter((parameter) => parameter.location === "query"); + const requestBody = resolveRequestBodyType(operation); + const response = resolveResponse(operation); + operations.push({ operationId: operation.operationId, method: method.toUpperCase(), path: pathKey, - parameters: collectPathParameters([...pathLevelParameters, ...(operation.parameters ?? [])]), - responseType: resolveResponseType(operation, components) + pathParameters, + queryParameters, + requestBody, + response }); } } @@ -144,55 +216,8 @@ function collectOperations(document, components) { return operations.sort((left, right) => left.operationId.localeCompare(right.operationId)); } -function collectPathParameters(parameters) { - const seen = new Set(); - const result = []; - - for (const parameter of parameters) { - if (!parameter || parameter.in !== "path" || typeof parameter.name !== "string") { - continue; - } - - if (seen.has(parameter.name)) { - continue; - } - - seen.add(parameter.name); - - const simpleType = mapSimpleType(parameter.schema); - result.push({ - name: parameter.name, - type: simpleType ?? "string" - }); - } - - return result; -} - -function resolveResponseType(operation, components) { - const responses = operation.responses ?? {}; - const successCodes = Object.keys(responses) - .filter((code) => code.startsWith("2")) - .sort(); - - if (successCodes.length === 0) { - return "void"; - } - - for (const code of successCodes) { - const content = responses[code]?.content?.["application/json"]; - const schema = content?.schema; - if (schema) { - return toTypeScriptType(schema, components); - } - } - - return "void"; -} - function buildSchemaTypes(document) { - const components = document.components ?? {}; - const schemas = components.schemas ?? {}; + const schemas = document.components?.schemas ?? {}; const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right)); const declarations = schemaEntries.map(([name, schema]) => { @@ -200,21 +225,62 @@ function buildSchemaTypes(document) { return `export type ${name} = unknown;`; } - return schemaToInterface(name, schema, components); + return schemaToInterface(name, schema); }); return declarations.join("\n\n"); } +function buildFunctionSource(operation) { + const pathParameters = operation.pathParameters; + const queryParameters = operation.queryParameters; + const body = operation.requestBody; + + const parts = []; + if (pathParameters.length > 0) { + parts.push(...pathParameters.map((parameter) => `${parameter.name}: ${parameter.type}`)); + } + + if (body) { + const optional = body.required ? "" : "?"; + parts.push(`body${optional}: ${body.type}`); + } + + if (queryParameters.length > 0) { + const queryType = queryParameters + .map((parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type};`) + .join(" "); + parts.push(`query: { ${queryType} }`); + } + + const signature = parts.join(", "); + + const pathParameterAssignment = pathParameters.length === 0 + ? "{}" + : `{ ${pathParameters.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`; + + const bodyArgument = body ? "body" : "undefined"; + const queryArgument = queryParameters.length > 0 ? "query" : "undefined"; + + return `export async function ${operation.operationId}(${signature}): Promise<${operation.response.type}> {\n return send<${operation.response.type}>(apiOperations.${operation.operationId}, {\n pathParams: ${pathParameterAssignment},\n query: ${queryArgument},\n body: ${bodyArgument}\n });\n}`; +} + function buildClientSource(operations, schemaTypes) { const operationEntries = operations - .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`) + .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`) .join(",\n"); const helper = ` type ApiOperation = { method: string; path: string; + expectsJson: boolean; +}; + +type RequestOptions = { + pathParams?: Record; + query?: Record; + body?: unknown; }; export const apiOperations = { @@ -223,17 +289,42 @@ ${operationEntries} `.trim(); const sendFunction = ` -async function send(operation: ApiOperation, pathParams: Record = {}): Promise { - let resolvedPath = operation.path; +function withPathParams(pathTemplate: string, pathParams: Record): string { + let pathValue = pathTemplate; for (const [key, value] of Object.entries(pathParams)) { - resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value))); + pathValue = pathValue.replace(\`{\${key}}\`, encodeURIComponent(String(value))); + } + + return pathValue; +} + +function withQuery(pathValue: string, query: Record): string { + const entries = Object.entries(query).filter(([, value]) => value !== undefined); + if (entries.length === 0) { + return pathValue; + } + + const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString(); + return queryString.length === 0 ? pathValue : \`\${pathValue}?\${queryString}\`; +} + +async function send(operation: ApiOperation, options: RequestOptions): Promise { + const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {}); + + const headers: Record = { + "Accept": "application/json" + }; + + let body: string | undefined; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(options.body); } const response = await fetch(resolvedPath, { method: operation.method, - headers: { - "Accept": "application/json" - } + headers, + body }); if (!response.ok) { @@ -245,31 +336,22 @@ async function send(operation: ApiOperation, pathParams: Record; } `.trim(); - const exports = operations - .map((operation) => { - const params = operation.parameters; - const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", "); - const pathParams = params.length === 0 - ? "{}" - : `{ ${params.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`; - - const responseType = operation.responseType ?? "unknown"; - - return `export async function ${operation.operationId}(${signature}): Promise<${responseType}> {\n return send<${responseType}>(apiOperations.${operation.operationId}, ${pathParams});\n}`; - }) - .join("\n\n"); + const exports = operations.map(buildFunctionSource).join("\n\n"); return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`; } const openApiText = await readFile(openApiPath, "utf8"); const document = JSON.parse(openApiText); -const components = document.components ?? {}; -const operations = collectOperations(document, components); +const operations = collectOperations(document); const schemaTypes = buildSchemaTypes(document); const clientSource = buildClientSource(operations, schemaTypes);