Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/hooks/cache/cache.hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ hook:
method: ['find', 'get', 'create', 'update', 'patch', 'remove']
multi: true
see:
- utils/passParams
- utils/gateParams
---

The `cache` hook caches `get` and `find` results based on `params`. On mutating methods (`create`, `update`, `patch`, `remove`), affected cache entries are automatically invalidated.
Expand All @@ -21,16 +21,16 @@ The `cache` hook caches `get` and `find` results based on `params`. On mutating
| ----------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `map` | `Cache` | The cache implementation. Must implement `get`, `set`, `delete`, `clear`, and `keys`. |
| `id` | `string` | The id field to use. Defaults to `service.options.id`, then `'id'`. |
| `transformParams` | `(params) => params` | Transform params before they are used as cache key. Compose it with [`passParams`](/utils/pass-params) to declaratively pick/drop keys and avoid false hits — see [Choosing Cache-Relevant Params](#choosing-cache-relevant-params-with-passparams). |
| `transformParams` | `(params) => params` | Transform params before they are used as cache key. Compose it with [`gateParams`](/utils/gate-params) to declaratively pick/drop keys and avoid false hits — see [Choosing Cache-Relevant Params](#choosing-cache-relevant-params-with-gateparams). |

## Choosing Cache-Relevant Params (with `passParams`)
## Choosing Cache-Relevant Params (with `gateParams`)

Deciding which `params` keys form the cache key is the trickiest part of caching, and the two failure modes are asymmetric:

- **False hits (dangerous):** if a key that affects the result is left out (e.g. `user`/tenant, `provider`), two semantically different requests collapse to the same key — one user can be served another user's cached data.
- **False misses (wasteful):** if a per-request/metrics key is included (e.g. `rateLimit`), every request produces a unique key and the cache never hits. A function-valued key (e.g. `stashed` from `stashable`) would even make serialization throw.

The [`passParams`](/utils/pass-params) utility makes this explicit and safe. It takes a declarative path schema (`true` include, `false` drop, or a predicate/projection function). `query` is always included by default, and keys you never classified are **kept by default** — the safe direction, since a forgotten key causes at worst a harmless cache miss, never a false hit.
The [`gateParams`](/utils/gate-params) utility makes this explicit and safe. It takes a declarative path schema (`true` include, `false` drop, or a predicate/projection function). `query` is always included by default, and keys you never classified are **kept by default** — the safe direction, since a forgotten key causes at worst a harmless cache miss, never a false hit.

> Transient keys that feathers-utils' own hooks attach to `params` — `rateLimit` (`rateLimit`), `skipHooks` (`skippable`/`addSkip`), the `stashed` function and `_stashable` flag (`stashable`) — are never cache-relevant. Drop them with `false`, or keep only what you list via `dropUnknownParams: true`.

Expand All @@ -39,12 +39,12 @@ The [`passParams`](/utils/pass-params) utility makes this explicit and safe. It
Cache on everything except the keys you explicitly drop with `false`. This is the default direction — safe against false hits:

```ts
import { passParams } from 'feathers-utils/utils'
import { gateParams } from 'feathers-utils/utils'

cache({
map: new Map(),
transformParams: (params) =>
passParams(params, { rateLimit: false, skipHooks: false }),
gateParams(params, { rateLimit: false, skipHooks: false }),
})
```

Expand All @@ -53,12 +53,12 @@ cache({
Set `dropUnknownParams: true` so only `query` (always) and the listed paths form the cache key. `user.id` is picked via dot-notation so different tenants never collide and per-request `user` fields don't bloat the key. Use `onUnknownParams` to log anything that was dropped:

```ts
import { passParams } from 'feathers-utils/utils'
import { gateParams } from 'feathers-utils/utils'

cache({
map: new Map(),
transformParams: (params) =>
passParams(
gateParams(
params,
{ 'user.id': true }, // `query` is included automatically
{
Expand Down
12 changes: 6 additions & 6 deletions src/hooks/cache/cache.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { TTLCache } from '@isaacs/ttlcache'
import { MemoryService } from '@feathersjs/memory'
import { expect, expectTypeOf } from 'vitest'
import { copy } from 'fast-copy'
import { passParams } from '../../utils/pass-params/pass-params.util.js'
import { gateParams } from '../../utils/gate-params/gate-params.util.js'

const setup = (options: CacheOptions, serviceOptions?: { id?: string }) => {
const app = feathers<{
Expand Down Expand Up @@ -1149,12 +1149,12 @@ describe('cache hook as an around hook', () => {
})
})

describe('cache hook with passParams', () => {
describe('cache hook with gateParams', () => {
it('prevents false hits across users and collapses non-id user fields (whitelist)', async () => {
const { usersService, before } = setup({
map: new Map(),
// `query` is included by default; only `user.id` is added explicitly.
transformParams: (params) => passParams(params, { 'user.id': true }),
transformParams: (params) => gateParams(params, { 'user.id': true }),
})

await usersService.create({ id: 1, name: 'John' })
Expand Down Expand Up @@ -1185,7 +1185,7 @@ describe('cache hook with passParams', () => {
const { usersService, before } = setup({
map: new Map(),
// keep everything except the transient `rateLimit` metric.
transformParams: (params) => passParams(params, { rateLimit: false }),
transformParams: (params) => gateParams(params, { rateLimit: false }),
})

await usersService.create({ id: 1, name: 'John' })
Expand All @@ -1209,7 +1209,7 @@ describe('cache hook with passParams', () => {
map: new Map(),
// keep only `query` (default); `stashed` (a function) is dropped.
transformParams: (params) =>
passParams(params, {}, { dropUnknownParams: true }),
gateParams(params, {}, { dropUnknownParams: true }),
})

await usersService.create({ id: 1, name: 'John' })
Expand All @@ -1235,7 +1235,7 @@ describe('cache hook with passParams', () => {
const { usersService } = setup({
map: new Map(),
transformParams: (params) =>
passParams(params, { query: true }, { onUnknownParams }),
gateParams(params, { query: true }, { onUnknownParams }),
})

await usersService.create({ id: 1, name: 'John' })
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/cache/cache.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,17 @@ export type CacheOptions = {
* There are params properties you don't want to include in the cache key.
* You can use this function to transform the params before they are stringified.
*
* The {@link passParams} util is built for exactly this: it declaratively
* The {@link gateParams} util is built for exactly this: it declaratively
* selects/projects `params` keys (keeping `query` by default) so noise like
* `rateLimit` never ends up in the cache key.
*
* @example
* ```ts
* import { passParams } from 'feathers-utils/utils'
* import { gateParams } from 'feathers-utils/utils'
*
* cache({
* map: new Map(),
* transformParams: (params) => passParams(params, { rateLimit: false }),
* transformParams: (params) => gateParams(params, { rateLimit: false }),
* })
* ```
*/
Expand Down
42 changes: 33 additions & 9 deletions src/hooks/rate-limit/rate-limit.hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
title: rateLimit
category: hooks
hook:
type: ["before", "around"]
method: ["find", "get", "create", "update", "patch", "remove"]
type: ['before', 'around']
method: ['find', 'get', 'create', 'update', 'patch', 'remove']
multi: true
---

Expand All @@ -13,10 +13,10 @@ Any rate limiter backend supported by `rate-limiter-flexible` can be used (Memor

## Options

| Option | Type | Description |
| --- | --- | --- |
| `key` | `(context) => string` | Generate the rate-limiting key. Defaults to `context.path`. |
| `points` | `(context) => number` | Number of points to consume per request. Defaults to `1`. |
| Option | Type | Description |
| -------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | `string \| ((context) => string)` | The rate-limiting key, or a function to derive it from the context. Defaults to `context.path`. Pass a static string for a single shared bucket (a global rate limit). |
| `points` | `number \| ((context) => number)` | Number of points to consume per request, or a function to compute it from the context. Defaults to `1`. |

The `RateLimiterRes` is stored on `context.params.rateLimit` on both success and failure, so downstream hooks or services can inspect `remainingPoints`, `consumedPoints`, `msBeforeNext`, etc.

Expand Down Expand Up @@ -58,16 +58,40 @@ app.service('messages').hooks({
})
```

### Global Rate Limit

Pass a static string as the `key` to share a single bucket across all requests — a global cap on an endpoint instead of one bucket per `context.path`:

```ts
const rateLimiter = new RateLimiterMemory({ points: 1000, duration: 60 })

app.service('search').hooks({
before: {
find: [rateLimit(rateLimiter, { key: 'search' })],
},
})
```

### Custom Points per Request

Use the `points` option to consume more points for expensive operations:
Pass a static number to consume a fixed cost per request:

```ts
app.service('reports').hooks({
before: {
find: [rateLimit(rateLimiter, { points: 5 })],
},
})
```

Or pass a function to compute the cost from the context — e.g. to charge more for expensive queries:

```ts
app.service('reports').hooks({
before: {
find: [
rateLimit(rateLimiter, {
points: (context) => context.params.query?.$limit > 100 ? 5 : 1,
points: (context) => (context.params.query?.$limit > 100 ? 5 : 1),
}),
],
},
Expand Down Expand Up @@ -128,4 +152,4 @@ app.service('users').hooks({

// Skip rate limiting for this call
app.service('users').find({ skipHooks: ['rateLimit'] })
```
```
31 changes: 31 additions & 0 deletions src/hooks/rate-limit/rate-limit.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,37 @@ describe('hook - rateLimit', () => {
)
})

it('uses a static string key as a shared bucket', async () => {
const context: any = {
type: 'before',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })

// Both requests share the same static bucket, so the second is rejected
await rateLimit(rateLimiter, { key: 'global' })(context)
await expect(
rateLimit(rateLimiter, { key: 'global' })(context),
).rejects.toThrow('Too many requests')
})

it('uses static number points', async () => {
const context: any = {
type: 'before',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })

// Consuming 2 points against a 1-point limit should fail immediately
await expect(
rateLimit(rateLimiter, { points: 2 })(context),
).rejects.toThrow('Too many requests')
})

it('throws when used in an after hook', async () => {
const context: any = {
type: 'after',
Expand Down
25 changes: 18 additions & 7 deletions src/hooks/rate-limit/rate-limit.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@ import { checkContext } from '../../utils/index.js'
import type { Promisable } from '../../internal.utils.js'

export type RateLimitOptions<H extends HookContext = HookContext> = {
/** Generate the rate-limiting key. Defaults to `context.path`. */
key?: (context: H) => Promisable<string>
/** Number of points to consume per request. Defaults to `1`. */
points?: (context: H) => Promisable<number>
/**
* The rate-limiting key, or a function to derive it from the context.
* Defaults to `context.path`.
*
* Pass a static string to use a single shared bucket (a global rate limit
* across all requests), or a function to compute the key per request
* (e.g. per user or per IP).
*/
key?: string | ((context: H) => Promisable<string>)
/**
* Number of points to consume per request, or a function to compute it from
* the context. Defaults to `1`.
*/
points?: number | ((context: H) => Promisable<number>)
}

/**
Expand All @@ -35,13 +45,14 @@ export const rateLimit = <H extends HookContext = HookContext>(
options?: RateLimitOptions<H>,
) => {
const key = options?.key ?? ((context: HookContext) => context.path)
const points = options?.points ?? (() => 1)
const points = options?.points ?? 1

return async (context: H, next?: NextFunction): Promise<void> => {
checkContext(context, { type: ['before', 'around'], label: 'rateLimit' })

const resolvedKey = await key(context)
const resolvedPoints = await points(context)
const resolvedKey = typeof key === 'function' ? await key(context) : key
const resolvedPoints =
typeof points === 'function' ? await points(context) : points

try {
const res = await rateLimiter.consume(resolvedKey, resolvedPoints)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: passParams
title: gateParams
category: utils
see:
- hooks/cache
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { Params } from '@feathersjs/feathers'
import { expectTypeOf } from 'vitest'
import { passParams } from './pass-params.util.js'
import { gateParams } from './gate-params.util.js'

it('returns a Params object', () => {
const out = passParams({ query: {} }, { query: true })
const out = gateParams({ query: {} }, { query: true })
expectTypeOf(out).toEqualTypeOf<Params>()
})

it('accepts boolean and function rules, including nested paths and custom keys', () => {
passParams({ query: {}, user: { id: 1 }, custom: 1 } as Params, {
gateParams({ query: {}, user: { id: 1 }, custom: 1 } as Params, {
query: true,
paginate: false,
'user.id': true,
Expand All @@ -22,7 +22,7 @@ it('accepts boolean and function rules, including nested paths and custom keys',
})

it('types onUnknownParams and dropUnknownParams', () => {
passParams(
gateParams(
{ query: {} } as Params,
{ query: true },
{
Expand Down
Loading
Loading