// API service component. // // Declare after login-gate in the shell: // // // // Use from any other component: // const api = document.querySelector('api-client') // const vrfs = await api.netbox.list('/ipam/vrfs/') // const vpc = await api.agent.get('/vpcs/vp-admin') // await api.agent.post('/vpcs', { name: 'vp-admin', cidr: '10.0.0.0/8' }) // await api.agent.delete('/vpcs/vp-admin') // await api.agent.waitFor('/vms/i-test1', 'started') // // Phase 2: swap login-gate for oidc-gate → this component unchanged. export class ApiError extends Error { constructor(origin, status, message) { super(`[${origin}] HTTP ${status}: ${message}`) this.origin = origin // 'netbox' | 'agent' this.status = status } } class ApiClient extends HTMLElement { // Resolves once login-gate is ready (or immediately if no gate in DOM). #credentials = null async connectedCallback() { this.style.display = 'none' // Support login-gate (static token) and biscuit-gate (attenuated token). const gate = document.querySelector('login-gate') this.#credentials = gate ? await gate.ready : null this.netbox = this.#buildNetbox() this.agent = this.#buildAgent() } get creds() { return this.#credentials ?? {} } // ── Internal ────────────────────────────────────────────────────────────── async #request(origin, url, options = {}) { let response try { response = await fetch(url, options) } catch (e) { throw new ApiError(origin, 0, `Network error: ${e.message}`) } if (response.status === 204) return null const text = await response.text() let json try { json = JSON.parse(text) } catch { json = null } if (!response.ok) { const detail = json?.detail ?? json?.message ?? text throw new ApiError(origin, response.status, detail) } return json } // ── Netbox ──────────────────────────────────────────────────────────────── #buildNetbox() { const headers = () => ({ 'Authorization': `Token ${this.creds.netbox_token}`, 'Accept': 'application/json', }) const url = (path, params = {}) => { const u = new URL(path, this.creds.netbox_url + '/api/') Object.entries(params).forEach(([k, v]) => { if (v != null) u.searchParams.set(k, v) }) return u.toString() } return { list: (path, params = {}) => this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() }) .then(d => d?.results ?? []), get: (path, params = {}) => this.#request('netbox', url(path, params), { headers: headers() }), } } // ── Agent ───────────────────────────────────────────────────────────────── #buildAgent() { const headers = (body = false) => ({ 'Accept': 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), }) const url = path => new URL(path, this.creds.agent_url + '/').toString() const req = (method, path, body) => this.#request('agent', url(path), { method, headers: headers(body != null), ...(body != null ? { body: JSON.stringify(body) } : {}), }) return { get: path => req('GET', path), list: path => req('GET', path), post: (path, b) => req('POST', path, b), delete: path => req('DELETE', path), waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => { const deadline = Date.now() + timeout while (Date.now() < deadline) { const r = await req('GET', path) if (r?.state === desired) return r if (r?.state === 'error') throw new ApiError('agent', 'error', `${path} reached error state`) await new Promise(ok => setTimeout(ok, interval)) } throw new ApiError('agent', 'timeout', `${path} did not reach '${desired}' in ${timeout}ms`) }, } } } customElements.define('api-client', ApiClient)