-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
48f1c8c
commit e179cf3
Showing
9 changed files
with
232 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/* | ||
* @adonisjs/limiter | ||
* | ||
* (c) AdonisJS | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
import { debuglog } from 'node:util' | ||
|
||
export default debuglog('adonisjs:limiter') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
/* | ||
* @adonisjs/limiter | ||
* | ||
* (c) AdonisJS | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
import string from '@adonisjs/core/helpers/string' | ||
import { RuntimeException } from '@adonisjs/core/exceptions' | ||
|
||
import debug from './debug.js' | ||
import { Limiter } from './limiter.js' | ||
import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js' | ||
|
||
/** | ||
* Limiter manager is used to manage multiple rate limiters | ||
* using different storage providers. | ||
* | ||
* Also, you can create limiter instances with runtime options | ||
* for "requests", "duration", and "blockDuration". | ||
*/ | ||
export class LimiterManager<KnownStores extends Record<string, LimiterManagerStoreFactory>> { | ||
/** | ||
* Configuration with a collection of known stores | ||
*/ | ||
#config: { default: keyof KnownStores; stores: KnownStores } | ||
|
||
/** | ||
* Cached limiters. One limiter is created for a unique combination | ||
* of "store,requests,duration,blockDuration" options | ||
*/ | ||
#limiters: Map<string, Limiter> = new Map() | ||
|
||
constructor(config: { default: keyof KnownStores; stores: KnownStores }) { | ||
this.#config = config | ||
} | ||
|
||
/** | ||
* Creates a unique key for a limiter instance. Since, we allow creating | ||
* limiters with runtime options for "requests", "duration" and "blockDuration". | ||
* The limiterKey is used to identify a limiter instance. | ||
*/ | ||
protected makeLimiterKey(store: keyof KnownStores, options: LimiterConsumptionOptions) { | ||
const chunks = [`s:${String(store)}`, `r:${options.requests}`, `d:${options.duration}`] | ||
if (options.blockDuration) { | ||
chunks.push(`bd:${options.blockDuration}`) | ||
} | ||
return chunks.join(',') | ||
} | ||
|
||
/** | ||
* Make a limiter instance for a given store and with | ||
* runtime options. | ||
* | ||
* Caches instances forever for the lifecycle of the process. | ||
*/ | ||
use(options: LimiterConsumptionOptions): Limiter | ||
use<K extends keyof KnownStores>(store: K, options: LimiterConsumptionOptions): Limiter | ||
use( | ||
store: keyof KnownStores | LimiterConsumptionOptions, | ||
options?: LimiterConsumptionOptions | ||
): Limiter { | ||
/** | ||
* Normalize options | ||
*/ | ||
let storeToUse: keyof KnownStores = typeof store === 'string' ? store : this.#config.default | ||
let optionsToUse: LimiterConsumptionOptions | undefined = | ||
typeof store === 'object' ? store : options | ||
|
||
/** | ||
* Ensure options are defined | ||
*/ | ||
if (!optionsToUse) { | ||
throw new RuntimeException( | ||
'Specify the number of allowed requests and duration to create a limiter' | ||
) | ||
} | ||
|
||
optionsToUse.duration = string.seconds.parse(optionsToUse.duration) | ||
if (optionsToUse.blockDuration) { | ||
optionsToUse.blockDuration = string.seconds.parse(optionsToUse.blockDuration) | ||
} | ||
|
||
/** | ||
* Make limiter key to uniquely identify a limiter | ||
*/ | ||
const limiterKey = this.makeLimiterKey(storeToUse, optionsToUse) | ||
debug('created limiter key "%s"', limiterKey) | ||
|
||
/** | ||
* Read and return from cache | ||
*/ | ||
if (this.#limiters.has(limiterKey)) { | ||
debug('re-using cached limiter store "%s", options %O', storeToUse, optionsToUse) | ||
return this.#limiters.get(limiterKey)! | ||
} | ||
|
||
/** | ||
* Create a fresh instance and cache it | ||
*/ | ||
const limiter = new Limiter(this.#config.stores[storeToUse](optionsToUse)) | ||
debug('creating new limiter instance "%s", options %O', storeToUse, optionsToUse) | ||
this.#limiters.set(limiterKey, limiter) | ||
return limiter | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
/* | ||
* @adonisjs/limiter | ||
* | ||
* (c) AdonisJS | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
import { test } from '@japa/runner' | ||
|
||
import { createRedis } from './helpers.js' | ||
import { Limiter } from '../src/limiter.js' | ||
import LimiterRedisStore from '../src/stores/redis.js' | ||
import { LimiterManager } from '../src/limiter_manager.js' | ||
|
||
test.group('Limiter manager', () => { | ||
test('create limiter instances using manager', async ({ assert }) => { | ||
const redis = createRedis(['rlflx:ip_localhost']).connection() | ||
const limiterManager = new LimiterManager({ | ||
default: 'redis', | ||
stores: { | ||
redis: (options) => new LimiterRedisStore(redis, options), | ||
}, | ||
}) | ||
|
||
const limiter = limiterManager.use('redis', { requests: 10, duration: '2 minutes' }) | ||
assert.instanceOf(limiter, Limiter) | ||
|
||
const response = await limiter.consume('ip_localhost') | ||
assert.containsSubset(response.toJSON(), { | ||
limit: 10, | ||
remaining: 9, | ||
consumed: 1, | ||
}) | ||
assert.closeTo(response.availableIn, 120, 5) | ||
}) | ||
|
||
test('re-use instances as long as all options are the same', async ({ assert }) => { | ||
const redis = createRedis(['rlflx:ip_localhost']).connection() | ||
const limiterManager = new LimiterManager({ | ||
default: 'redis', | ||
stores: { | ||
redis: (options) => new LimiterRedisStore(redis, options), | ||
}, | ||
}) | ||
|
||
assert.strictEqual( | ||
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), | ||
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }) | ||
) | ||
assert.strictEqual( | ||
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), | ||
limiterManager.use({ requests: 10, duration: '2 minutes' }) | ||
) | ||
assert.notStrictEqual( | ||
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), | ||
limiterManager.use('redis', { requests: 10, duration: '1 minute' }) | ||
) | ||
assert.notStrictEqual( | ||
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), | ||
limiterManager.use('redis', { requests: 5, duration: '2 minutes' }) | ||
) | ||
assert.notStrictEqual( | ||
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), | ||
limiterManager.use('redis', { requests: 10, duration: '2 minutes', blockDuration: '2 mins' }) | ||
) | ||
}) | ||
|
||
test('throw error when no options are provided', async ({ assert }) => { | ||
const redis = createRedis(['rlflx:ip_localhost']).connection() | ||
const limiterManager = new LimiterManager({ | ||
default: 'redis', | ||
stores: { | ||
redis: (options) => new LimiterRedisStore(redis, options), | ||
}, | ||
}) | ||
|
||
assert.throws( | ||
// @ts-expect-error | ||
() => limiterManager.use('redis'), | ||
'Specify the number of allowed requests and duration to create a limiter' | ||
) | ||
assert.throws( | ||
// @ts-expect-error | ||
() => limiterManager.use(), | ||
'Specify the number of allowed requests and duration to create a limiter' | ||
) | ||
}) | ||
}) |