AkurAI Build
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;