Aster Web Data / Build an API
Rules
State validation and access checks once, in a YAML file beside the model, and the server applies them to every request.
An approved expense must keep its amount. Written as code, that check has to go into every endpoint that writes expenses, and the one that misses it is the one that lets the amount change. A rule states it once, beside the model:
rules:
- id: ApprovedAmountLocked
entity: Expense
host: {Property: amount}
kind: ReadOnlyWhen
condition: "status eq 'approved'"
message: An approved expense keeps its amount
The server checks the rule on every write to Expense, whichever request makes it. A PATCH that changes the amount of an approved expense answers 400 with the code ReadOnly and the target amount, and a change to its title still saves. The condition is $filter text, the same language clients query with.
The rules file
A service names its rules file with rules: rules.yaml. This one gives Expense, a set with an id, a title, an amount and a status, three rules: who may read it, the locked amount, and a check that no expense is negative:
rules:
# Who may read expenses: the blue team (a JWT claim), auditors, or a
# request from the acme tenant (an allow-listed header).
- id: ExpenseReaders
entity: Expense
host: EntitySet
kind: {RequiresWhen: Read}
condition: "AsterData.Claim('team') eq 'blue' or AsterData.HasRole('auditor') or AsterData.Header('X-Tenant') eq 'acme'"
- id: ApprovedAmountLocked
entity: Expense
host: {Property: amount}
kind: ReadOnlyWhen
condition: "status eq 'approved'"
message: An approved expense keeps its amount
- id: NonNegativeAmount
entity: Expense
host: Entity
kind: Constraint
condition: "amount ge 0"
message: An expense cannot be negative
Each rule has these keys:
| Key | Meaning |
|---|---|
id |
The rule's name. A simple identifier, unique for its kind. It appears in $metadata and in the error a refused request returns. |
entity |
The set the rule belongs to. |
host |
What the rule applies to: EntitySet for a gate on the whole set, {Binding: operationId} for a gate on one operation, Entity for a check across several properties, {Property: name} for one property. |
kind |
What the rule does, described below. |
condition |
A boolean condition, written as $filter text. |
message |
The text a failed check returns. Gates do not return a message. |
A rule that does not compile, or that names a field the set does not have, stops the server from starting, and the error names the rule.
Validation rules
Constraint, ReadOnlyWhen and RequiredWhen check a write before it is saved. A write that breaks one answers 400 Bad Request with the code ValidationFailed. The details list each failed Constraint or RequiredWhen rule with its id as the detail's code and the message the rule gives. A field locked by ReadOnlyWhen is listed with the code ReadOnly and the property as its target.
The shop service that the examples below use has two such rules on Invoice:
rules:
- id: PositiveAmount
entity: Invoice
host: Entity
kind: Constraint
condition: "amount gt 0"
message: An invoice amount must be more than zero
- id: PaidInvoiceLocked
entity: Invoice
host: {Property: amount}
kind: ReadOnlyWhen
condition: "paid eq true"
message: A paid invoice keeps its amount
An insert with a negative amount breaks PositiveAmount. The detail's code is the rule's id:
{
"customerId": 1,
"issued": "2026-10-01",
"amount": -20.00,
"paid": false
}As a URL
POST /shop/InvoiceHTTP 400
content-language: en
content-type: application/json;odata.metadata=minimal
odata-version: 4.01{
"error": {
"code": "ValidationFailed",
"message": "An invoice amount must be more than zero",
"details": [
{
"code": "PositiveAmount",
"message": "An invoice amount must be more than zero"
}
]
}
}An update of the amount of a paid invoice breaks PaidInvoiceLocked. The detail's code is ReadOnly and its target is the property:
{
"amount": 1.00
}As a URL
PATCH /shop/Invoice(1)HTTP 400
content-language: en
content-type: application/json;odata.metadata=minimal
odata-version: 4.01{
"error": {
"code": "ValidationFailed",
"message": "A paid invoice keeps its amount",
"details": [
{
"code": "ReadOnly",
"message": "A paid invoice keeps its amount",
"target": "amount"
}
]
}
}The rules run inside the write's transaction, so a failed check saves nothing.
Checking a record without writing it
A bound action checks a record against the set's validators and rules and writes nothing. Use it before a save, for example to show errors while a user edits a form:
POST /odata/Product/AsterData.Validate
Content-Type: application/json
{"Record": {"name": "X", "code": "OK"}}
The answer is 204 No Content when the record passes. A record that fails answers 400 with the same details a save would return.
Conditions
A condition is $filter text. It can use the properties of the record, the properties of a related row through a navigation, and the request facts described below:
- Properties and single-valued navigations:
project/billable. - Related collections:
lines/$count gt 0, orlines/any(l: l/qty gt 10). - Aggregates over related rows:
AsterData.Sum(lines, price), with an optional condition over the related row as its last argument. - Constants:
null,true,false, numbers, quoted text, and dates written without quotes, such as2026-01-31.
Which record an unprefixed name reads depends on the rule's kind and the operation. The ApprovedAmountLocked rule above is checked against the stored expense on an update, so its status is the status the record already has.
| Rule kind | Insert | Update | Delete | Read |
|---|---|---|---|---|
ReadOnlyWhen |
the submitted record | the stored record | not checked | not checked |
RequiredWhen, Constraint |
the submitted record | the stored record with the changes applied | not checked | not checked |
RequiresWhen |
the submitted record | the stored record | the stored record | the request facts only |
ViewableWhen |
the row returned | the row returned | not checked | the row returned |
To read a particular record whatever the operation, name it: AsterData.Stored(status), AsterData.Submitted(status) or AsterData.Merged(status).
Access rules
A gate decides whether a caller may use a set at all. RequiresWhen on the set is checked at every read of it, including a read inside an $expand and the read that answers a write. A read gate reads only the request facts, not the fields of a row. A caller the gate refuses gets 403 Forbidden, with a message that does not say which part of the condition failed.
GET /odata/Expense?$orderby=id
X-Tenant: acme
With the ExpenseReaders rule above, a request that carries X-Tenant: acme reads the expenses. The same request without the header, from a caller with no claim and no role, answers 403.
A refused read does not stop a write. A caller who fails the read gate may still insert into the set, as a write without read. Gates for the other writes are separate RequiresWhen rules, with the kind named: Insert, Update or Delete.
A gate can also apply to one operation. {Binding: operationId} applies to the requests that run that operation, and replaces the set's gate of the same operation for them.
Access rules work alongside the per-set access in Authentication and access. The set's configuration says who may read or write at all, and a gate adds a condition over the caller, the request or, for a write, the record.
Hiding a field
A ViewableWhen rule hides a property for the records that match it. A hidden property reads as null, and the entity lists it in @AsterData.Hidden. A write that names it is refused with ReadOnly.
VisibleWhen is a rule for the user interface. It is published in $metadata for clients to act on, and the server does not enforce it.
Request facts
Rules can read facts about the request itself: the caller, the time, the address, and the headers the operator allows. A header is readable only when the configuration lists it, so a client cannot make a rule read one the operator did not expose:
request_facts:
headers: [X-Tenant]
client_address_header: X-Forwarded-For # set only when a proxy you trust sets it
With that configuration, the rules can read these facts:
| Function | Value |
|---|---|
AsterData.Header('X-Tenant') |
The header's value. Any other header reads as null. |
AsterData.ClientAddress() |
The client's address, from the connection, or from the configured header behind a proxy. |
AsterData.RequestTime() |
The time the request arrived. |
AsterData.UserId() |
The caller's user ID. |
AsterData.HasRole('auditor') |
Whether the caller has the role. |
AsterData.Claim('team') |
The value of a token claim. |
AsterData.Operation() |
The operation the request is running. |
AsterData.WasSent(field) |
Whether the request's body sent the property. |
Set client_address_header only when a proxy you trust sets that header.
Next
- Authentication and access sets who the caller is and which sets they may use.
- Operations in code covers checks that need code.