Child entity handlers with nested URLs#
When an entity only makes sense in the context of a parent — line items under a purchase, comments under a ticket, attachments under a document — scope the URL under the parent and use the path variable to filter queries and inject the FK on writes.
The pattern#
A purchase has many line items. The line-item handler mounts under /purchase/{purchaseId}/purchaseLineItem, reads purchaseId from the URL on every request, and:
- On query: adds
purchase.id = purchaseIdas a filter condition, so the list returns only this purchase’s line items. - On write: injects
purchase.idinto the tuple before save, so the FK is always set — the client doesn’t have to pass it in the body. - On save with side effects: updates stock entries when a line item changes.
Real-world example — PurchaseLineItemHandler#
From the clinic reference:
@Component
@RequiredArgsConstructor
@CrudMapping(
mapping = "/purchase/{purchaseId}/purchaseLineItem",
type = PurchaseLineItemModel.class,
secondaryMapping = "/purchase/{purchaseId}/purchaseLineItem/{id}"
)
public class PurchaseLineItemHandler extends AbstractHandler
implements QueryHandler, ReadHandler, SaveHandler {
private final ProxyGenerator proxyGen;
private final StockEntryService seService;
// ---------------------------------------------------------------
// 1. Inject the parent FK from the URL into the tuple on writes
// ---------------------------------------------------------------
@Override
public Tuple preProcessRaw(Tuple data, HandlerContext ctx) {
Map<String, String> inputs = ctx.getParams();
if (null != inputs) {
data.setParentAttribute("purchase.id", inputs.get("purchaseId"));
}
return data;
}
// ---------------------------------------------------------------
// 2. Scope every query to this purchase
// ---------------------------------------------------------------
@Override
public QueryFilter applyQueryFilter(QueryFilter filter, HandlerContext ctx) {
Map<String, String> inputs = ctx.getParams();
if (null != inputs) {
filter.addCondition(Criteria.EQ("purchase.id", inputs.get("purchaseId")));
}
return QueryHandler.super.applyQueryFilter(filter, ctx);
}
// ---------------------------------------------------------------
// 3. Side effect — update stock when a line item is saved
// ---------------------------------------------------------------
@Override
public Tuple onSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) {
// Convert the raw Tuple to a typed model for cleaner access
PurchaseLineItemModel pli = proxyGen.generate(PurchaseLineItemModel.class, tuple);
StockEntry stockEntry = new StockEntry();
stockEntry.setBatchNumber(pli.getBatchNumber());
stockEntry.setExpiryDate(pli.getExpiryDate());
stockEntry.setProduct(pli.getProduct().getId());
stockEntry.setPurchaseId(pli.getPurchase().getId());
// If updating and the product or batch changed, fix the old stock record too
if (dbTuple != null) {
String oldBatch = dbTuple.getAttributeAsString("batchNumber");
Integer oldProduct = dbTuple.getAttributeAsInt("product");
if (!pli.getBatchNumber().equalsIgnoreCase(oldBatch)
|| !pli.getProduct().getId().equals(oldProduct)) {
StockEntry oldEntry = new StockEntry();
oldEntry.setBatchNumber(oldBatch);
oldEntry.setProduct(oldProduct);
oldEntry.setPurchaseId(dbTuple.getAttributeAsInt("purchase"));
seService.updateOldStock(oldEntry);
}
}
seService.updateStock(stockEntry);
return SaveHandler.super.onSave(tuple, dbTuple, ctx, action);
}
}What makes this work#
Nested URL with {purchaseId}#
@CrudMapping(
mapping = "/purchase/{purchaseId}/purchaseLineItem",
secondaryMapping = "/purchase/{purchaseId}/purchaseLineItem/{id}"
){purchaseId} is a path variable, not the primary key of the line item. The framework makes it available via ctx.getParams().get("purchaseId"). The line item’s own primary key sits in {id} on the secondaryMapping.
preProcessRaw — inject the FK before validation#
data.setParentAttribute("purchase.id", inputs.get("purchaseId"));This runs before validate() and before preSave(). The client never has to include { "purchase": { "id": 42 } } in the request body — the URL carries it. This:
- Prevents the client from accidentally setting a different purchase ID.
- Keeps the request body clean — just the line-item fields.
- Works for both create and update (the FK is set on every write).
applyQueryFilter — scope the list#
filter.addCondition(Criteria.EQ("purchase.id", inputs.get("purchaseId")));GET /api/purchase/42/purchaseLineItem returns only purchase 42’s line items. Palmyra adds the JOIN to the purchase table automatically from the dotted path.
onSave — cross-entity side effect#
The handler updates the stock ledger whenever a line item is saved. It uses ProxyGenerator to convert the raw Tuple into a typed PurchaseLineItemModel — this gives you getter methods instead of stringly-typed tuple.get("batchNumber"). The dbTuple (pre-image) lets it detect when the product or batch changed and fix the old stock record accordingly.
The URL calls#
# List line items for purchase 42
GET /api/purchase/42/purchaseLineItem?_orderBy=-id
# Create a line item under purchase 42
POST /api/purchase/42/purchaseLineItem
{ "product": { "id": 7 }, "quantity": 100, "rate": 25.50,
"batchNumber": "B2026-001", "expiryDate": "2027-06-30" }
# Update line item 15 under purchase 42
POST /api/purchase/42/purchaseLineItem/15
{ "quantity": 120 }
# Read a single line item
GET /api/purchase/42/purchaseLineItem/15
# Delete
DELETE /api/purchase/42/purchaseLineItem/15Note that purchase.id never appears in the request body — it’s always derived from the URL.
Apply the pattern to your own entities#
| Parent / child | mapping |
preProcessRaw sets |
applyQueryFilter scopes by |
|---|---|---|---|
| Purchase → Line Item | /purchase/{purchaseId}/lineItem |
purchase.id |
purchase.id |
| Order → Order Item | /order/{orderId}/item |
order.id |
order.id |
| Ticket → Comment | /ticket/{ticketId}/comment |
ticket.id |
ticket.id |
| Employee → Document | /employee/{empId}/document |
employee.id |
employee.id |
The shape is always the same: read the path variable from ctx.getParams(), inject it on writes via setParentAttribute, scope reads via addCondition.
See also: Custom query filters, Cross-entity validation, @CrudMapping.