-
Notifications
You must be signed in to change notification settings - Fork 0
Per tenant filtering
Many services need to constrain list operations to data owned by the current user / tenant / account. The adapter exposes exampleFromContext exactly for this — it feeds the example argument of Adapter.prepareListInput(example, index), which is typically wired to populate KeyConditionExpression parameters.
GET /?status=active
│
▼
exampleFromContext(query, null, req)
│
▼ returns {tenantId: 'acme', status: 'active'}
Adapter.getList(opts, example, index)
│
▼
Adapter.prepareListInput(example, index) ← consumer-supplied hook
│
▼ returns {IndexName: 'by-tenant', KeyConditionExpression: …, ExpressionAttributeValues: {':t': 'acme'}}
DynamoDB Query
Every piece is consumer-controlled. The adapter's job is to get {tenantId, status} from the request into the Adapter; what happens beyond that lives in prepareListInput on the Adapter instance.
Auth middleware populates req.user upstream; the adapter merges that into every list query.
import {Adapter} from 'dynamodb-toolkit';
import {createExpressAdapter} from 'dynamodb-toolkit-express';
const items = new Adapter({
client,
table: 'items',
keyFields: ['id'],
hooks: {
prepareListInput(example) {
// example == {tenantId: 'acme', ...}
return {
IndexName: 'by-tenant-index',
KeyConditionExpression: '#t = :t',
ExpressionAttributeNames: {'#t': 'tenantId'},
ExpressionAttributeValues: {':t': example.tenantId}
};
},
prepare(item, isPatch) {
// Ensure writes also carry tenantId
if (!isPatch && !item.tenantId) {
throw Object.assign(new Error('tenantId required'), {status: 422, code: 'MissingTenant'});
}
return item;
}
}
});
app.use(auth); // sets req.user.tenantId
app.use('/items', createExpressAdapter(items, {
exampleFromContext: (query, _body, req) => ({
tenantId: req.user.tenantId
})
}));Requests:
-
GET /items/?limit=20→QuerywithtenantId = acme(fromreq.user). -
GET /items/?status=pending&limit=20→exampleFromContextreturns{tenantId, status}; yourprepareListInputdecides what to do withstatus(use another GSI, add aFilterExpression, ignore it…).
For public APIs where the caller drives filtering, forward query parameters directly:
createExpressAdapter(items, {
exampleFromContext: query => ({
category: query.category,
status: query.status || 'active'
})
});Then prepareListInput picks whichever fields it needs:
hooks: {
prepareListInput(example, index) {
if (example.category) {
return {
IndexName: index || 'by-category',
KeyConditionExpression: '#c = :c',
ExpressionAttributeNames: {'#c': 'category'},
ExpressionAttributeValues: {':c': example.category}
};
}
return {}; // fall back to Scan
}
}If the caller also passes ?sort=createdAt, the adapter looks up sortableIndices.createdAt and passes the resulting index name as the index argument to prepareListInput. Your hook can use it to choose the right GSI:
hooks: {
prepareListInput(example, index) {
// `index` here is whatever sortableIndices resolved to, or undefined.
return {
IndexName: index || 'by-tenant',
KeyConditionExpression: '#t = :t',
ExpressionAttributeNames: {'#t': 'tenantId'},
ExpressionAttributeValues: {':t': example.tenantId}
};
}
}PUT /-clone and PUT /-move carry a body (the overlay). The second argument of exampleFromContext is the parsed body, so you can merge body-derived data into the example:
createExpressAdapter(items, {
exampleFromContext: (query, body, req) => ({
tenantId: req.user.tenantId,
// When the bulk operation includes a specific category overlay, scope the scan
...(body && body.category ? {category: body.category} : {})
})
});GET / and DELETE / pass null for body.
If the filter is purely body-driven (?filter= query), use the parent toolkit's parseFilter / buildFilter pipeline instead. exampleFromContext is for key-condition scoping (which GSI, which partition); ?filter= handles post-query field matching.
Rough guide:
- Partition / GSI selection →
exampleFromContext+prepareListInput. - Post-scan predicates →
?filter=in the URL. - Both at once → do both; they compose.