import type { ApiErrorPayload, Organization, OrganizationDetailParams, OrganizationListParams, OrganizationListResponse, QueryListValue, JsonValue, } from "./types.js"; export type FetchLike = ( input: RequestInfo, init?: RequestInit, ) => Promise; export interface MostovikOrganizationsClientOptions { baseUrl: string | URL; fetch?: FetchLike; headers?: HeadersInit; accessToken?: string; } export interface ApiRequestOptions { headers?: HeadersInit; signal?: AbortSignal; } export class ApiClientError extends Error { readonly status: number; readonly payload: ApiErrorPayload; constructor(message: string, status: number, payload: ApiErrorPayload) { super(message); this.name = "ApiClientError"; this.status = status; this.payload = payload; } } type QueryParamValue = string | number | boolean | null | undefined; type QueryParam = QueryParamValue | readonly QueryParamValue[]; type QueryParams = Record; export class MostovikOrganizationsClient { private readonly baseUrl: string; private readonly fetchImpl: FetchLike; private readonly defaultHeaders: HeadersInit | undefined; private readonly accessToken: string | undefined; constructor(options: MostovikOrganizationsClientOptions) { this.baseUrl = normalizeBaseUrl(options.baseUrl); this.fetchImpl = options.fetch ?? defaultFetch(); this.defaultHeaders = options.headers; this.accessToken = options.accessToken; } listOrganizations( params: OrganizationListParams = {}, options: ApiRequestOptions = {}, ): Promise { return this.request( "api/v2/organizations/", params as QueryParams, options, ); } getOrganization( uid: string, params: OrganizationDetailParams = {}, options: ApiRequestOptions = {}, ): Promise { return this.request( `api/v2/organizations/${encodeURIComponent(uid)}/`, params as QueryParams, options, ); } private async request( path: string, params: QueryParams, options: ApiRequestOptions, ): Promise { const url = this.buildUrl(path, params); const init: RequestInit = { method: "GET", headers: this.buildHeaders(options.headers), }; if (options.signal !== undefined) { init.signal = options.signal; } const response = await this.fetchImpl(url.toString(), init); const payload = await parseResponsePayload(response); if (!response.ok) { const errorPayload = toApiErrorPayload(payload, response.status); throw new ApiClientError( `GET ${url.pathname} failed with HTTP ${response.status}`, response.status, errorPayload, ); } return payload as T; } private buildUrl(path: string, params: QueryParams): URL { const url = new URL(path, this.baseUrl); appendQueryParams(url, params); return url; } private buildHeaders(requestHeaders?: HeadersInit): Headers { const headers = new Headers(this.defaultHeaders); headers.set("Accept", "application/json"); if (this.accessToken !== undefined && !headers.has("Authorization")) { headers.set("Authorization", `Bearer ${this.accessToken}`); } if (requestHeaders !== undefined) { new Headers(requestHeaders).forEach((value, key) => { headers.set(key, value); }); } return headers; } } function normalizeBaseUrl(baseUrl: string | URL): string { const value = String(baseUrl); return value.endsWith("/") ? value : `${value}/`; } function defaultFetch(): FetchLike { if (typeof globalThis.fetch !== "function") { throw new Error( "No fetch implementation is available. Pass fetch in client options.", ); } return (input, init) => globalThis.fetch(input, init); } function appendQueryParams(url: URL, params: QueryParams): void { Object.entries(params).forEach(([key, value]) => { if (isQueryParamArray(value)) { value.forEach((item) => appendQueryParam(url, key, item)); return; } appendQueryParam(url, key, value); }); } function isQueryParamArray(value: QueryParam): value is readonly QueryParamValue[] { return Array.isArray(value); } function appendQueryParam(url: URL, key: string, value: QueryParamValue): void { if (value === undefined || value === null) { return; } url.searchParams.append(key, String(value)); } async function parseResponsePayload(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; const body = await response.text(); if (body === "") { return null; } if (contentType.includes("application/json")) { return JSON.parse(body) as JsonValue; } try { return JSON.parse(body) as JsonValue; } catch { return body; } } function toApiErrorPayload( payload: JsonValue | null, status: number, ): ApiErrorPayload { if ( isJsonObject(payload) && payload.success === false && payload.data === null && Array.isArray(payload.errors) ) { const meta = payload.meta ?? null; return { success: false, data: null, errors: payload.errors.map(toApiErrorDetail), meta: isJsonObject(meta) ? meta : null, }; } return { success: false, data: null, errors: [ { code: `http_${status}`, message: "HTTP request failed", details: { payload, }, }, ], meta: null, }; } function toApiErrorDetail(value: JsonValue): { code: string; message: string; details?: { readonly [key: string]: JsonValue }; } { if (isJsonObject(value)) { const details = value.details ?? null; const code = typeof value.code === "string" ? value.code : "error"; const message = typeof value.message === "string" ? value.message : JSON.stringify(value); if (isJsonObject(details)) { return { code, message, details, }; } return { code, message, }; } return { code: "error", message: String(value), }; } function isJsonObject(value: JsonValue | null): value is { readonly [key: string]: JsonValue; } { return typeof value === "object" && value !== null && !Array.isArray(value); }