Menu
Public repository / master
AkurAI-Framework / site/frontend/akurai-client.js
Latest change 1d2bfb32048e3c8b890ef369116a9fa65bac00ec - Add typed client SDK (akurai-client.js), demo page, and docs by Ólafur Búi Ólafsson
// akurai-client.js — a generated, typed JS client for the AkurAI auto-API.
//
// No build step. This is hand-written native ES module the browser loads
// directly. "Typed" means JSDoc `@typedef` + `@param`/`@returns` annotations,
// which editors and TS-in-checkJS understand without any compilation.
//
// Usage:
//
// import { createClient } from "/akurai-client.js";
// const client = await createClient(); // awaits GET /api/_meta
// const posts = await client.posts.list(); // GET .../posts/records
// const made = await client.posts.create({ title: "Hi", body: "world" });
// const one = await client.posts.get(made.id);
// await client.posts.update(made.id, { title: "Edited" });
// const hits = await client.posts.search("world", { limit: 5 });
// await client.posts.delete(made.id);
//
// The collection accessors (`client.posts`, …) are built dynamically from the
// `/api/_meta` manifest at connect time, so they mirror exactly the collections
// your server declared in `backend/collections.toml`. Inspect `client.meta` for
// the raw manifest and `client.collectionNames` for the available accessors.
/**
* A record stored in a collection. Every record carries two engine-owned keys
* plus whatever fields the collection declares.
*
* @typedef {Object} AkuraiRecord
* @property {number} id Auto-increment primary key (engine-owned).
* @property {number} created Unix seconds the record was created (engine-owned).
* @property {*} [*] One key per declared field (e.g. `title`, `body`).
*/
/**
* A single declared field in a collection's schema.
*
* @typedef {Object} AkuraiField
* @property {string} name Field name.
* @property {("text"|"int"|"float"|"bool")} type Field type.
* @property {boolean} required Whether create requires the field.
* @property {boolean} embed Whether the field is indexed for semantic search.
*/
/**
* The manifest entry for one collection, as returned under `/api/_meta`.
*
* @typedef {Object} AkuraiCollectionMeta
* @property {string} name Collection name (the accessor key).
* @property {AkuraiField[]} fields Declared fields.
* @property {Object<string,string>} endpoints Human-readable endpoint descriptors.
*/
/**
* Options for a list/search call.
*
* @typedef {Object} AkuraiListOpts
* @property {number} [limit] Cap the number of records returned (`?limit=`).
*/
/**
* The typed accessor for one collection. `createClient` builds one of these per
* collection in the manifest and hangs it off the client as `client.<name>`.
*
* @typedef {Object} AkuraiCollectionClient
* @property {(opts?: AkuraiListOpts) => Promise<AkuraiRecord[]>} list
* List records, newest first. Honors `opts.limit`.
* @property {(data: Object) => Promise<AkuraiRecord>} create
* Create a record from a JSON body. Resolves to the created record.
* @property {(id: number|string) => Promise<AkuraiRecord>} get
* Fetch one record by id.
* @property {(id: number|string, patch: Object) => Promise<AkuraiRecord>} update
* Partial-update a record by id. Resolves to the updated record.
* @property {(id: number|string) => Promise<void>} delete
* Delete a record by id. Resolves once the server returns 204.
* @property {(query: string, opts?: AkuraiListOpts) => Promise<AkuraiRecord[]>} search
* Search records via `?search=`. Honors `opts.limit`.
*/
/**
* The connected client. In addition to the dynamic per-collection accessors
* (one `AkuraiCollectionClient` per declared collection, keyed by collection
* name — e.g. `client.posts`), it exposes the raw manifest and helpers.
*
* Because accessor keys are dynamic, they cannot be statically listed here; use
* `client.collectionNames` to enumerate them at runtime.
*
* @typedef {Object} AkuraiClient
* @property {Object} meta The raw `/api/_meta` document.
* @property {AkuraiCollectionMeta[]} collections The `collections` array from the manifest.
* @property {string[]} collectionNames Names of every available collection accessor.
* @property {string} baseUrl The base URL all requests are made against.
* @property {AkuraiCollectionClient} [*] One accessor per collection (e.g. `posts`).
*/
/**
* An error thrown when the auto-API responds with a non-2xx status. Carries the
* HTTP `status` and the server's `{ error }` message when present.
*/
export class AkuraiError extends Error {
/**
* @param {string} message Human-readable message (server error + status).
* @param {number} status The HTTP status code.
* @param {string} url The request URL that failed.
*/
constructor(message, status, url) {
super(message);
this.name = "AkuraiError";
this.status = status;
this.url = url;
}
}
/**
* Issue a fetch and parse the JSON body, throwing a useful {@link AkuraiError}
* on any non-2xx response (including the server's `{ error }` message).
*
* @param {string} url Fully-qualified request URL.
* @param {RequestInit} [init] Fetch init (method, headers, body).
* @returns {Promise<*>} Parsed JSON body, or `null` on 204.
*/
async function request(url, init) {
const res = await fetch(url, init);
if (res.status === 204) return null;
let payload = null;
const text = await res.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text; // non-JSON body; keep raw text for the error message
}
}
if (!res.ok) {
const serverMsg =
payload && typeof payload === "object" && payload.error
? payload.error
: typeof payload === "string" && payload
? payload
: res.statusText;
throw new AkuraiError(
`AkurAI API ${res.status} on ${url}: ${serverMsg}`,
res.status,
url,
);
}
return payload;
}
/** JSON request headers shared by create/update. */
const JSON_HEADERS = { "Content-Type": "application/json" };
/**
* Build the typed accessor object for a single collection.
*
* @param {string} baseUrl Base URL (no trailing slash) requests target.
* @param {string} name Collection name.
* @returns {AkuraiCollectionClient} The per-collection method bundle.
*/
function makeCollectionClient(baseUrl, name) {
const base = `${baseUrl}/api/collections/${encodeURIComponent(name)}/records`;
/**
* Append `?search=`/`?limit=` query params to the records URL.
*
* @param {{ search?: string, limit?: number }} params
* @returns {string}
*/
const withQuery = (params) => {
const qs = new URLSearchParams();
if (params.search != null) qs.set("search", params.search);
if (params.limit != null) qs.set("limit", String(params.limit));
const q = qs.toString();
return q ? `${base}?${q}` : base;
};
return {
/**
* @param {AkuraiListOpts} [opts]
* @returns {Promise<AkuraiRecord[]>}
*/
list(opts = {}) {
return request(withQuery({ limit: opts.limit }));
},
/**
* @param {Object} data The record body (declared fields only).
* @returns {Promise<AkuraiRecord>}
*/
create(data) {
return request(base, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(data),
});
},
/**
* @param {number|string} id
* @returns {Promise<AkuraiRecord>}
*/
get(id) {
return request(`${base}/${encodeURIComponent(id)}`);
},
/**
* @param {number|string} id
* @param {Object} patch Partial fields to update.
* @returns {Promise<AkuraiRecord>}
*/
update(id, patch) {
return request(`${base}/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: JSON_HEADERS,
body: JSON.stringify(patch),
});
},
/**
* @param {number|string} id
* @returns {Promise<void>}
*/
async delete(id) {
await request(`${base}/${encodeURIComponent(id)}`, { method: "DELETE" });
},
/**
* @param {string} query
* @param {AkuraiListOpts} [opts]
* @returns {Promise<AkuraiRecord[]>}
*/
search(query, opts = {}) {
return request(withQuery({ search: query, limit: opts.limit }));
},
};
}
/**
* Create and connect a typed client against the AkurAI auto-API.
*
* This is async because it must await `GET /api/_meta` to discover collections
* before it can build their accessors. The returned client has one
* {@link AkuraiCollectionClient} per declared collection, keyed by name.
*
* @param {string} [baseUrl=""] Origin to target. Default `""` (same origin).
* @returns {Promise<AkuraiClient>} The connected client.
* @throws {AkuraiError} If `/api/_meta` cannot be fetched.
*/
export async function createClient(baseUrl = "") {
// Normalize: drop a trailing slash so we can concatenate paths cleanly.
const root = baseUrl.replace(/\/+$/, "");
const meta = await request(`${root}/api/_meta`);
const collections = Array.isArray(meta && meta.collections)
? meta.collections
: [];
/** @type {AkuraiClient} */
const client = /** @type {any} */ ({
meta,
collections,
baseUrl: root,
collectionNames: collections.map((c) => c.name),
});
for (const coll of collections) {
client[coll.name] = makeCollectionClient(root, coll.name);
}
console.log(
`[client] connected, ${collections.length} collection(s): ${client.collectionNames.join(", ") || "(none)"}`,
);
return client;
}
export default createClient;