Aster Web Data / Build an API
Query your data
Use $filter, $orderby, $select, $expand, $count, paging, search and the AsterData functions.
A product list starts as one request: GET /Product returns every product, and the screen works. Then the requests change. Someone wants only products over 15, sorted by price. The invoice screen needs the customer's name next to each invoice. The list must page two rows at a time, and the total must count every product that matches, not only the page. Each of these is one more query option on the same URL, with no change on the server, and the server builds each one into the database's own query.
The examples run against a small shop service on PostgreSQL with four sets: Customer, Invoice, InvoiceLine and Product. A customer has invoices, an invoice has lines, and each line points at a product. A long value array shows its first rows and says how many there were.
Filter
$filter takes an OData 4.01 expression over a set's properties. The first change in the scenario is "only products over 15":
| filter | price gt 15 |
|---|
As a URL
GET /shop/Product?$filter=price gt 15HTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01The first 3 of 7 rows
{
"@odata.context": "http://localhost:8080/shop/$metadata#Product",
"value": [
{
"@odata.etag": "W/\"54706147\"",
"id": 1,
"code": "HM-1",
"name": "Claw hammer",
"price": 18.90
},
{
"@odata.etag": "W/\"54706147\"",
"id": 2,
"code": "DR-2",
"name": "Cordless drill",
"price": 129.00
},
{
"@odata.etag": "W/\"54706147\"",
"id": 3,
"code": "DB-3",
"name": "Drill bit set",
"price": 24.50
}
]
}The comparison operators are eq, ne, gt, ge, lt and le. Combine conditions with and, or and not, and group them with parentheses. in takes a list:
| filter | code in ('HM-1','DR-2') |
|---|
As a URL
GET /shop/Product?$filter=code in ('HM-1','DR-2')Text functions take a property and a value. contains compares as the column's collation does, which is case-sensitive on the shop's columns:
| filter | contains(name,'drill') |
|---|
As a URL
GET /shop/Product?$filter=contains(name,'drill')A property that a set includes from a related row can be filtered as if it were the set's own field. The same property can be selected and ordered by.
To filter a set by its related rows, use a lambda over the navigation. This returns the customers that have at least one invoice over 500:
| filter | invoices/any(i: i/amount gt 500) |
|---|
As a URL
GET /shop/Customer?$filter=invoices/any(i: i/amount gt 500)all(...) and $count over a navigation work the same way.
A filter the server cannot read, such as a property the set does not have, is a 400 with the code InvalidQuery. The error's target names the part of the request that failed.
Order
$orderby sorts the rows. Add desc to reverse a property. The scenario sorts the products over 15 by price, highest first:
| filter | price gt 15 |
|---|---|
| orderby | price desc |
As a URL
GET /shop/Product?$filter=price gt 15&$orderby=price descHTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01The first 3 of 7 rows
{
"@odata.context": "http://localhost:8080/shop/$metadata#Product",
"value": [
{
"@odata.etag": "W/\"54706147\"",
"id": 7,
"code": "WB-7",
"name": "Workbench",
"price": 349.00
},
{
"@odata.etag": "W/\"54706147\"",
"id": 2,
"code": "DR-2",
"name": "Cordless drill",
"price": 129.00
},
{
"@odata.etag": "W/\"54706147\"",
"id": 12,
"code": "LD-12",
"name": "Step ladder",
"price": 89.50
}
]
}The server adds the key properties as the last sort keys. A page therefore never shuffles rows that share a sort value, and paging over the same order gives the same rows each time. Name a second sort property after the first, as in $orderby=region,name.
Nulls sort as the OData standard sets, and Aster states the placement on every sort key. See Comparison and arithmetic rules.
Select
$select returns only the properties a screen needs. The scenario's list needs a name and a price, so the response carries nothing else:
| filter | price gt 15 |
|---|---|
| orderby | price desc |
| select | name,price |
As a URL
GET /shop/Product?$filter=price gt 15&$orderby=price desc&$select=name,priceHTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01The first 3 of 7 rows
{
"@odata.context": "http://localhost:8080/shop/$metadata#Product(name,price,id)",
"value": [
{
"@odata.etag": "W/\"54706147\"",
"name": "Workbench",
"price": 349.00,
"id": 7
},
{
"@odata.etag": "W/\"54706147\"",
"name": "Cordless drill",
"price": 129.00,
"id": 2
},
{
"@odata.etag": "W/\"54706147\"",
"name": "Step ladder",
"price": 89.50,
"id": 12
}
]
}The key is added to every selected row, so the client can address the row afterwards. A service can turn that off with select_without_keys, so that $select returns only the properties asked for.
Expand
The invoice list needs each invoice's customer name. Expanding the navigation returns the related row inside each invoice, in the same response:
| expand | customer |
|---|---|
| select | name |
As a URL
GET /shop/Invoice?$expand=customer($select=name)A collection navigation works in the other direction. Here, one customer lists their two largest invoices, with a count of all their invoices:
| expand | invoices |
|---|---|
| select | issued,amount |
| orderby | amount desc |
| top | 2 |
| count | true |
As a URL
GET /shop/Customer(3)?$expand=invoices($select=issued,amount;$orderby=amount desc;$top=2;$count=true)HTTP 200
content-type: application/json;odata.metadata=minimal
etag: W/"54706134"
odata-version: 4.01{
"@odata.context": "http://localhost:8080/shop/$metadata#Customer(invoices(issued,amount,id))/$entity",
"@odata.etag": "W/\"54706134\"",
"id": 3,
"name": "Birch Supply",
"email": "accounts@birch-supply.example.com",
"region": "NA",
"invoices@odata.count": 7,
"invoices": [
{
"@odata.etag": "W/\"54706138\"",
"issued": "2026-07-26",
"amount": 2094.00,
"id": 7
},
{
"@odata.etag": "W/\"54706138\"",
"issued": "2026-05-04",
"amount": 688.75,
"id": 12
}
]
}Options inside the parentheses apply to the expanded rows only: $select, $filter, $orderby, $top, $skip, $count and a further $expand. $count inside an expansion is exact and sits in the parent row (invoices@odata.count), so counting a page of parents does not need a second request.
A worked example
Real screens combine these options. This request lists the first five EU customers who have an unpaid invoice over 500. Each customer carries their three latest unpaid invoices, each invoice carries its lines, and each line carries the product's name:
| filter | region eq 'EU' and invoices/any(i: i/paid eq false and i/amount gt 500) |
|---|---|
| orderby | name |
| count | true |
| top | 5 |
| select | id,name |
| expand | invoices |
| filter | paid eq false |
| orderby | issued desc |
| top | 3 |
| select | id,issued,amount |
| expand | lines |
| select | quantity,unitPrice |
| expand | product |
| select | name |
As a URL
GET /shop/Customer?$filter=region eq 'EU' and invoices/any(i: i/paid eq false and i/amount gt 500)&$orderby=name&$count=true&$top=5&$select=id,name&$expand=invoices($filter=paid eq false;$orderby=issued desc;$top=3;$select=id,issued,amount;$expand=lines($select=quantity,unitPrice;$expand=product($select=name)))The filter reaches into the invoices (invoices/any(...)), the expansion filters and sorts them again, and the nesting goes three levels deep. The response is one document:
HTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01The first 3 of 5 rows
{
"@odata.context": "http://localhost:8080/shop/$metadata#Customer(id,name,invoices(id,issued,amount,lines(quantity,unitPrice,id,product(name,id))))",
"value": [
{
"@odata.etag": "W/\"54706134\"",
"id": 15,
"name": "Harbor Supply",
"invoices": [
{
"@odata.etag": "W/\"54706138\"",
"id": 54,
"issued": "2026-10-05",
"amount": 1306.50,
"lines": [
{
"@odata.etag": "W/\"54706143\"",
"quantity": 3,
"unitPrice": 129.00,
"id": 143,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Cordless drill",
"id": 2
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 6,
"unitPrice": 129.00,
"id": 140,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Cordless drill",
"id": 2
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 3,
"unitPrice": 24.50,
"id": 142,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Drill bit set",
"id": 3
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 6,
"unitPrice": 12.00,
"id": 141,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Safety glasses",
"id": 8
}
}
]
},
{
"@odata.etag": "W/\"54706138\"",
"id": 53,
"issued": "2026-02-26",
"amount": 82.65,
"lines": [
{
"@odata.etag": "W/\"54706143\"",
"quantity": 5,
"unitPrice": 9.90,
"id": 139,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Tape measure",
"id": 5
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 3,
"unitPrice": 4.80,
"id": 138,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Wood glue",
"id": 9
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 5,
"unitPrice": 3.75,
"id": 137,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Sanding block",
"id": 10
}
}
]
}
]
},
{
"@odata.etag": "W/\"54706134\"",
"id": 17,
"name": "Iris Supply",
"invoices": [
{
"@odata.etag": "W/\"54706138\"",
"id": 59,
"issued": "2026-09-18",
"amount": 830.70,
"lines": [
{
"@odata.etag": "W/\"54706143\"",
"quantity": 3,
"unitPrice": 18.90,
"id": 155,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Claw hammer",
"id": 1
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 6,
"unitPrice": 129.00,
"id": 156,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Cordless drill",
"id": 2
}
}
]
},
{
"@odata.etag": "W/\"54706138\"",
"id": 60,
"issued": "2026-07-30",
"amount": 77.40,
"lines": [
{
"@odata.etag": "W/\"54706143\"",
"quantity": 2,
"unitPrice": 18.90,
"id": 158,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Claw hammer",
"id": 1
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 4,
"unitPrice": 9.90,
"id": 157,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Tape measure",
"id": 5
}
}
]
},
{
"@odata.etag": "W/\"54706138\"",
"id": 61,
"issued": "2026-06-11",
"amount": 133.50,
"lines": [
{
"@odata.etag": "W/\"54706143\"",
"quantity": 6,
"unitPrice": 15.75,
"id": 159,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Spirit level",
"id": 4
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 6,
"unitPrice": 6.50,
"id": 160,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Utility knife",
"id": 6
}
}
]
}
]
},
{
"@odata.etag": "W/\"54706134\"",
"id": 20,
"name": "Juniper Works",
"invoices": [
{
"@odata.etag": "W/\"54706138\"",
"id": 73,
"issued": "2026-06-30",
"amount": 669.50,
"lines": [
{
"@odata.etag": "W/\"54706143\"",
"quantity": 5,
"unitPrice": 129.00,
"id": 187,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Cordless drill",
"id": 2
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 1,
"unitPrice": 24.50,
"id": 186,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Drill bit set",
"id": 3
}
}
]
},
{
"@odata.etag": "W/\"54706138\"",
"id": 75,
"issued": "2026-01-04",
"amount": 523.50,
"lines": [
{
"@odata.etag": "W/\"54706143\"",
"quantity": 4,
"unitPrice": 129.00,
"id": 192,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Cordless drill",
"id": 2
}
},
{
"@odata.etag": "W/\"54706143\"",
"quantity": 2,
"unitPrice": 3.75,
"id": 191,
"product": {
"@odata.etag": "W/\"54706147\"",
"name": "Sanding block",
"id": 10
}
}
]
}
]
}
],
"@odata.count": 7
}From request to SQL shows the statements this request becomes, and Reads across databases runs it with the customers and the invoices on different databases.
Count and paging
$count=true adds @odata.count, which is the total of the filtered set across all pages. Here the products over 15 are counted in full:
| filter | price gt 15 |
|---|---|
| orderby | price desc |
| count | true |
As a URL
GET /shop/Product?$filter=price gt 15&$orderby=price desc&$count=trueHTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01The first 3 of 7 rows
{
"@odata.context": "http://localhost:8080/shop/$metadata#Product",
"value": [
{
"@odata.etag": "W/\"54706147\"",
"id": 7,
"code": "WB-7",
"name": "Workbench",
"price": 349.00
},
{
"@odata.etag": "W/\"54706147\"",
"id": 2,
"code": "DR-2",
"name": "Cordless drill",
"price": 129.00
},
{
"@odata.etag": "W/\"54706147\"",
"id": 12,
"code": "LD-12",
"name": "Step ladder",
"price": 89.50
}
],
"@odata.count": 7
}With $top=2 added, the page holds two rows and the count still covers every match:
HTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01{
"@odata.context": "http://localhost:8080/shop/$metadata#Product",
"value": [
{
"@odata.etag": "W/\"54706147\"",
"id": 7,
"code": "WB-7",
"name": "Workbench",
"price": 349.00
},
{
"@odata.etag": "W/\"54706147\"",
"id": 2,
"code": "DR-2",
"name": "Cordless drill",
"price": 129.00
}
],
"@odata.count": 7
}The count is exact: the server reads the page and runs COUNT(*) in one snapshot, so the total matches the rows.
Paging with next links
A service pages every read, 1,000 rows at a time unless max_page_size says otherwise, so a large $top arrives in pages instead of being refused. A client asks for a smaller page with Prefer: odata.maxpagesize, and the server answers with an @odata.nextLink when more rows follow:
GET /shop/Product?$orderby=price%20desc&$select=name,price
Prefer: odata.maxpagesize=2
Follow @odata.nextLink for the next page. The token in it is opaque: send it back as it came, and do not build it yourself. The next link keeps the query options of the request, because the token is valid only for them.
The token holds the last row's sort values, so a row inserted or deleted between two page requests does not move any other row across a page boundary. Offsets are the exception, because an insert earlier in the order shifts the rows after it: a $skip counts rows on the first page, and a request ordered by an expression, or using $apply, pages by offset throughout. A token sent with a different query is a 400.
Offset paging with $top and $skip
$top and $skip page by offset. The second page of two rows starts after the first two rows:
| orderby | price desc |
|---|---|
| top | 2 |
| skip | 2 |
As a URL
GET /shop/Product?$orderby=price desc&$top=2&$skip=2HTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01{
"@odata.context": "http://localhost:8080/shop/$metadata#Product",
"value": [
{
"@odata.etag": "W/\"54706147\"",
"id": 12,
"code": "LD-12",
"name": "Step ladder",
"price": 89.50
},
{
"@odata.etag": "W/\"54706147\"",
"id": 11,
"code": "SS-11",
"name": "Socket set",
"price": 56.00
}
]
}A set can refuse $skip altogether, in which case the request is a 400. A $top above the page size is served one page at a time, with a next link to the rest.
Group and total
$apply groups and aggregates in the database. This request totals the invoices by the customer's region:
| apply | groupby((customer/region),aggregate(amount with sum as total)) |
|---|
As a URL
GET /shop/Invoice?$apply=groupby((customer/region),aggregate(amount with sum as total))HTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01{
"@odata.context": "http://localhost:8080/shop/$metadata#Invoice(customer/region,total)",
"value": [
{
"@odata.id": null,
"customer": {
"region": "APAC"
},
"total": 21527.25
},
{
"@odata.id": null,
"customer": {
"region": "EU"
},
"total": 22096.95
},
{
"@odata.id": null,
"customer": {
"region": "NA"
},
"total": 30499.95
}
]
}Search
The server does not implement $search. A request that uses it gets 501 NotImplemented. For a text match, use contains in $filter, as in the filter example above. For a match that ignores case, use the AsterData function icontains, described below.
AsterData functions
Some AsterData functions extend $filter with case-insensitive and collation-aware comparisons. They take named arguments:
| Function | Matches |
|---|---|
AsterData.icontains(Property=name,Value='drill') |
the text contains the value, ignoring case |
AsterData.istartswith(Property=name,Value='cord') |
the text starts with the value, ignoring case |
AsterData.iendswith(Property=name,Value='reel') |
the text ends with the value, ignoring case |
AsterData.like(Property=name,Pattern='Cord%25') |
SQL LIKE, with % for any run and _ for one character |
AsterData.regexp(Property=name,Pattern='^Cord',CaseInsensitive=true) |
a regular expression search |
The case-insensitive functions work on any text property. Escape % in a URL as %25. This request finds both drill products whatever the case of the stored name:
| filter | AsterData.icontains(Property=name,Value='DRILL') |
|---|
As a URL
GET /shop/Product?$filter=AsterData.icontains(Property=name,Value='DRILL')HTTP 200
content-type: application/json;odata.metadata=minimal
odata-version: 4.01{
"@odata.context": "http://localhost:8080/shop/$metadata#Product",
"value": [
{
"@odata.etag": "W/\"54706147\"",
"id": 2,
"code": "DR-2",
"name": "Cordless drill",
"price": 129.00
},
{
"@odata.etag": "W/\"54706147\"",
"id": 3,
"code": "DB-3",
"name": "Drill bit set",
"price": 24.50
}
]
}The full list, with the signature of each function, is in the AsterData vocabulary. Rules use a different set of AsterData functions, for claims and request facts, described on Rules.
Next
- Write data creates, updates and deletes the same records.
- From request to SQL shows the statements these requests become.