commit 262a56c2e587559e7eff7e8bd3ae5e4710bdf81a Author: Giuliano Silvestro Date: Thu Apr 30 08:26:34 2026 +1000 init: initial commit Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1403132 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +*.log +dist/ +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..79987f4 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# @crema/arcadia-client + +Typed HTTP client + React bindings for the [arcadia](https://git.sky-ai.com/CremaUIStudio/arcadia-app) Phoenix API. Wraps the OpenAPI-spec'd surface at `/api/v1` with auth (Bearer JWT and/or service-account API key), tenant context (`X-Tenant-ID`), idempotency keys, error normalization, and rate-limit-aware retry. + +The client is **stateless about how the JWT is obtained** — callers pass `getToken` so token refresh and storage stay app-owned. + +## Public API + +```ts +import { + createArcadiaClient, + ArcadiaProvider, + useArcadia, + useArcadiaClient, + ArcadiaError, +} from "@crema/arcadia-client"; +``` + +## Usage in a Crema app + +In `app/root.tsx`, wrap providers: + +```tsx +import { ArcadiaProvider } from "@crema/arcadia-client"; + + sessionStorage.getItem("arcadia_token")} + onUnauthorized={() => navigate("/login")} +> + {children} +; +``` + +In any component: + +```tsx +import { useArcadiaClient, ArcadiaError } from "@crema/arcadia-client"; + +function ResourceList() { + const arcadia = useArcadiaClient(); + const [items, setItems] = useState([]); + + useEffect(() => { + arcadia + .GET<{ data: Resource[] }>("/api/v1/digital_objects", { params: { page: 1 } }) + .then((res) => setItems(res.data)) + .catch((err: ArcadiaError) => { + if (err.isRateLimited) showToast("Too many requests, slow down."); + }); + }, [arcadia]); +} +``` + +## Generated types + +Endpoint request/response types come from arcadia's live OpenAPI spec. Regenerate with: + +```bash +ARCADIA_OPENAPI_URL=http://localhost:4000/api/openapi \ + node ../lib-arcadia-client/scripts/sync-spec.mjs +``` + +Requires `openapi-typescript` in the consuming app's devDeps: + +```bash +npm i -D openapi-typescript +``` + +The generated file lives at `src/generated/openapi.d.ts`. Until it's been generated at least once, the file is a stub and only the hand-written types in `src/types.ts` are useful. + +## Two surfaces: generic + typed + +The client gives you both a generic-string API and a fully typed (OpenAPI-driven) API. They share the same auth/retry/error plumbing. + +```ts +const arcadia = useArcadiaClient(); + +// Generic — accepts any string path. Use when the spec is incomplete or +// when calling endpoints outside the spec. +const res = await arcadia.GET<{ data: Resource[] }>( + "/api/v1/digital_objects", + { params: { page: 1 } }, +); + +// Typed — paths, params, and responses inferred from the generated spec. +// Throws ArcadiaError on non-2xx. +const { data } = await arcadia.typed.GET("/api/v1/digital_objects", { + params: { query: { page: 1 } }, +}); +``` + +## Realtime + +Phoenix Channels at `/socket/tenant`, opt-in via the provider. The socket auto-connects when `enableRealtime` is on, joins `tenant:` (and optionally `tenant::user:`), and disconnects on unmount. + +```tsx + sessionStorage.getItem("arcadia_access_token")} +> + … + +``` + +```tsx +import { useArcadiaSubscription } from "@crema/arcadia-client"; + +function NotificationToasts() { + useArcadiaSubscription("notification", (n) => { + toast(n.title, n.body); + }); + return null; +} +``` + +Known events on `TenantEventMap`: `notification`, `digital_object`, `announcement`, `status_update`, `event`. The map is open-ended — apps can subscribe to any string event arcadia emits; payload type defaults to `Record`. + +For user-scoped events (those filtered to the current user), pass `{ scope: "user" }` and ensure `userId` was provided to the provider. + +## What's not in here yet + +- **TanStack Query helpers** — opt-in. Vibespace doesn't use Query today; we can layer it via a sub-export later. +- **Token refresh helper** — the client surfaces 401 via `onUnauthorized`; the app decides whether to refresh + retry. A reference refresh helper may land later. + +## Conventions + +- Inline imports only — no own `package.json` (lib lives by the consuming app's deps). +- Path-aliased into apps via `tsconfig.json` `paths`: `@crema/arcadia-client` → `../lib-arcadia-client/src/index.tsx`. +- Tailwind doesn't scan this lib — no UI; nothing to scan. diff --git a/scripts/sync-spec.mjs b/scripts/sync-spec.mjs new file mode 100644 index 0000000..abe3e94 --- /dev/null +++ b/scripts/sync-spec.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// Fetches the arcadia OpenAPI spec and regenerates ../src/generated/openapi.d.ts. +// +// Usage (from a consuming app, with arcadia reachable): +// node ../lib-arcadia-client/scripts/sync-spec.mjs +// +// Configurable via env: +// ARCADIA_OPENAPI_URL default: http://localhost:4000/api/openapi +// ARCADIA_BEARER_TOKEN optional, sent as Authorization if the spec route +// is gated in your deployment +// +// Requires `openapi-typescript` to be available (in the consuming app's +// node_modules, or globally). Run from a consuming app so the dep resolves: +// npm i -D openapi-typescript + +import { writeFile, mkdir } from "node:fs/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; +import { createRequire } from "node:module"; + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(here, "..", "src", "generated"); +const outFile = resolve(outDir, "openapi.d.ts"); +const specUrl = process.env.ARCADIA_OPENAPI_URL ?? "http://localhost:4000/api/openapi"; + +async function main() { + console.log(`→ fetching ${specUrl}`); + const headers = {}; + if (process.env.ARCADIA_BEARER_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.ARCADIA_BEARER_TOKEN}`; + } + const res = await fetch(specUrl, { headers }); + if (!res.ok) { + console.error(`spec fetch failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + const spec = await res.json(); + + // Resolve openapi-typescript from the consuming app's node_modules, since + // the lib has no package.json of its own. + let openapiTs; + let astToString; + try { + const requireFromCwd = createRequire(resolve(process.cwd(), "package.json")); + const resolved = requireFromCwd.resolve("openapi-typescript"); + const mod = await import(pathToFileURL(resolved).href); + // Handle both ESM (mod.default is the function) and CJS-via-import + // (mod.default is the namespace, mod.default.default is the function). + openapiTs = typeof mod.default === "function" ? mod.default : mod.default?.default; + astToString = mod.astToString ?? mod.default?.astToString; + } catch (err) { + console.error( + `openapi-typescript could not be resolved from ${process.cwd()}.\n` + + `Run from your consuming app's directory after installing it:\n` + + ` cd && npm i -D openapi-typescript\n` + + `Original error: ${err.message}`, + ); + process.exit(1); + } + + // Scrub malformed operation entries. Arcadia's spec contains some + // endpoints whose operation value is the bare string "ok" (a controller + // placeholder that never got replaced with a real OperationObject). + // openapi-typescript can't transform them — strip them and report so + // the generation succeeds for the rest. Fix is on the arcadia side. + const skipped = []; + if (spec.paths) { + for (const [path, item] of Object.entries(spec.paths)) { + if (!item || typeof item !== "object") continue; + for (const [verb, op] of Object.entries(item)) { + if (typeof op !== "object" || op === null) { + skipped.push(`${verb.toUpperCase()} ${path}`); + delete item[verb]; + } + } + } + } + if (skipped.length) { + console.warn(`⚠ skipped ${skipped.length} malformed operations (arcadia spec bug):`); + for (const s of skipped.slice(0, 10)) console.warn(` - ${s}`); + if (skipped.length > 10) console.warn(` …and ${skipped.length - 10} more`); + } + + console.log(`→ generating types`); + const ast = await openapiTs(spec); + const dts = astToString(ast); + await mkdir(outDir, { recursive: true }); + const banner = + "// AUTO-GENERATED — do not edit by hand. Regenerate with sync-spec.mjs.\n" + + `// Source: ${specUrl}\n` + + `// Generated: ${new Date().toISOString()}\n\n`; + await writeFile(outFile, banner + dts, "utf8"); + console.log(`✓ wrote ${outFile}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..c6bdf6c --- /dev/null +++ b/src/client.ts @@ -0,0 +1,209 @@ +// Core HTTP client. Hand-rolled around fetch with hooks for auth, tenant +// context, idempotency, and rate-limit-aware retry. +// +// Two surfaces: +// - client.GET/POST/PUT/PATCH/DELETE — generic, accepts any string path. +// Returns parsed JSON or throws ArcadiaError. Use this for paths that +// aren't in the OpenAPI spec, or when the spec is incomplete (arcadia +// has some "ok"-placeholder operations that don't generate operation +// types — see scripts/sync-spec.mjs). +// - client.typed.GET/POST/... — openapi-fetch-backed, fully typed against +// the generated `paths`. Same auth/retry plumbing. Returns the parsed +// `data` or throws ArcadiaError. +// +// The client is stateless about how the JWT is obtained — callers pass +// `getToken` so refresh and storage stay app-owned. + +import createOpenapiClient, { type Client as OpenapiClient } from "openapi-fetch"; + +import { normalizeErrorResponse, ArcadiaError } from "./errors"; +import type { ArcadiaTenantId } from "./types"; +import type { paths } from "./generated/openapi"; + +export interface ArcadiaClientOptions { + baseUrl: string; + getToken?: () => string | null | Promise; + apiKey?: string; + apiKeyMode?: "fallback" | "always"; + tenantId?: ArcadiaTenantId; + onUnauthorized?: (err: ArcadiaError) => void; + /** Number of retries on 429 / 503. Default 2. Honors Retry-After. */ + maxRetries?: number; + fetch?: typeof fetch; +} + +export interface RequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Query params (URL search params). null/undefined are dropped. */ + params?: Record; + /** Request body. Plain object → JSON. FormData / Blob → sent as-is. */ + body?: unknown; + /** Idempotency key for safe retries on POST/PUT/PATCH/DELETE. */ + idempotencyKey?: string; + headers?: Record; + signal?: AbortSignal; +} + +/** openapi-fetch's typed client, wrapped so methods return data and throw + * ArcadiaError on failure (matching the rest of the API). */ +export type TypedArcadiaClient = OpenapiClient; + +export interface ArcadiaClient { + request(path: string, opts?: RequestOptions): Promise; + GET(path: string, opts?: Omit): Promise; + POST(path: string, opts?: Omit): Promise; + PUT(path: string, opts?: Omit): Promise; + PATCH(path: string, opts?: Omit): Promise; + DELETE(path: string, opts?: Omit): Promise; + /** Fully typed client for paths in the OpenAPI spec. Same auth/retry + * plumbing; throws ArcadiaError on non-2xx. */ + typed: TypedArcadiaClient; + getTenantId(): ArcadiaTenantId | undefined; + setTenantId(id: ArcadiaTenantId | undefined): void; +} + +export function createArcadiaClient(opts: ArcadiaClientOptions): ArcadiaClient { + const baseUrl = opts.baseUrl.replace(/\/+$/, ""); + const realFetch = opts.fetch ?? globalThis.fetch.bind(globalThis); + const apiKeyMode = opts.apiKeyMode ?? "fallback"; + const maxRetries = opts.maxRetries ?? 2; + + let tenantId = opts.tenantId; + + // Shared fetch: header injection + retry. Used both by `request()` and as + // the underlying transport for openapi-fetch (`typed`). + const transportFetch: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + headers.set("Accept", headers.get("Accept") ?? "application/json"); + + const token = opts.getToken ? await opts.getToken() : null; + if (token && !headers.has("Authorization")) headers.set("Authorization", `Bearer ${token}`); + if (opts.apiKey && (apiKeyMode === "always" || !token) && !headers.has("X-API-Key")) { + headers.set("X-API-Key", opts.apiKey); + } + if (tenantId && !headers.has("X-Tenant-ID")) headers.set("X-Tenant-ID", tenantId); + + let attempt = 0; + while (true) { + const res = await realFetch(input, { ...init, headers }); + if ((res.status === 429 || res.status === 503) && attempt < maxRetries) { + attempt += 1; + const wait = parseRetryAfter(res.headers.get("Retry-After")) ?? backoff(attempt); + await sleep(wait); + continue; + } + return res; + } + }; + + // Build the openapi-fetch-backed typed client. We then wrap each verb so + // it throws ArcadiaError on non-2xx instead of returning `{ data, error }`. + const oa = createOpenapiClient({ baseUrl, fetch: transportFetch }); + const typed = wrapOpenapi(oa, opts.onUnauthorized); + + async function request(path: string, ro: RequestOptions = {}): Promise { + const method = ro.method ?? "GET"; + const url = buildUrl(baseUrl, path, ro.params); + const headers: Record = { ...(ro.headers ?? {}) }; + if (ro.idempotencyKey && method !== "GET") headers["X-Idempotency-Key"] = ro.idempotencyKey; + + let body: BodyInit | undefined; + if (ro.body !== undefined && ro.body !== null) { + if (ro.body instanceof FormData || ro.body instanceof Blob || typeof ro.body === "string") { + body = ro.body as BodyInit; + } else { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(ro.body); + } + } + + const res = await transportFetch(url, { method, headers, body, signal: ro.signal }); + + if (res.ok) { + if (res.status === 204) return undefined as T; + const text = await res.text(); + if (!text) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } + } + + const err = await normalizeErrorResponse(res); + if (err.isAuth) opts.onUnauthorized?.(err); + throw err; + } + + return { + request, + GET: (p, o) => request(p, { ...o, method: "GET" }), + POST: (p, o) => request(p, { ...o, method: "POST" }), + PUT: (p, o) => request(p, { ...o, method: "PUT" }), + PATCH: (p, o) => request(p, { ...o, method: "PATCH" }), + DELETE: (p, o) => request(p, { ...o, method: "DELETE" }), + typed, + getTenantId: () => tenantId, + setTenantId: (id) => { + tenantId = id; + }, + }; +} + +/** Wrap openapi-fetch so each verb throws ArcadiaError on non-2xx instead + * of returning `{ data, error }`. Preserves full type inference because + * we forward the original method's signature. */ +function wrapOpenapi( + oa: OpenapiClient, + onUnauthorized?: (err: ArcadiaError) => void, +): TypedArcadiaClient { + const verbs = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE"] as const; + const out: Record = { ...oa }; + for (const verb of verbs) { + const orig = (oa as unknown as Record unknown>)[verb]; + if (typeof orig !== "function") continue; + out[verb] = async (...args: unknown[]) => { + const result = (await orig.apply(oa, args)) as { data?: unknown; error?: unknown; response: Response }; + if (result.error !== undefined && !result.response.ok) { + const err = await normalizeErrorResponse(result.response); + if (err.isAuth) onUnauthorized?.(err); + throw err; + } + return result; + }; + } + return out as unknown as TypedArcadiaClient; +} + +function buildUrl( + baseUrl: string, + path: string, + params?: Record, +): string { + const url = path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`; + if (!params) return url; + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === null || v === undefined) continue; + qs.append(k, String(v)); + } + const s = qs.toString(); + return s ? `${url}${url.includes("?") ? "&" : "?"}${s}` : url; +} + +function parseRetryAfter(h: string | null): number | null { + if (!h) return null; + const seconds = Number(h); + if (Number.isFinite(seconds)) return seconds * 1000; + const date = Date.parse(h); + if (!Number.isNaN(date)) return Math.max(0, date - Date.now()); + return null; +} + +function backoff(attempt: number): number { + return Math.min(4000, 250 * Math.pow(4, attempt - 1)); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..46e86bd --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,77 @@ +// Error normalization for the arcadia API. +// +// Arcadia returns errors as JSON in the shape: +// { error: string, message: string, details?: object, request_id?: string } +// with appropriate HTTP status. ArcadiaError preserves all of that plus the +// status code so callers can branch on either code (`error === "rate_limited"`) +// or status (`status === 429`). + +export interface ArcadiaErrorBody { + error: string; + message: string; + details?: Record; + request_id?: string; +} + +export class ArcadiaError extends Error { + readonly status: number; + readonly code: string; + readonly details?: Record; + readonly requestId?: string; + readonly raw?: unknown; + + constructor(opts: { + status: number; + code: string; + message: string; + details?: Record; + requestId?: string; + raw?: unknown; + }) { + super(opts.message); + this.name = "ArcadiaError"; + this.status = opts.status; + this.code = opts.code; + this.details = opts.details; + this.requestId = opts.requestId; + this.raw = opts.raw; + } + + get isAuth(): boolean { + return this.status === 401; + } + get isForbidden(): boolean { + return this.status === 403; + } + get isNotFound(): boolean { + return this.status === 404; + } + get isValidation(): boolean { + return this.status === 422; + } + get isRateLimited(): boolean { + return this.status === 429; + } + get isServer(): boolean { + return this.status >= 500; + } +} + +export async function normalizeErrorResponse(res: Response): Promise { + let body: Partial = {}; + let raw: unknown; + try { + raw = await res.clone().json(); + if (raw && typeof raw === "object") body = raw as ArcadiaErrorBody; + } catch { + // Non-JSON error (e.g. HTML 502 from a proxy). Fall through. + } + return new ArcadiaError({ + status: res.status, + code: body.error ?? `http_${res.status}`, + message: body.message ?? res.statusText ?? `Request failed with ${res.status}`, + details: body.details, + requestId: body.request_id, + raw, + }); +} diff --git a/src/generated/openapi.d.ts b/src/generated/openapi.d.ts new file mode 100644 index 0000000..54c93eb --- /dev/null +++ b/src/generated/openapi.d.ts @@ -0,0 +1,2006 @@ +// AUTO-GENERATED — do not edit by hand. Regenerate with sync-spec.mjs. +// Source: http://localhost:4000/api/openapi +// Generated: 2026-04-29T10:25:44.504Z + +export interface paths { + "/api/v1/storage_configs/{storage_config_id}/mark-degraded": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/user_quotas/top-users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/{storage_config_id}/activate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/user_quotas/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/user_quotas/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get quota statistics */ + get: operations["ArcadiaWeb.API.UserQuotaController.stats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/batch/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Batch restore digital objects */ + post: operations["ArcadiaWeb.API.DigitalObjectController.batch_restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/my/quota": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get my quota */ + get: operations["ArcadiaWeb.API.UserQuotaController.my_quota"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/{storage_config_id}/deactivate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/upload_sessions/{id}/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/batch/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Batch delete digital objects */ + post: operations["ArcadiaWeb.API.DigitalObjectController.batch_delete"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List digital objects */ + get: operations["ArcadiaWeb.API.DigitalObjectController.index"]; + put?: never; + /** Create a digital object */ + post: operations["ArcadiaWeb.API.DigitalObjectController.create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/user_quotas/recalculate-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/my/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get my storage usage */ + get: operations["ArcadiaWeb.API.DigitalObjectController.my_usage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/upload_sessions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/{digital_object_id}/purge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/upload_sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/user_quotas": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List user quotas */ + get: operations["ArcadiaWeb.API.UserQuotaController.index"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get storage statistics */ + get: operations["ArcadiaWeb.API.DigitalObjectController.stats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/user_quotas/can-upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check if user can upload */ + get: operations["ArcadiaWeb.API.UserQuotaController.can_upload"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/{storage_config_id}/set-default": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/batch/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Batch update digital object metadata */ + post: operations["ArcadiaWeb.API.DigitalObjectController.batch_update"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/{storage_config_id}/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validate a storage configuration */ + post: operations["ArcadiaWeb.API.StorageConfigController.validate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/batch/purge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Batch purge (permanently delete) digital objects */ + post: operations["ArcadiaWeb.API.DigitalObjectController.batch_purge"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a digital object */ + get: operations["ArcadiaWeb.API.DigitalObjectController.show"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/{user_id}/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/{storage_config_id}/mark-maintenance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/user_quotas/{user_quota_id}/recalculate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/digital_objects/{digital_object_id}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/storage_configs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List storage configurations */ + get: operations["ArcadiaWeb.API.StorageConfigController.index"]; + put?: never; + /** Create a storage configuration */ + post: operations["ArcadiaWeb.API.StorageConfigController.create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/{user_id}/quota": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * BatchOperationResult + * @description Result of a batch operation + * @example { + * "failure_count": 2, + * "failures": [ + * { + * "error": "Not found", + * "id": "123" + * }, + * { + * "error": "Permission denied", + * "id": "456" + * } + * ], + * "success_count": 8, + * "total": 10 + * } + */ + BatchOperationResult: { + /** @description Number of failed operations */ + failure_count: number; + /** @description Details of failed operations */ + failures?: { + /** @description Error message */ + error?: string; + /** @description Item ID */ + id?: string; + }[]; + /** @description Number of successful operations */ + success_count: number; + /** @description Total number of items processed */ + total: number; + }; + /** + * CanUploadResponse + * @description Response indicating whether a user can upload a file + */ + CanUploadResponse: { + /** @description Whether the upload is allowed */ + can_upload: boolean; + /** @description Current quota information */ + quota_info?: { + /** @description Current object count */ + object_count?: number; + /** @description Object count limit */ + object_count_limit?: number | null; + /** @description Storage limit */ + storage_limit?: number; + /** @description Current storage used */ + storage_used?: number; + } | null; + /** @description Reason if upload is denied */ + reason?: string | null; + }; + /** + * DeleteResponse + * @description Response for delete operations + * @example { + * "deleted": true, + * "id": "123e4567-e89b-12d3-a456-426614174000" + * } + */ + DeleteResponse: { + /** @description Whether deletion was successful */ + deleted: boolean; + /** + * Format: uuid + * @description Deleted resource ID + */ + id?: string; + }; + /** + * DetailedHealthCheck + * @description Detailed API health check with component status + * @example { + * "components": { + * "cache": { + * "latency_ms": 1, + * "status": "up" + * }, + * "database": { + * "latency_ms": 5, + * "status": "up" + * }, + * "storage": { + * "status": "up" + * } + * }, + * "memory": { + * "percent": 25, + * "total_mb": 2048, + * "used_mb": 512 + * }, + * "status": "healthy", + * "timestamp": "2024-01-15T10:30:00Z", + * "version": "1.0.0" + * } + */ + DetailedHealthCheck: { + /** @description Individual component health status */ + components: { + cache?: { + latency_ms?: number; + /** @enum {string} */ + status?: "up" | "down"; + }; + database?: { + latency_ms?: number; + /** @enum {string} */ + status?: "up" | "down"; + }; + storage?: { + /** @enum {string} */ + status?: "up" | "down" | "degraded"; + }; + }; + /** @description Memory usage statistics */ + memory?: { + percent?: number; + total_mb?: number; + used_mb?: number; + }; + /** + * @description Overall system health status + * @enum {string} + */ + status: "healthy" | "degraded" | "unhealthy"; + /** + * Format: date-time + * @description Health check timestamp + */ + timestamp: string; + /** @description API version */ + version: string; + }; + /** + * DigitalObject + * @description A digital object stored in the system + * @example { + * "checksum": "abc123...", + * "content_type": "application/pdf", + * "deleted_at": null, + * "id": "123e4567-e89b-12d3-a456-426614174000", + * "inserted_at": "2024-01-01T00:00:00Z", + * "metadata": { + * "description": "Important document" + * }, + * "object_key": "uploads/2024/01/file.pdf", + * "original_filename": "document.pdf", + * "size_bytes": 1048576, + * "status": "active", + * "storage_backend": "s3", + * "storage_config_id": "123e4567-e89b-12d3-a456-426614174003", + * "tags": [ + * "important", + * "pdf" + * ], + * "tenant_id": "123e4567-e89b-12d3-a456-426614174001", + * "updated_at": "2024-01-01T00:00:00Z", + * "user_id": "123e4567-e89b-12d3-a456-426614174002", + * "version": 1 + * } + */ + DigitalObject: { + /** @description SHA-256 checksum */ + checksum?: string; + /** @description MIME content type */ + content_type?: string; + /** + * Format: date-time + * @description Soft deletion timestamp + */ + deleted_at?: string | null; + /** + * Format: uuid + * @description Digital object ID + */ + id: string; + /** + * Format: date-time + * @description Creation timestamp + */ + inserted_at?: string; + /** @description Custom metadata */ + metadata?: Record; + /** @description Unique key for the object in storage */ + object_key: string; + /** @description Original filename */ + original_filename: string; + /** @description Size in bytes */ + size_bytes: number; + /** + * @description Object status + * @enum {string} + */ + status?: "active" | "archived" | "deleted"; + /** + * @description Storage backend type + * @enum {string} + */ + storage_backend: "s3" | "local" | "gcs"; + /** + * Format: uuid + * @description Storage configuration ID + */ + storage_config_id?: string; + /** @description Object tags */ + tags?: string[]; + /** + * Format: uuid + * @description Tenant ID + */ + tenant_id: string; + /** + * Format: date-time + * @description Last update timestamp + */ + updated_at?: string; + /** + * Format: uuid + * @description User ID who owns the object + */ + user_id: string; + /** @description Object version */ + version?: number; + }; + /** + * DigitalObjectListResponse + * @description Response with a list of digital objects + */ + DigitalObjectListResponse: { + data: components["schemas"]["DigitalObject"][]; + }; + /** + * DigitalObjectRequest + * @description Request body for creating or updating a digital object + */ + DigitalObjectRequest: { + digital_object: { + /** @description SHA-256 checksum */ + checksum?: string; + /** @description MIME content type */ + content_type?: string; + /** @description Custom metadata */ + metadata?: Record; + /** @description Unique key for the object */ + object_key: string; + /** @description Original filename */ + original_filename: string; + /** @description Size in bytes */ + size_bytes: number; + /** + * Format: uuid + * @description Storage configuration ID + */ + storage_config_id?: string; + /** @description Object tags */ + tags?: string[]; + }; + }; + /** + * DigitalObjectResponse + * @description Response with a single digital object + */ + DigitalObjectResponse: { + data: components["schemas"]["DigitalObject"]; + }; + /** + * Error + * @description Standard error response + * @example { + * "details": { + * "email": [ + * "is invalid" + * ] + * }, + * "error": "validation_error", + * "message": "The request body contains invalid data", + * "request_id": "req_abc123" + * } + */ + Error: { + /** @description Additional error details */ + details?: Record | null; + /** @description Error type or code */ + error: string; + /** @description Human-readable error message */ + message: string; + /** @description Request ID for debugging */ + request_id?: string | null; + }; + /** + * ForbiddenError + * @description Access denied due to insufficient permissions + * @example { + * "error": "forbidden", + * "message": "You don't have permission to access this resource", + * "required_permission": "admin:users:write" + * } + */ + ForbiddenError: { + /** @description Error type */ + error: string; + /** @description Error message */ + message: string; + /** @description Permission required for this action */ + required_permission?: string | null; + }; + /** + * HealthCheck + * @description API health check response + * @example { + * "status": "healthy", + * "timestamp": "2024-01-15T10:30:00Z", + * "version": "1.0.0" + * } + */ + HealthCheck: { + /** + * @description Overall system health status + * @enum {string} + */ + status: "healthy" | "degraded" | "unhealthy"; + /** + * Format: date-time + * @description Health check timestamp + */ + timestamp: string; + /** @description API version */ + version: string; + }; + /** + * NotFoundError + * @description Resource not found error + * @example { + * "error": "not_found", + * "id": "123e4567-e89b-12d3-a456-426614174000", + * "message": "User not found", + * "resource": "user" + * } + */ + NotFoundError: { + /** @description Error type */ + error: string; + /** @description Resource ID */ + id?: string | null; + /** @description Error message */ + message: string; + /** @description Resource type */ + resource?: string | null; + }; + /** + * PaginatedResponse + * @description Paginated list response wrapper + */ + PaginatedResponse: { + /** @description List of items */ + data: Record[]; + pagination: components["schemas"]["Pagination"]; + }; + /** + * Pagination + * @description Pagination metadata for list responses + * @example { + * "has_next": true, + * "has_prev": false, + * "page": 1, + * "page_size": 20, + * "total_count": 150, + * "total_pages": 8 + * } + */ + Pagination: { + /** @description Whether there is a next page */ + has_next?: boolean; + /** @description Whether there is a previous page */ + has_prev?: boolean; + /** @description Current page number (1-indexed) */ + page: number; + /** @description Number of items per page */ + page_size: number; + /** @description Total number of items */ + total_count: number; + /** @description Total number of pages */ + total_pages: number; + }; + /** + * QuotaStatsResponse + * @description Quota statistics for a tenant + */ + QuotaStatsResponse: { + data: { + /** @description Average quota usage percentage */ + average_usage_percent?: number; + /** @description Total storage limit across all users */ + total_storage_limit?: number; + /** @description Total storage used across all users */ + total_storage_used?: number; + /** @description Total number of users with quotas */ + total_users?: number; + /** @description Number of users over their quota */ + users_over_limit?: number; + }; + }; + /** + * RateLimitError + * @description Rate limit exceeded + * @example { + * "error": "rate_limit_exceeded", + * "limit": 100, + * "message": "Too many requests. Please try again later.", + * "remaining": 0, + * "reset_at": 1704067200, + * "retry_after": 60 + * } + */ + RateLimitError: { + /** @description Error type */ + error: string; + /** @description Request limit */ + limit?: number; + /** @description Error message */ + message: string; + /** @description Requests remaining */ + remaining?: number; + /** @description Unix timestamp when limit resets */ + reset_at?: number; + /** @description Seconds until rate limit resets */ + retry_after: number; + }; + /** + * ServerError + * @description Internal server error + * @example { + * "error": "internal_server_error", + * "message": "An unexpected error occurred. Please try again later.", + * "request_id": "req_abc123" + * } + */ + ServerError: { + /** @description Error type */ + error: string; + /** @description Error message */ + message: string; + /** @description Request ID for support reference */ + request_id?: string; + }; + /** + * StorageConfig + * @description A storage configuration for storing digital objects + * @example { + * "allowed_content_types": [ + * "image/jpeg", + * "image/png", + * "application/pdf" + * ], + * "backend_type": "s3", + * "config": { + * "access_key_id": "AKIA...", + * "bucket": "my-bucket", + * "region": "us-east-1", + * "secret_access_key": "***" + * }, + * "id": "123e4567-e89b-12d3-a456-426614174000", + * "inserted_at": "2024-01-01T00:00:00Z", + * "is_default": true, + * "max_file_size_bytes": 104857600, + * "name": "Primary S3 Storage", + * "status": "active", + * "tenant_id": "123e4567-e89b-12d3-a456-426614174001", + * "updated_at": "2024-01-01T00:00:00Z" + * } + */ + StorageConfig: { + /** @description Allowed MIME types */ + allowed_content_types?: string[] | null; + /** + * @description Storage backend type + * @enum {string} + */ + backend_type: "s3" | "local" | "gcs"; + /** @description Backend-specific configuration */ + config?: Record; + /** + * Format: uuid + * @description Storage configuration ID + */ + id: string; + /** + * Format: date-time + * @description Creation timestamp + */ + inserted_at?: string; + /** @description Whether this is the default configuration */ + is_default?: boolean; + /** @description Maximum file size in bytes */ + max_file_size_bytes?: number | null; + /** @description Configuration name */ + name: string; + /** + * @description Configuration status + * @enum {string} + */ + status: "active" | "inactive" | "degraded" | "maintenance"; + /** + * Format: uuid + * @description Tenant ID + */ + tenant_id: string; + /** + * Format: date-time + * @description Last update timestamp + */ + updated_at?: string; + }; + /** + * StorageConfigListResponse + * @description Response with a list of storage configurations + */ + StorageConfigListResponse: { + data: components["schemas"]["StorageConfig"][]; + }; + /** + * StorageConfigRequest + * @description Request body for creating or updating a storage configuration + */ + StorageConfigRequest: { + storage_config: { + /** @description Allowed MIME types */ + allowed_content_types?: string[]; + /** + * @description Storage backend type + * @enum {string} + */ + backend_type: "s3" | "local" | "gcs"; + /** @description Backend-specific configuration */ + config: Record; + /** @description Set as default configuration */ + is_default?: boolean; + /** @description Maximum file size in bytes */ + max_file_size_bytes?: number; + /** @description Configuration name */ + name: string; + }; + }; + /** + * StorageConfigResponse + * @description Response with a single storage configuration + */ + StorageConfigResponse: { + data: components["schemas"]["StorageConfig"]; + }; + /** + * StorageStatsResponse + * @description Storage statistics for a tenant + */ + StorageStatsResponse: { + data: { + /** @description Statistics grouped by storage backend */ + by_backend?: Record; + /** @description Statistics grouped by user */ + by_user?: Record; + /** @description Total number of objects */ + total_objects?: number; + /** @description Total storage used in bytes */ + total_size_bytes?: number; + }; + }; + /** + * SuccessResponse + * @description Generic success response + * @example { + * "message": "Operation completed successfully", + * "success": true + * } + */ + SuccessResponse: { + /** @description Success message */ + message?: string | null; + /** @description Operation success status */ + success: boolean; + }; + /** + * UnauthorizedError + * @description Authentication required or token invalid + * @example { + * "error": "unauthorized", + * "message": "Invalid or expired token" + * } + */ + UnauthorizedError: { + /** @description Error type */ + error: string; + /** @description Error message */ + message: string; + }; + /** + * UsageResponse + * @description User storage usage information + */ + UsageResponse: { + data: { + /** @description Number of objects */ + object_count?: number; + /** @description Storage used in bytes */ + storage_used_bytes?: number; + }; + }; + /** + * UserQuota + * @description User storage quota and usage information + * @example { + * "id": "123e4567-e89b-12d3-a456-426614174000", + * "inserted_at": "2024-01-01T00:00:00Z", + * "last_recalculated_at": "2024-01-01T00:00:00Z", + * "object_count": 250, + * "object_count_limit": 1000, + * "storage_limit_bytes": 10737418240, + * "storage_used_bytes": 5368709120, + * "tenant_id": "123e4567-e89b-12d3-a456-426614174001", + * "updated_at": "2024-01-01T00:00:00Z", + * "user_id": "123e4567-e89b-12d3-a456-426614174002" + * } + */ + UserQuota: { + /** + * Format: uuid + * @description User quota ID + */ + id: string; + /** + * Format: date-time + * @description Creation timestamp + */ + inserted_at?: string; + /** + * Format: date-time + * @description Last quota recalculation timestamp + */ + last_recalculated_at?: string | null; + /** @description Current number of objects */ + object_count: number; + /** @description Maximum number of objects */ + object_count_limit?: number | null; + /** @description Storage limit in bytes */ + storage_limit_bytes: number; + /** @description Storage currently used in bytes */ + storage_used_bytes: number; + /** + * Format: uuid + * @description Tenant ID + */ + tenant_id: string; + /** + * Format: date-time + * @description Last update timestamp + */ + updated_at?: string; + /** + * Format: uuid + * @description User ID + */ + user_id: string; + }; + /** + * UserQuotaListResponse + * @description Response with a list of user quotas + */ + UserQuotaListResponse: { + data: components["schemas"]["UserQuota"][]; + }; + /** + * UserQuotaResponse + * @description Response with a single user quota + */ + UserQuotaResponse: { + data: components["schemas"]["UserQuota"]; + }; + /** + * ValidationError + * @description Validation error response with field-level errors + * @example { + * "errors": { + * "email": [ + * "has already been taken" + * ], + * "password": [ + * "is too short", + * "must contain a number" + * ] + * } + * } + */ + ValidationError: { + /** @description Map of field names to error messages */ + errors: { + [key: string]: string[]; + }; + }; + /** + * ValidationResponse + * @description Storage configuration validation result + */ + ValidationResponse: { + /** + * Format: uuid + * @description Configuration ID + */ + config_id?: string | null; + /** @description Error message if validation failed */ + error?: string | null; + /** @description Validation message */ + message?: string; + /** @description Failure reason */ + reason?: string | null; + /** @description Whether validation succeeded */ + success: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "ArcadiaWeb.API.UserQuotaController.stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Quota statistics retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuotaStatsResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.batch_restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Digital object IDs */ + requestBody?: { + content: { + "application/json": { + /** @description Array of digital object IDs to restore */ + object_ids: string[]; + }; + }; + }; + responses: { + /** @description Batch restore results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + errors?: { + id?: string; + reason?: string; + }[]; + /** @description Number of failed restorations */ + failed?: number; + /** @description Number of successful restorations */ + success?: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.UserQuotaController.my_quota": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User quota retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserQuotaResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.batch_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Digital object IDs */ + requestBody?: { + content: { + "application/json": { + /** @description Array of digital object IDs to delete */ + object_ids: string[]; + }; + }; + }; + responses: { + /** @description Batch delete results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + errors?: { + id?: string; + reason?: string; + }[]; + /** @description Number of failed deletions */ + failed?: number; + /** @description Number of successful deletions */ + success?: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.index": { + parameters: { + query?: { + /** @description Filter by user ID */ + user_id?: string; + /** @description Filter by storage backend (s3, local, gcs) */ + storage_backend?: string; + /** @description Filter by tag */ + tag?: string; + /** @description Include soft-deleted objects */ + include_deleted?: boolean; + /** @description Maximum number of results */ + limit?: number; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Digital objects retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigitalObjectListResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Digital object parameters */ + requestBody?: { + content: { + "application/json": components["schemas"]["DigitalObjectRequest"]; + }; + }; + responses: { + /** @description Digital object created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigitalObjectResponse"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.my_usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Storage usage retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UsageResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.UserQuotaController.index": { + parameters: { + query?: { + /** @description Only return quotas that have been exceeded */ + exceeded_only?: boolean; + /** @description Filter by specific user ID */ + user_id?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User quotas retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserQuotaListResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Storage statistics retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageStatsResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.UserQuotaController.can_upload": { + parameters: { + query: { + /** @description File size in bytes to check */ + size_bytes: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Upload check successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CanUploadResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Upload not allowed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CanUploadResponse"]; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.batch_update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Batch update request */ + requestBody?: { + content: { + "application/json": { + /** @description Attributes to update (e.g., tags, metadata) */ + attrs: Record; + /** @description Array of digital object IDs to update */ + object_ids: string[]; + }; + }; + }; + responses: { + /** @description Batch update results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + errors?: unknown[]; + failed?: number; + updated?: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.StorageConfigController.validate": { + parameters: { + query?: never; + header?: never; + path: { + /** @description Storage configuration ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Configuration is valid */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidationResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Storage configuration not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation failed */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidationResponse"]; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.batch_purge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Digital object IDs */ + requestBody?: { + content: { + "application/json": { + /** @description Array of digital object IDs to purge */ + object_ids: string[]; + }; + }; + }; + responses: { + /** @description Batch purge results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + errors?: unknown[]; + failed?: number; + purged?: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.DigitalObjectController.show": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description Digital object ID + * @example 123e4567-e89b-12d3-a456-426614174000 + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Digital object retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DigitalObjectResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Digital object not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.StorageConfigController.index": { + parameters: { + query?: { + /** @description Filter by status (active, inactive, degraded, maintenance) */ + status?: string; + /** @description Filter by backend type (s3, local, gcs) */ + backend_type?: string; + /** @description Only return active configurations */ + active_only?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Storage configurations retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageConfigListResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + "ArcadiaWeb.API.StorageConfigController.create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Storage configuration parameters */ + requestBody?: { + content: { + "application/json": components["schemas"]["StorageConfigRequest"]; + }; + }; + responses: { + /** @description Storage configuration created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageConfigResponse"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..3cad2d0 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,30 @@ +// PURPOSE: Typed HTTP client + React bindings for the arcadia Phoenix API. +// Wraps the OpenAPI-spec'd surface at /api/v1 with auth (Bearer JWT +// and/or X-API-Key), tenant context (X-Tenant-ID), idempotency +// keys, error normalization, and rate-limit-aware retry. Realtime +// (Phoenix Channels at /socket/tenant) lives alongside in the same +// provider so apps can subscribe to notification / digital_object / +// announcement / status_update / event topics with the same auth. +// +// The client is stateless about how the JWT is obtained — callers +// pass `getToken` so refresh and storage stay app-owned. Generated +// types under ./generated/openapi.d.ts are produced by the +// sync-spec script (see ../scripts/sync-spec.mjs). +// =========================================================================== +// EXPORTS +// Client: createArcadiaClient, type ArcadiaClient, type TypedArcadiaClient, +// type ArcadiaClientOptions +// Errors: ArcadiaError, type ArcadiaErrorBody +// Provider: ArcadiaProvider, useArcadia, useArcadiaClient, +// useArcadiaSubscription +// Realtime: createArcadiaRealtime, socketUrlFromBaseUrl, +// type ArcadiaRealtime, type TenantEventMap, type RealtimeStatus +// Types: re-exported subset from ./types and ./generated/openapi +// =========================================================================== +"use client"; + +export * from "./client"; +export * from "./errors"; +export * from "./provider"; +export * from "./realtime"; +export * from "./types"; diff --git a/src/provider.tsx b/src/provider.tsx new file mode 100644 index 0000000..397f53c --- /dev/null +++ b/src/provider.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createArcadiaClient, type ArcadiaClient, type ArcadiaClientOptions } from "./client"; +import { + createArcadiaRealtime, + socketUrlFromBaseUrl, + type ArcadiaRealtime, + type RealtimeScope, + type RealtimeStatus, + type TenantEventMap, +} from "./realtime"; + +interface ArcadiaContextValue { + client: ArcadiaClient; + /** Current tenant id (state, so consumers re-render on switch). */ + tenantId: string | undefined; + setTenantId: (id: string | undefined) => void; + /** Realtime instance (if `enableRealtime` was set on the provider). */ + realtime: ArcadiaRealtime | null; + /** Realtime status, kept in state for ergonomic UI. */ + realtimeStatus: RealtimeStatus; +} + +const Ctx = createContext(null); + +export interface ArcadiaProviderProps extends Omit { + /** Initial tenant id. Use `setTenantId` from the hook to switch at runtime. */ + initialTenantId?: string; + /** User id for user-scoped realtime subscriptions. Optional. */ + userId?: string; + /** Enable Phoenix Channels realtime. Default false. When true and + * tenantId is set, the provider auto-connects on mount. */ + enableRealtime?: boolean; + /** Override socket URL. Defaults to socketUrlFromBaseUrl(baseUrl). */ + socketUrl?: string; + children: ReactNode; +} + +export function ArcadiaProvider({ + initialTenantId, + userId, + enableRealtime = false, + socketUrl, + children, + ...clientOpts +}: ArcadiaProviderProps) { + const [tenantId, setTenantIdState] = useState(initialTenantId); + const [realtimeStatus, setRealtimeStatus] = useState("idle"); + + // Keep callable refs so we don't rebuild the client on every render. + const optsRef = useRef(clientOpts); + optsRef.current = clientOpts; + + const client = useMemo( + () => + createArcadiaClient({ + ...clientOpts, + tenantId: initialTenantId, + getToken: () => optsRef.current.getToken?.() ?? null, + onUnauthorized: (err) => optsRef.current.onUnauthorized?.(err), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [clientOpts.baseUrl], + ); + + // Keep client tenant in lockstep with React state. + useEffect(() => { + client.setTenantId(tenantId); + }, [client, tenantId]); + + // Realtime lifecycle. Recreated when tenantId, userId, or socket URL change. + const realtime = useMemo(() => { + if (!enableRealtime || !tenantId) return null; + if (typeof window === "undefined") return null; // SSR-safe + return createArcadiaRealtime({ + socketUrl: socketUrl ?? socketUrlFromBaseUrl(clientOpts.baseUrl), + tenantId, + userId, + getToken: () => optsRef.current.getToken?.() ?? null, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableRealtime, tenantId, userId, socketUrl, clientOpts.baseUrl]); + + useEffect(() => { + if (!realtime) return; + const off = realtime.onStatusChange(setRealtimeStatus); + realtime.connect(); + return () => { + off(); + realtime.disconnect(); + }; + }, [realtime]); + + const value = useMemo( + () => ({ client, tenantId, setTenantId: setTenantIdState, realtime, realtimeStatus }), + [client, tenantId, realtime, realtimeStatus], + ); + + return {children}; +} + +export function useArcadia(): ArcadiaContextValue { + const v = useContext(Ctx); + if (!v) throw new Error("useArcadia must be used inside "); + return v; +} + +/** Convenience: most consumers only need the client. */ +export function useArcadiaClient(): ArcadiaClient { + return useArcadia().client; +} + +/** Subscribe to a Phoenix Channels event for as long as the component is + * mounted. No-op if the provider was created without `enableRealtime`. The + * callback is captured fresh on every render via a ref so consumers don't + * need to memoize it. */ +export function useArcadiaSubscription( + event: E, + callback: (payload: TenantEventMap[E]) => void, + opts?: { scope?: RealtimeScope; enabled?: boolean }, +): void { + const { realtime, realtimeStatus } = useArcadia(); + const cbRef = useRef(callback); + cbRef.current = callback; + + const enabled = opts?.enabled ?? true; + const scope = opts?.scope ?? "tenant"; + + useEffect(() => { + if (!realtime || !enabled || realtimeStatus !== "open") return; + const unsub = realtime.on(event, ((payload) => cbRef.current(payload)) as (p: TenantEventMap[E]) => void, scope); + return unsub; + }, [realtime, realtimeStatus, event, enabled, scope]); +} diff --git a/src/realtime.ts b/src/realtime.ts new file mode 100644 index 0000000..65156c7 --- /dev/null +++ b/src/realtime.ts @@ -0,0 +1,163 @@ +// Realtime over Phoenix Channels at /socket/tenant. +// +// Arcadia exposes a tenant-scoped socket; clients authenticate by passing +// the JWT in connect params. After joining `tenant:` the server +// pushes events: notification, digital_object, announcement, status_update, +// event. A user-scoped sub-topic `tenant::user:` carries the +// same shape filtered to the current user. +// +// SSR-safe: no WebSocket reference is created until connect() is called. + +import { Socket, Channel } from "phoenix"; +import type { ArcadiaTenantId } from "./types"; + +/** Known event payloads. Open-ended — arcadia may add more, and apps can + * augment via module declaration merging or just use the indexed access. */ +export interface TenantEventMap { + notification: { id: string; level?: "info" | "warning" | "error"; title: string; body?: string; [k: string]: unknown }; + digital_object: { id: string; action: "created" | "updated" | "deleted" | "restored"; [k: string]: unknown }; + announcement: { id: string; title: string; body?: string; [k: string]: unknown }; + status_update: { id: string; component?: string; status?: string; [k: string]: unknown }; + event: { type: string; payload?: unknown; [k: string]: unknown }; + [key: string]: Record; +} + +export type RealtimeStatus = "idle" | "connecting" | "open" | "closed" | "error"; +export type RealtimeScope = "tenant" | "user"; + +export interface ArcadiaRealtimeOptions { + /** Full URL to the Phoenix socket endpoint. Example: + * "ws://localhost:4000/socket/tenant" or "wss://api.example.com/socket/tenant". + * If you only have an HTTP base URL, see `socketUrlFromBaseUrl`. */ + socketUrl: string; + /** Tenant id whose topic we'll join (e.g. UUID or slug). */ + tenantId: ArcadiaTenantId; + /** Optional user id; required if you want user-scoped subscriptions. */ + userId?: string; + /** Returns the JWT to send as Phoenix connect param `token`. Called + * once on connect; reconnect re-evaluates so token rotation works. */ + getToken: () => string | null | Promise; + /** Heartbeat ms. Phoenix default is 30000. */ + heartbeatIntervalMs?: number; + /** Called for any unexpected error (auth failure, channel join refused). */ + onError?: (err: Error) => void; + /** Custom Socket factory (for tests / mocks). */ + socketFactory?: (endpoint: string, opts: ConstructorParameters[1]) => Socket; +} + +export interface ArcadiaRealtime { + /** Open the socket and join tenant + (optionally) user channels. Idempotent. */ + connect(): Promise; + /** Tear everything down. */ + disconnect(): void; + /** Subscribe to an event. Returns an unsubscribe function. + * scope="tenant" (default) targets `tenant:`. + * scope="user" targets `tenant::user:` (requires userId). */ + on( + event: E, + callback: (payload: TenantEventMap[E]) => void, + scope?: RealtimeScope, + ): () => void; + /** Current status. */ + status: RealtimeStatus; + /** Subscribe to status changes. Returns an unsubscribe fn. */ + onStatusChange(cb: (status: RealtimeStatus) => void): () => void; +} + +/** Convert an http(s) base URL into the matching ws(s) socket URL. */ +export function socketUrlFromBaseUrl(baseUrl: string, path = "/socket/tenant"): string { + const u = new URL(baseUrl); + const proto = u.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${u.host}${path}`; +} + +export function createArcadiaRealtime(opts: ArcadiaRealtimeOptions): ArcadiaRealtime { + let socket: Socket | null = null; + let tenantChannel: Channel | null = null; + let userChannel: Channel | null = null; + let _status: RealtimeStatus = "idle"; + const statusListeners = new Set<(s: RealtimeStatus) => void>(); + + function setStatus(next: RealtimeStatus) { + if (_status === next) return; + _status = next; + for (const cb of statusListeners) cb(next); + } + + async function connect(): Promise { + if (socket) return; + setStatus("connecting"); + + const token = await opts.getToken(); + const factory = + opts.socketFactory ?? ((endpoint, o) => new Socket(endpoint, o)); + + socket = factory(opts.socketUrl, { + params: () => ({ token }), + heartbeatIntervalMs: opts.heartbeatIntervalMs ?? 30000, + }); + socket.onOpen(() => setStatus("open")); + socket.onClose(() => setStatus("closed")); + socket.onError((err) => { + setStatus("error"); + opts.onError?.(err instanceof Error ? err : new Error(String(err))); + }); + socket.connect(); + + tenantChannel = socket.channel(`tenant:${opts.tenantId}`, {}); + tenantChannel + .join() + .receive("error", (resp) => opts.onError?.(new Error(`tenant channel join failed: ${JSON.stringify(resp)}`))); + + if (opts.userId) { + userChannel = socket.channel(`tenant:${opts.tenantId}:user:${opts.userId}`, {}); + userChannel + .join() + .receive("error", (resp) => + opts.onError?.(new Error(`user channel join failed: ${JSON.stringify(resp)}`)), + ); + } + } + + function disconnect() { + tenantChannel?.leave(); + userChannel?.leave(); + socket?.disconnect(); + tenantChannel = null; + userChannel = null; + socket = null; + setStatus("idle"); + } + + function on( + event: E, + callback: (payload: TenantEventMap[E]) => void, + scope: RealtimeScope = "tenant", + ): () => void { + const channel = scope === "user" ? userChannel : tenantChannel; + if (!channel) { + // Connection not established yet — return a no-op unsubscribe; caller + // should retry after connect() resolves. The React hook handles this. + return () => {}; + } + const ref = channel.on(event as string, callback as (payload: unknown) => void); + return () => { + channel.off(event as string, ref); + }; + } + + function onStatusChange(cb: (status: RealtimeStatus) => void): () => void { + statusListeners.add(cb); + return () => statusListeners.delete(cb); + } + + return { + connect, + disconnect, + on, + get status() { + return _status; + }, + onStatusChange, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5067b67 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,47 @@ +// Hand-written shared types. +// +// Per-endpoint request/response types come from the generated OpenAPI types +// (see ./generated/openapi.d.ts after running `sync-spec`). The shapes here +// are the ones that exist *around* every request — wrappers, pagination, +// auth payloads — and don't belong to any one endpoint. + +/** Successful response envelope used by most v1 endpoints. */ +export interface ArcadiaEnvelope { + data: T; + meta?: ArcadiaMeta; +} + +/** Optional metadata block on list responses (pagination, totals, etc). */ +export interface ArcadiaMeta { + page?: number; + per_page?: number; + total?: number; + total_pages?: number; + request_id?: string; + [key: string]: unknown; +} + +/** Auth-token pair returned by /auth/login, /auth/refresh, OAuth callbacks. + * `expires_in` is seconds-from-now (Phoenix Guardian default); compute the + * absolute expiry on the client if needed. */ +export interface ArcadiaAuthTokens { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: "Bearer"; +} + +/** Minimal user shape used by useArcadia / auth-ui until generated types + * cover the whole User schema. Apps should prefer generated types when + * available. */ +export interface ArcadiaUser { + id: string; + email: string; + name?: string; + tenant_id?: string; + roles?: string[]; +} + +/** Tenant identifier — either a UUID string, or a slug if the deployment + * uses friendly tenant slugs. The client doesn't enforce a format. */ +export type ArcadiaTenantId = string;