Files
nocobase/packages/libs/api-client

@nocobase/api-client

A lightweight HTTP and remote Repository client for NocoBase APIs. It does not connect directly to a database and has no React or application-runtime dependency.

import { createApiClient } from '@nocobase/api-client';

const api = createApiClient({ baseURL: '/api' });

const health = await api.request<{ ok: boolean }>({
  path: '/healthz',
});

await api.request({
  path: '/auth/sign-in/email',
  method: 'POST',
  json: { email, password },
});

const upload = new FormData();
upload.append('file', file);

await api.request({
  path: '/attachments/uploadOne',
  method: 'POST',
  body: upload,
});

const stream = await api.stream({
  path: `/chats/${encodeURIComponent(chatId)}/send`,
  method: 'POST',
  json: { message: 'Hello' },
});

const order = await api.repository<Order>('orders').findOne({
  filter: { id: 'order-1' },
});

const orders = await api.repository<Order>('orders').findMany();

for await (const order of api.repository<Order>('orders').findMany()) {
  console.log(order);
}

Use json for values that the client should serialize as JSON. Use body for raw Fetch bodies such as FormData, Blob, and strings. The two options are mutually exclusive. request() defaults to accepting JSON, while stream() defaults to accepting server-sent events; explicit request headers override both defaults.

Repository calls are encoded as POST /{name}/{action}, relative to the API base URL. For example, api.repository('salesOrders').findOne() requests POST /api/salesOrders/findOne. Exposure names are camelCase, which the server enforces when it declares them. The server decides which repositories and actions are exposed and remains responsible for authentication, authorization, validation, and query limits.

findMany() is lazy and supports one consumption mode. Await it to request a complete JSON array, or asynchronously iterate it to request framed NDJSON and process records as they arrive. A query cannot be awaited and iterated, or be iterated twice; create another query to execute it again.

Repository input builders

Every remote Repository method accepts builders in its structured input positions, alongside the existing JSON input forms. Parameters can mix forms independently. Scalar options such as limit, offset, direction, ifVersion, and field arrays such as by and distinct retain their usual values.

const orders = api.repository<Order>('orders');

const query = orders.findMany({
  filter: (f) =>
    f.and([f.string('status').eq('paid'), f.number('amount').gte(100)]),
  select: (s) => s.fields('id', 'status', 'amount'),
  sort: (s) => s.field('amount').desc().nullsLast(),
  limit: 20,
});

const rows = await query;

Callbacks execute synchronously when the method is called, including for lazy findMany queries. Their results and JSON inputs are snapshotted before HTTP execution, so changing a captured variable or the input object afterward does not change the request. Awaiting and streaming use the same snapshot.

Complete options for api.request

Use the corresponding build*Options helper to construct a plain JSON options object without sending a request:

import { buildFindManyOptions } from '@nocobase/api-client';

const options = buildFindManyOptions<Order>({
  filter: (f) => f.string('status').eq('paid'),
  select: (s) => s.fields('id', 'amount'),
  sort: (s) => s.field('amount').desc(),
  limit: 20,
});

const response = await api.request<{ data: Pick<Order, 'id' | 'amount'>[] }>({
  path: '/orders/findMany',
  method: 'POST',
  json: options,
});

// Built options can also be reused with Repository methods.
const rows = await api.repository<Order>('orders').findMany(options);

api.request retains the endpoint's response envelope; Repository methods unwrap { data }. The helpers only build input and do not add pagination defaults or decide which actions a server exposes.

Method Complete options helper Builder positions
findMany buildFindManyOptions filter, select, sort
findOne buildFindOneOptions filter, select, sort
count buildCountOptions filter
exists buildExistsOptions filter
aggregate buildAggregateOptions filter, aggregate
groupBy buildGroupByOptions filter, aggregate, having, sort
createOne buildCreateOneOptions values, select
updateOne buildUpdateOneOptions filter, values, select
deleteOne buildDeleteOneOptions filter, select

The Remote*Options types describe accepted input, including callbacks. The corresponding Remote*OptionsJson types describe built output, without callbacks. count and exists share RemoteFilterOnlyOptionsJson.

Individual parameters

buildFilter, buildSelect, buildSort, and buildAggregate construct their respective ASTs. Use them to store/reuse a parameter or send it to a custom endpoint that accepts that AST:

import { buildFilter, buildAggregate } from '@nocobase/api-client';

await api.request({
  path: '/orders/aggregate',
  method: 'POST',
  json: {
    filter: buildFilter<Order>((f) => f.string('status').eq('paid')),
    aggregate: buildAggregate<Order>((a) => ({
      count: a.count(),
      total: a.sum('amount'),
    })),
  },
});

buildFilter also converts an equality shorthand such as { status: 'paid' } to a Filter AST. Options helpers preserve existing shorthand objects on the wire. Nested select includes and combine branches recursively convert their filter/sort callbacks; they do not need database metadata in the browser.

api.request and api.stream do not inspect arbitrary JSON fields for callbacks. Explicitly build the parameters first. URL query values remain scalars or arrays of scalars; when an endpoint expects an AST as a query-string value, pass JSON.stringify(buildFilter(...)) explicitly.

Mutation values

Mutation methods accept ordinary values, a whole-values callback for variable/literal expressions, and field callbacks for numeric updates or relation operations. buildCreateValues and buildUpdateValues expose the same conversion for individual values objects.

await orders.updateOne({
  filter: (f) => f.string('id').eq(orderId),
  values: (v) => ({
    status: v.literal('paid'),
    amount: (n) => n.increment(10),
    tasks: (r) =>
      r.update({
        filter: (f) => f.string('status').eq('pending'),
        values: { points: (n) => n.multiply(2) },
      }),
  }),
  select: (s) => s.fields('id', 'status', 'amount'),
});

The server still determines whether a field is numeric, a relation, or JSON and validates allowed operations. Ordinary JSON values, including objects with keys such as increment or update, stay data. Nested callbacks are processed only in supported mutation operation positions, never inside literal payloads. Variables are encoded as references; helpers do not supply or authorize a server context, and the standard HTTP adapter does not accept a client-provided context.

Relation create callbacks preserve through data and clientKey. A creation with a client key uses the explicit JSON form below, which can also be sent directly to servers supporting this input protocol:

{
  create: [{
    kind: 'relationCreate',
    version: 1,
    values: { title: 'New task' },
    clientKey: 'local-task',
  }],
}

Repository builders serialize valid Date instances as ISO strings and bigint values as decimal strings. They reject circular data, non-finite numbers, invalid callback results, and functions left in JSON data rather than silently losing input during serialization. These conversion rules apply to Repository builders; raw api.request({ json }) keeps its normal JSON serialization behavior.

AST contracts and builder implementations are shared with @nocobase/db through the browser-compatible @nocobase/repository-input package. The client does not import the database runtime, database drivers, or React.

Aggregate and grouped queries

Enable aggregate and groupBy in the server's defineRepositoryApiRoutes exposure before calling them. Both return promises and accept either JSON ASTs or synchronous builder callbacks. Callbacks execute locally and only their JSON output crosses HTTP. Database context remains a server concern.

const repository = api.repository<{ status: string; amount: number }>('orders');
const aggregate = {
  kind: 'aggregate',
  version: 1,
  items: [
    { kind: 'count', alias: 'count' },
    { kind: 'sum', field: 'amount', alias: 'total' },
    { kind: 'avg', field: 'amount', alias: 'average' },
    { kind: 'min', field: 'amount', alias: 'minimum' },
    { kind: 'max', field: 'amount', alias: 'maximum' },
  ],
} as const;
const totals = await repository.aggregate({
  filter: { status: 'paid' },
  aggregate,
});
const groups = await repository.groupBy({
  by: ['status'],
  aggregate,
  having: {
    kind: 'filter',
    version: 1,
    root: {
      kind: 'group',
      logic: 'and',
      items: [
        { kind: 'condition', path: ['count'], operator: '$gte', value: 2 },
      ],
    },
  },
  sort: {
    kind: 'sort',
    version: 1,
    items: [{ kind: 'field', path: ['total'], direction: 'desc' }],
  },
});

Requests use POST /orders/aggregate and POST /orders/groupBy; the client unwraps { data } into an aggregate object or array of group objects. Exported contracts are RemoteAggregateAst, RemoteAggregateOptions, RemoteGroupByOptions, and RemoteAggregateResult. Result aliases are dynamic. COUNT (including the standalone count() method) returns a safe integer number; counts above Number.MAX_SAFE_INTEGER fail with INVALID_STORED_VALUE. SUM/AVG of integer, BIGINT and DECIMAL fields preserve database-formatted strings; FLOAT/DOUBLE SUM/AVG return numbers. MIN/MAX retain the field's logical type (BIGINT string, integer number). An empty input set returns count 0 and null for SUM/AVG/MIN/MAX; empty grouped results are []. BigInt results are serialized as decimal strings to avoid precision loss, dates as ISO strings, and dialect-specific numeric strings are preserved. These methods do not use findMany pagination or NDJSON streaming.

Authorization is server-only. Remote Repository options and their builders expose neither writePolicy nor policy, and an HTTP request containing either is rejected. Frontend code sends values; the server declares what may be written in each exposure's policy in defineRepositoryApiRoutes, for example policy: { read: true, create: false, update: { scope: true, fields: ['name'] }, delete: false }. Every exposure declares one, and a node it does not grant is refused with 403.