Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Per tenant filtering

Eugene Lazutkin edited this page Apr 23, 2026 · 2 revisions

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.

The flow

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.

Example: header-derived tenant

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=20Query with tenantId = acme (from the header).
  • GET /items/?status=pending&limit=20exampleFromContext returns {tenantId, status}; your prepareListInput decides what to do with status (use another GSI, add a FilterExpression, ignore it…).

Example: host-derived tenant (subdomain)

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.

Example: Cloudflare Workers request.cf context

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'
  })
});

Example: query-string-driven filter

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
  }
}

Interaction with ?sort=

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}
    };
  }
}

Forwarding body to exampleFromContext

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.

request.body is already consumed

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.

When not to use exampleFromContext

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.

Clone this wiki locally