3. Publish handlers#
The clinic sample does not use the composite CrudHandler — every handler explicitly composes the granular interfaces it needs. The common pattern is QueryHandler + ReadHandler + SaveHandler, extending a project-local AbstractHandler that owns the shared concerns.
The AbstractHandler base#
A local base class keeps ACL, audit, and shared dependencies out of every handler:
// handler/AbstractHandler.java
public abstract class AbstractHandler implements PreProcessor {
@Autowired
protected AuthProvider authProvider;
@Override
public int aclCheck(Tuple tuple, HandlerContext ctx) {
// Fine-grained ACL is enforced by palmyra-dbacl-mgmt at the filter
// level; handlers get full rights once authentication has passed.
return AclRights.ALL;
}
protected String currentUser() {
return authProvider.getUser();
}
}Minimal handler — master data#
The simplest handler is just a composition declaration:
// handler/MstManufacturerHandler.java
@Component
@CrudMapping(
mapping = "/mstManufacturer",
type = MstManufacturerModel.class,
secondaryMapping = "/mstManufacturer/{id}"
)
public class MstManufacturerHandler extends AbstractHandler
implements QueryHandler, ReadHandler, SaveHandler { }secondaryMapping is what makes GET /api/mstManufacturer/{id} work alongside the paginated collection at GET /api/mstManufacturer.
Handler with hooks — stock entries#
Real handlers override applyQueryFilter for default ordering, onQueryResult for computed fields, and preProcessRaw for input normalization:
// handler/StockEntryHandler.java
@Component
@CrudMapping(
mapping = "/stockEntry",
type = StockEntryModel.class,
secondaryMapping = "/stockEntry/{id}"
)
public class StockEntryHandler extends AbstractHandler
implements QueryHandler, ReadHandler, SaveHandler {
@Override
public QueryFilter applyQueryFilter(QueryFilter filter, HandlerContext ctx) {
filter.addOrderDesc("id"); // newest first by default
return QueryHandler.super.applyQueryFilter(filter, ctx);
}
@Override
public Tuple onQueryResult(Tuple tuple, Action action) {
// derive a boolean status from the stock level
Integer currQty = tuple.getAttributeAsInt("currentQuantity");
tuple.setAttribute("status", (currQty == null || currQty == 0) ? 0 : 1);
return QueryHandler.super.onQueryResult(tuple, action);
}
@Override
public Tuple preProcessRaw(Tuple data, HandlerContext ctx) {
Object currQty = data.get("currentQuantity");
data.set("status", Objects.equals(currQty, 0) ? 0 : 1);
return super.preProcessRaw(data, ctx);
}
}Handler with a preSave default#
Stamp defaults on mutations without exposing them to clients:
@Component
@CrudMapping(mapping = "/user", type = UserModel.class, secondaryMapping = "/user/{id}")
public class UserManagementHandler extends AbstractHandler
implements QueryHandler, ReadHandler, CreateHandler, UpdateHandler {
@Override
public Tuple preCreate(Tuple tuple, HandlerContext ctx) {
// the DB has a unique constraint on loginName, not email — keep them in sync
tuple.setAttribute("loginName", tuple.get("email"));
tuple.setAttribute("createdBy", currentUser());
return tuple;
}
}Why granular over CrudHandler?#
The clinic project deliberately avoids the CrudHandler composite because:
- Read-only endpoints (reports, pickers) need
QueryHandler + ReadHandlerbut not mutations. - Master-data handlers use
SaveHandler(upsert) instead of separateCreateHandler + UpdateHandler. - Some endpoints need
CreateHandler + UpdateHandlerbut neverDeleteHandler(audit requires soft-delete through status columns instead).
Composing the exact interfaces your endpoint needs keeps the surface honest.
Try it#
curl "http://localhost:8080/api/mstManufacturer?limit=10&sort=-id"
curl -X POST "http://localhost:8080/api/mstManufacturer" \
-H 'Content-Type: application/json' \
-d '{"name":"Acme Pharma","rating":4,"address":"..."}'See also: QueryHandler, ReadHandler, SaveHandler, PreProcessor.