import type { Project } from '../model/Project'; import { statusMessage } from './http'; import { type LoadedProject, type ProjectStore, type ProjectSummary, StaleRevisionError, } from './ProjectStore'; import { deserializeProject, serializeProject } from './serialize'; /** Routing goes through the `r` query parameter because the site's nginx config rejects PATH_INFO. */ export class HttpProjectStore implements ProjectStore { constructor(private readonly endpoint: string) {} async list(): Promise { return this.request('GET', 'projects'); } async load(id: string): Promise { const body = await this.request<{ revision: number; project: unknown }>( 'GET', `projects/${encodeURIComponent(id)}`, ); return { project: deserializeProject(body.project, id), revision: body.revision }; } async save(project: Project, revision: number): Promise { const body = await this.request<{ revision: number }>( 'PUT', `projects/${encodeURIComponent(project.id)}`, { revision, project: serializeProject(project), }, ); return body.revision; } async create(name: string): Promise { const body = await this.request<{ revision: number; project: unknown }>('POST', 'projects', { name }); const project = deserializeProject(body.project); return { project, revision: body.revision }; } async delete(id: string): Promise { await this.request('DELETE', `projects/${encodeURIComponent(id)}`); } /** 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */ private async request(method: string, route: string, payload?: unknown): Promise { const url = `${this.endpoint}?r=${encodeURIComponent(route)}`; const response = await fetch(url, { method, headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {}, body: payload !== undefined ? JSON.stringify(payload) : undefined, }); if (response.status === 204) return undefined as T; const text = await response.text(); let data: unknown; try { data = text ? JSON.parse(text) : null; } catch { // nginx rejects an oversized body itself and answers with an HTML page, so a failure is // explained by its status rather than as "invalid JSON". if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`); data = null; } if (response.status === 409) { const rev = (data as { revision?: number } | null)?.revision ?? -1; throw new StaleRevisionError(rev); } if (!response.ok) { const message = (data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText); throw new Error(`API ${method} ${route}: ${message} (${response.status})`); } return data as T; } }