-
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, request)
│
▼ 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.
A common Fetch pattern is to validate an auth header at the edge and pass the tenant along in a custom header for downstream consumers.
import {Adapter} from 'dynamodb-toolkit';
import {createFetchAdapter} from 'dynamodb-toolkit-fetch';
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;
}
}
});
const handler = createFetchAdapter(items, {
mountPath: '/items',
exampleFromContext: (query, _body, request) => ({
tenantId: request.headers.get('x-tenant-id')
})
});
// Assume upstream auth set x-tenant-id on the Request.
export default {
async fetch(request, env) {
const tenantId = await verifyToken(request.headers.get('authorization'), env);
if (!tenantId) return new Response('Unauthorized', {status: 401});
const enriched = new Request(request, {
headers: new Headers([...request.headers, ['x-tenant-id', tenantId]])
});
return handler(enriched);
}
};Requests:
-
GET /items/?limit=20→QuerywithtenantId = acme(from the header). -
GET /items/?status=pending&limit=20→exampleFromContextreturns{tenantId, status}; yourprepareListInputdecides what to do withstatus(use another GSI, add aFilterExpression, ignore it…).
When tenants map to subdomains (acme.api.example.com), pull from Request.url directly:
const handler = createFetchAdapter(items, {
mountPath: '/items',
exampleFromContext: (query, _body, request) => {
const host = new URL(request.url).host;
const tenantId = host.split('.')[0];
return {tenantId, status: query.status || 'active'};
}
});The adapter hands new URL(request.url) to you directly — Request.url is always the full original URL, absolute and ready.
Workers attach geographic / TLS / bot-score data to request.cf. Use it as part of your example:
const handler = createFetchAdapter(items, {
mountPath: '/items',
exampleFromContext: (query, _body, request) => ({
country: request.cf?.country ?? 'XX',
status: query.status || 'active'
})
});For public APIs where the caller drives filtering, forward query parameters directly:
createFetchAdapter(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:
createFetchAdapter(items, {
exampleFromContext: (query, body, request) => ({
tenantId: request.headers.get('x-tenant-id'),
// 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.
By the time exampleFromContext fires on PUT /-clone / PUT /-move, the adapter has already read the body — that's how you get the body argument. Do not try to re-read request.body or call request.json() again; it will throw TypeError: Body already used. Use the body argument instead.
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.