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 = purchaseId as a filter condition, so the list returns only this purchase’s line items.
  • On write: injects purchase.id into 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/15

Note 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.