/** * Platform API helpers — typed wrappers around nullplatform REST API. * * Generic helpers usable by any plugin type (scope, service, etc.). * Replicates what `np service workflow build-context` does in the Go CLI. * * Usage: * import { api } from "@nullplatform/plugin"; * * const scope = await api.scope("12345"); * const deployment = await api.deployment("67890"); * const providers = await api.providers(scope.nrn, ["cloud-providers"]); */ // ─── Configuration ─────────────────────────────────────────────────── import { accessToken } from "./auth"; import { cfg } from "./config"; const TIMEOUT_MS = 30_000; function getConfig() { const apiUrl = cfg.apiUrl(); const apiKey = cfg.apiKey(); if (!apiKey) { throw new Error("NP_API_KEY is not set"); } return { apiUrl, apiKey }; } async function fetchJson( path: string, params?: Record ): Promise { const { apiUrl } = getConfig(); const url = new URL(path, apiUrl); if (params) { for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== "") url.searchParams.set(k, v); } } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); // The API wants a Bearer *token*: NP_API_KEY is exchanged (and cached) by // the shared auth module — sending the raw key answers 401 "Invalid token". const send = (token: string) => fetch(url.toString(), { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, signal: controller.signal, }); try { let res = await send(await accessToken()); // A warm worker's token can expire between calls — on 401, force a fresh // exchange and retry once before giving up. if (res.status === 401) res = await send(await accessToken(true)); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error( `API ${res.status} ${res.statusText}: ${path}${ body ? ` — ${body}` : "" }` ); } return res.json() as Promise; } finally { clearTimeout(timeoutId); } } // ─── Generic core ──────────────────────────────────────────────────── // // The SDK will never be as current as the API, so the generic layer IS the // contract: any path, any query, any body — authenticated, retried, paged — // without waiting for an SDK release. The typed helpers below are sugar over // this core, never the only way in. export interface Page { paging?: { total?: number; offset?: number; limit?: number }; results?: T[]; } export type Query = Record; /** Authenticated GET of any path — the forward-compatible escape hatch. */ export async function get( path: string, query?: Query ): Promise { const params: Record = {}; for (const [k, v] of Object.entries(query ?? {})) { if (v !== undefined && v !== "") params[k] = String(v); } return fetchJson(path, params); } /** Authenticated write of any path. Returns the parsed body when there is one. */ export async function request( method: "POST" | "PATCH" | "PUT" | "DELETE", path: string, body?: unknown ): Promise { return sendJson(method, path, body); } /** * Iterate a standard listing (`{ paging, results }`) page by page, yielding * items. Stops on a short page, an exhausted total, or `max`. An endpoint * that ignores paging still works: its single page is yielded and iteration * ends on the short-page rule. */ export async function* list( path: string, query: Query = {}, opts: { pageSize?: number; max?: number } = {} ): AsyncGenerator { const pageSize = Math.min(opts.pageSize ?? 200, 200); // the API caps limit at 200 let offset = 0; let yielded = 0; for (;;) { const page = await get>(path, { ...query, limit: pageSize, offset, }); const results = page.results ?? []; for (const item of results) { yield item; yielded++; if (opts.max && yielded >= opts.max) return; } offset += results.length; const total = page.paging?.total; if ( results.length < pageSize || (typeof total === "number" && offset >= total) ) return; } } /** The whole listing, collected. */ export async function listAll( path: string, query: Query = {}, opts: { pageSize?: number; max?: number } = {} ): Promise { const out: T[] = []; for await (const item of list(path, query, opts)) out.push(item); return out; } // ─── Entity types ──────────────────────────────────────────────────── export interface ScopeInfo { id: string; slug: string; nrn: string; domain: string; asset_name: string; current_active_deployment: string; capabilities: Record; dimensions: Record; [key: string]: unknown; } export interface DeploymentInfo { id: string; status: string; release_id: string; strategy_data: { desired_switched_traffic?: number; }; [key: string]: unknown; } export interface ApplicationInfo { id: string; slug: string; [key: string]: unknown; } export interface NamespaceInfo { id: string; slug: string; [key: string]: unknown; } export interface AccountInfo { id: string; slug: string; [key: string]: unknown; } export interface AssetInfo { type: string; url: string; [key: string]: unknown; } export interface ParameterResult { variable: string; values: Array<{ value: string }>; } export type ProvidersMap = Record>; async function sendJson( method: string, path: string, body: unknown ): Promise { const { apiUrl } = getConfig(); const url = new URL(path, apiUrl).toString(); const send = (token: string) => fetch(url, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); let res = await send(await accessToken()); if (res.status === 401) res = await send(await accessToken(true)); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error( `API ${res.status} ${res.statusText}: ${path}${text ? ` — ${text}` : ""}` ); } const text = await res.text().catch(() => ""); if (!text) return undefined; try { return JSON.parse(text) as T; } catch { return undefined; } } // ─── API functions ─────────────────────────────────────────────────── export async function scope(id: string): Promise { return fetchJson(`/scope/${id}`); } export async function deployment(id: string): Promise { return fetchJson(`/deployment/${id}`); } export async function application(id: string): Promise { return fetchJson(`/application/${id}`); } export async function namespace(id: string): Promise { return fetchJson(`/namespace/${id}`); } export async function account(id: string): Promise { return fetchJson(`/account/${id}`); } /** * Fetch asset by following the release → build → asset chain. * Mirrors the Go CLI's fetchAndStoreAsset logic. */ export async function asset( releaseId: string, assetName: string ): Promise { const release = await fetchJson<{ build_id: string }>( `/release/${releaseId}` ); // /asset does not accept a name filter (verified against the OpenAPI spec) — // an earlier version passed one, the API silently ignored it, and callers // got an arbitrary first asset. Fetch the build's set and match here. const all = await assets(String(release.build_id)); if (!all.length) { throw new Error( `no assets on build ${release.build_id} (release ${releaseId})` ); } const named = all.filter((a) => (a as { name?: string }).name === assetName); if (named.length === 1) return named[0]; if (named.length > 1) { throw new Error( `asset name "${assetName}" is ambiguous on build ${release.build_id}` ); } if (all.length === 1) return all[0]; // one asset is unambiguous whatever it is called const names = all .map((a) => `"${(a as { name?: string }).name ?? "?"}"`) .join(", "); throw new Error( `no asset named "${assetName}" on build ${release.build_id} — available: ${names}` ); } /** * Fetch parameters for a scope by NRN. * Mirrors --include-secrets flag from build-context. */ export async function parameters( nrn: string, opts: { includeSecrets?: boolean } = {} ): Promise> { const result = await fetchJson<{ results: ParameterResult[] }>("/parameter", { nrn, show_secret_values: String(opts.includeSecrets ?? false), interpolate: "true", limit: "500", }); const params: Record = {}; for (const p of result.results ?? []) { if (p.variable && p.values?.[0]?.value !== undefined) { params[p.variable] = p.values[0].value; } } return params; } /** * Fetch providers by NRN and categories. * Mirrors --provider-categories flag from build-context. * Returns providers keyed by category slug with their attributes. */ export async function providers( nrn: string, categories: string[], dimensions?: Record ): Promise { // /provider does not accept a categories filter — the lookup is a chain: // category slugs → category ids → provider_specifications (which carry the // category) → providers by specification id. Then, per category, the // provider whose dimensions best fit the scope's wins, the way // `np service workflow build-context` picks it; a category several // specifications share (aws-configuration and azure-configuration are both // cloud-providers) also carries each specification's winner under its slug. const cats = await listAll<{ id?: string; slug?: string }>("/category", { slug: categories.join(","), }); const categoryIds = cats.map((c) => c.id).filter(Boolean) as string[]; if (!categoryIds.length) return {}; const specs = await listAll<{ id?: string; slug?: string; categories?: Array<{ slug?: string }>; }>("/provider_specification", { category: categoryIds.join(","), nrn }); const specToCategory = new Map(); const specSlug = new Map(); for (const spec of specs) { const slug = spec.categories?.[0]?.slug; if (spec.id && slug) specToCategory.set(spec.id, slug); if (spec.id && spec.slug) specSlug.set(spec.id, spec.slug); } if (!specToCategory.size) return {}; const asked = dimensions ?? {}; const dimensionFilter = dimensions ? Object.entries(dimensions) .map(([k, v]) => `${k}:${v}`) .join(",") : undefined; type Provider = { id?: string; specification_id?: string; nrn?: string; dimensions?: Record; attributes?: Record; }; // The chain up from the NRN — the scope's, its application's, namespace's, // account's, organization's — the way `np provider list` asks for it. const provs = await listAll("/provider", { nrn, specification_id: [...specToCategory.keys()].join(","), show_ascendants: "true", ...(dimensionFilter ? { dimensions: dimensionFilter } : {}), }); // 1. Per specification, the way `np provider list` picks: a provider set to // a different value of a dimension the scope has is out; among the rest // the longer (closer) NRN, then more of the scope's dimensions matched, // then fewer dimensions the scope never asked about, then the lower id, // so the choice never depends on list order. const contradicts = (p: Provider): boolean => Object.entries(p.dimensions ?? {}).some( ([k, v]) => k in asked && (typeof v !== "string" || asked[k] !== v) ); const fit = (p: Provider): { matched: number; unasked: number } => { let matched = 0; let unasked = 0; for (const k of Object.keys(p.dimensions ?? {})) { if (k in asked) matched++; else unasked++; } return { matched, unasked }; }; const nrnLength = (p: Provider) => (p.nrn ?? "").length; const beatsForSpec = (candidate: Provider, current: Provider): boolean => { if (nrnLength(candidate) !== nrnLength(current)) return nrnLength(candidate) > nrnLength(current); const a = fit(candidate); const b = fit(current); if (a.matched !== b.matched) return a.matched > b.matched; if (a.unasked !== b.unasked) return a.unasked < b.unasked; return String(candidate.id ?? "") < String(current.id ?? ""); }; const bestBySpec = new Map(); for (const p of provs) { if (!p.specification_id || !specToCategory.has(p.specification_id)) continue; if (contradicts(p)) continue; const s = bestBySpec.get(p.specification_id); if (!s || beatsForSpec(p, s)) bestBySpec.set(p.specification_id, p); } // 2. The list carries no attributes: read each winner, the way // `np provider list` does, unless the row already brought them. await Promise.all( [...bestBySpec.values()].map(async (p) => { if (p.attributes || !p.id) return; const detail = await get(`/provider/${p.id}`); p.attributes = detail.attributes ?? {}; }) ); // 3. Per category among the specifications' winners, the way // `np service workflow build-context` picks: the number of the // provider's dimensions the scope matches (-1, last, when it has one the // scope does not), then the longer NRN, then the lower id. const score = (p: Provider): number => { let n = 0; for (const [k, v] of Object.entries(p.dimensions ?? {})) { if (typeof v !== "string" || asked[k] !== v) return -1; n++; } return n; }; const beats = (candidate: Provider, current: Provider): boolean => { if (score(candidate) !== score(current)) return score(candidate) > score(current); if (nrnLength(candidate) !== nrnLength(current)) return nrnLength(candidate) > nrnLength(current); return String(candidate.id ?? "") < String(current.id ?? ""); }; const bestByCategory = new Map(); for (const [specId, p] of bestBySpec) { const category = specToCategory.get(specId)!; const c = bestByCategory.get(category); if (!c || beats(p, c)) bestByCategory.set(category, p); } const map: ProvidersMap = {}; for (const [category, provider] of bestByCategory) { const context: Record = { ...(provider.attributes ?? {}) }; for (const [specId, p] of bestBySpec) { if (specToCategory.get(specId) !== category) continue; const slug = specSlug.get(specId); if (slug && !(slug in context)) context[slug] = p.attributes ?? {}; } map[category] = context; } return map; } export interface ReleaseInfo { build_id?: string; [k: string]: unknown; } export async function release(id: string): Promise { return fetchJson(`/release/${id}`); } /** * Every asset on a build, unfiltered. A filtered query for a name that does * not exist returns an empty list, which cannot tell "no such name" apart * from "no assets at all" — fetching the whole set lets callers fall back * sensibly and name what was actually there when they fail. */ export async function assets(buildId: string): Promise { const result = await fetchJson<{ results?: AssetInfo[] }>("/asset", { build_id: buildId, }); return result.results ?? []; } /** * Report how much traffic actually moved. * * The platform does not read this off action results — verified in * production: an action returning `switched_traffic: 100` was recorded * successful while the deployment stayed at 0. The deployment must be * PATCHed, and this is the handler's own obligation while answering its * action — which is why it is the ONE deployment write the SDK offers. * Orchestration (status transitions, strategy stepping, finalization) is the * platform orchestrator's job, never the SDK's. */ export async function reportSwitchedTraffic( deploymentId: string, switchedTraffic: number ): Promise { await sendJson("PATCH", `/deployment/${deploymentId}`, { strategy_data: { switched_traffic: switchedTraffic }, }); } // ─── Resource facade ───────────────────────────────────────────────── // // The same calls, grouped the way people think: `api.scopes.get(id)`, // `api.scopes.list({ nrn })`. Each resource is get + list over the generic // core, so a resource the facade lacks is never a dead end — `get`/`list` // take any path. The flat functions above remain; both styles are the same // implementations. function resource(path: string) { return { get: (id: string | number) => get(`${path}/${id}`), list: (query: Query = {}, opts: { pageSize?: number; max?: number } = {}) => listAll(path, query, opts), /** Page-by-page iteration for large listings. */ iterate: ( query: Query = {}, opts: { pageSize?: number; max?: number } = {} ) => list(path, query, opts), }; } export const scopes = resource("/scope"); export const deployments = resource("/deployment"); export const applications = resource("/application"); export const namespaces = resource("/namespace"); export const accounts = resource("/account"); export const releases = resource("/release");