aster.Web Data / Docs Aster UI ↗

Aster Web Data / Build an API

Operations in code

Add actions and functions to a set, written as Python or TypeScript functions that run in the request's transaction.

Approving an order is more than a PATCH. The order must still be open, the approver must hold the right role, and the approval must leave an audit record, all or nothing. An operation gives that work a name a client can call, such as POST /odata/Order(3)/Ops.approve. You declare it in the DataSource and write it as one function in a Python or TypeScript script. The function changes data through the same engine calls as a client's requests, as the caller, in the request's transaction, so the set's access rules apply to it and its writes roll back together.

An approve action

The Order DataSource names its script and declares two operations: an approve action on one order, and a summary function over the set:

dataSource:
  ID: Order
  serverType: sql
  tableName: ops_order
  script:
    file: orders.py
    # A script that holds the transaction too long is cut off at `runMs`.
    limits: {runMs: 300, turnMs: 300, hostCalls: 40}
  etag: {columns: [status, total, note]}
  fields:
    - {name: id, type: integer, primaryKey: true}
    - {name: status, type: text, length: 20}
    - {name: total, type: decimal, precision: 10, scale: 2}
    - {name: note, type: text, length: 200}
  operations:
    - name: approve
      kind: action
      bound: entity
      parameters:
        - {name: note, type: text, optional: true}
      returns: {entity: Order}
      requires: {role: approver}
    - name: summary
      kind: function
      bound: collection
      parameters:
        - {name: status, type: text}
      returns: {type: integer}

The function in orders.py takes the operation's parameters and a context. It refuses an order that is not open, updates the order under the ETag it was read with, and records the approval:

def approve(params, ctx):
    order = ctx['row']
    if order['status'] != 'open':
        raise Conflict('Only an open order can be approved', code='InvalidState', target='status')
    approver = ctx['principal']['id']
    updated = db.update('Order', ctx['key'], {'status': 'approved', 'note': params['note']},
                        if_match=order['@etag'])
    db.insert('Audit', {'id': updated['id'], 'what': f'approved by {approver}'})
    return updated

The same function in TypeScript, in orders.ts:

type Order = {
  id: bigint;
  status: string;
  total: string;
  note: string | null;
  '@etag'?: string;
};

interface OrderCtx extends OpCtx {
  key: { id: bigint };
  row: Order;
}

export async function approve(params: { note: string | null }, ctx: OrderCtx): Promise<Order> {
  const order = ctx.row;
  if (order.status !== 'open') {
    throw new aster.Conflict('Only an open order can be approved', { code: 'InvalidState', target: 'status' });
  }
  const approver = ctx.principal.id;
  const updated = (await aster.db.update('Order', ctx.key, { status: 'approved', note: params.note }, {
    ifMatch: order['@etag'],
  })) as Order;
  await aster.db.insert('Audit', { id: updated.id, what: `approved by ${approver}` });
  return updated;
}

A client calls the action on one order with POST, and the function with GET. The namespace in the path is the service's namespace, Ops here:

POST /odata/Order(3)/Ops.approve        body: {"note": "checked"}
GET  /odata/Order/Ops.summary(status='open')

With the development authentication dev-headers, the X-Aster-User and X-Aster-Roles headers choose the caller. For production, see Authentication and access.

curl -X POST 'http://127.0.0.1:8080/odata/Order(3)/Ops.approve' \
  -H 'Content-Type: application/json' \
  -H 'X-Aster-User: u1' -H 'X-Aster-Roles: approver' \
  -d '{"note": "checked"}'

The answer is the approved order. A caller without the approver role gets 403, and an order that is not open gets 409 with the code InvalidState.

Declaring an operation

Each entry under operations: takes these keys:

Key Meaning
name The operation's name in the API.
kind action (the default), called with POST, or function, called with GET.
bound entity (the default), which runs on one row, or collection, which runs on the set.
script The function to call. It defaults to the operation's name.
parameters A list of {name, type}, with optional: true where a value may be left out. The types are text, integer, boolean, float, decimal, date, time, datetime, duration, guid and binary. A value of the wrong type is refused with 400.
returns {entity: <this set>} for one row, {entities: <this set>} for several, or {type: <type>} for a value. A function that returns anything else is refused, and its writes roll back.
requires A role the caller must hold, such as {role: approver}. A caller without it gets 403.

What the script receives

The function gets two arguments: the parameters, by name, and a context.

Context field Holds
attempt The attempt number, counting from 1.
principal The caller: id and roles.
key The key of the row an entity operation runs on.
row The row as stored before the operation, with its @etag.

The script reads and changes data only through db (aster.db in TypeScript, where each call returns a promise). Each call is a request of the protocol made as the caller, so the set's access rules and server rules apply:

Python TypeScript Does
db.fetch(set, filter=None, top=None) aster.db.fetch(set, filter?, top?) Reads rows. filter is OData $filter text, such as "status eq 'open'".
db.get(set, key) aster.db.get(set, key) Reads one row by key, or None / null.
db.insert(set, values) aster.db.insert(set, values) Inserts a row and returns the stored row.
db.update(set, key, values, if_match=None) aster.db.update(set, key, values, { ifMatch }) Updates one row and returns the stored row.
db.delete(set, key, if_match=None) aster.db.delete(set, key, { ifMatch }) Deletes one row.

An update or delete with a stale ETag is refused with 412. The script can catch that refusal and raise its own error, or let it end the operation with 412. An operation raises the same error classes as a virtual data source, with the same statuses, and takes the same limits:.

Transactions and retries

An action runs inside the request's write transaction. If the script raises an error, or one of its engine calls is refused, every write in the request rolls back, including writes made by other parts of the request. A function runs without a transaction, and each of its reads stands alone.

If the database reports a conflict on the action's transaction, the action runs again from the start, and attempt counts the runs. Write the function so that a second run over the current row is correct: read the row, then write under the ETag you read, as approve does.

Numbers and decimals

Decimal values reach the script as text, so no amount passes through a binary float. Decimal (aster.Decimal in TypeScript) computes with them exactly. It has methods rather than operators: add, sub, mul, div, round, cmp, neg and abs. Compare two decimals with cmp; in Python, == on decimals raises TypeError. div and round take a scale and a rounding mode, named the same in both languages: halfEven, halfUp, halfDown, down, up, floor and ceiling.

def reprice(params, ctx):
    total = Decimal(params['price']).mul(params['qty']).round(2, params['mode'])
    return db.update('Order', ctx['key'], {'total': total}, if_match=ctx['row']['@etag'])
export async function reprice(params: { price: string; qty: bigint; mode: RoundingMode }, ctx: OrderCtx) {
  const total = new aster.Decimal(params.price).mul(params.qty).round(2, params.mode);
  return aster.db.update('Order', ctx.key, { total }, { ifMatch: ctx.row['@etag'] });
}

An overflow, a division by zero or a bad decimal raises a ValidationError with a code such as DecimalOverflow or DivisionByZero. In TypeScript, 64-bit integers are bigint, so write a literal as 1000n.

Calling an API from an operation

An operation can call a REST API on a connection named in the server configuration: http.post(...) and the other methods in Python, fetch(...) in TypeScript. The connection holds the base URL, the credential, the timeout, the retries and the rate limit, and the script names only the connection and a path. Virtual data sources describes the call and its answer, and Configuration lists the connection's keys.

This action files an issue with a tracker and records it in a stored set, in the request's write transaction:

dataSource:
  ID: Filing
  serverType: sql
  tableName: filing
  script: filings.py
  fields:
    - {name: id, type: sequence, primaryKey: true}
    - {name: issueId, type: integer}
    - {name: title, type: text, length: 200}
  operations:
    - name: file
      kind: action
      bound: collection
      parameters:
        - {name: title, type: text}
      returns: {entity: Filing}
def file(params, ctx):
    r = http.post('/issues', connection='tracker', json={'title': params['title']})
    if r.status_code == 422:
        raise ValidationError(r.json()['message'], code='TrackerRefused', target='title')
    if r.status_code != 201:
        fail('TrackerError', f'the tracker answered {r.status_code}')
    issue = r.json()
    return db.insert('Filing', {'issueId': issue['id'], 'title': issue['title']})
type Issue = { id: number; title: string };

export async function file(params: { title: string }) {
  const r = await fetch('/issues', {
    connection: 'tracker',
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: params.title }),
  });
  if (r.status === 422) {
    const { message } = await r.json();
    throw new aster.ValidationError(message, { code: 'TrackerRefused', target: 'title' });
  }
  if (r.status !== 201) aster.fail('TrackerError', `the tracker answered ${r.status}`);
  const issue: Issue = await r.json();
  return aster.db.insert('Filing', { issueId: issue.id, title: issue.title });
}

The tracker refuses an empty title with 422, and the script turns that into a ValidationError, so the caller gets 400 with the tracker's message. Any other unexpected status fails the action with a code of the script's choosing, and the Filing row is not written. A POST is not retried unless the call passes retry, so a timeout cannot file the issue twice.

curl -X POST 'http://127.0.0.1:8080/odata/Filing/Tracker.file' \
  -H 'Content-Type: application/json' -H 'X-Aster-User: ann' \
  -d '{"title":"Dark mode resets on reload"}'

TypeScript scripts

A TypeScript script is a .ts file. The server picks the language from the extension and refuses any other, .js included, so plain JavaScript goes in a .ts file too. The server strips the types without checking them: put aster.d.ts beside the scripts, which declares OpCtx, RoundingMode and everything else used above, and run tsc --noEmit in your editor or in CI.

Functions may be async, and a promise that can never settle fails the call. Scripts have no module loader, so import is not available, and decorators are refused when the script loads.

Next