diff --git a/bin/test.ts b/bin/test.ts
index 800b093..da7b278 100644
--- a/bin/test.ts
+++ b/bin/test.ts
@@ -9,6 +9,7 @@
import { assert } from '@japa/assert'
import { fileSystem } from '@japa/file-system'
+import { expectTypeOf } from '@japa/expect-type'
import { processCLIArgs, configure, run } from '@japa/runner'
/*
@@ -27,7 +28,7 @@ import { processCLIArgs, configure, run } from '@japa/runner'
processCLIArgs(process.argv.slice(2))
configure({
files: ['tests/**/*.spec.ts'],
- plugins: [assert(), fileSystem()],
+ plugins: [assert(), fileSystem(), expectTypeOf()],
forceExit: true,
})
diff --git a/package.json b/package.json
index b679764..6d680eb 100644
--- a/package.json
+++ b/package.json
@@ -24,8 +24,8 @@
"./types": "./build/src/types.js"
},
"scripts": {
- "pretest": "npm run lint",
- "test": "npm run test:pg && npm run test:mysql && cp -r coverage/*/tmp/. coverage/tmp && c8 report",
+ "pretest": "npm run lint && del-cli coverage",
+ "test": "npm run test:pg && npm run test:mysql && mkdir coverage/tmp && cp -r coverage/*/tmp/. coverage/tmp && c8 report",
"test:pg": "cross-env DB=pg c8 --reporter=json --report-dir=coverage/pg npm run quick:test",
"test:mysql": "cross-env DB=mysql c8 --reporter=json --report-dir=coverage/mysql npm run quick:test",
"clean": "del-cli build",
@@ -54,6 +54,7 @@
"@adonisjs/redis": "^8.0.1",
"@adonisjs/tsconfig": "^1.1.8",
"@japa/assert": "^2.1.0",
+ "@japa/expect-type": "^2.0.1",
"@japa/file-system": "^2.2.0",
"@japa/runner": "^3.1.1",
"@swc/core": "^1.3.68",
diff --git a/src/define_config.ts b/src/define_config.ts
new file mode 100644
index 0000000..4d0f569
--- /dev/null
+++ b/src/define_config.ts
@@ -0,0 +1,168 @@
+/*
+ * @adonisjs/limiter
+ *
+ * (c) AdonisJS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+///
+///
+
+import { configProvider } from '@adonisjs/core'
+import type { ConfigProvider } from '@adonisjs/core/types'
+import type { RedisConnectionsList } from '@adonisjs/redis/types'
+import { InvalidArgumentsException } from '@adonisjs/core/exceptions'
+
+import debug from './debug.js'
+import LimiterRedisStore from './stores/redis.js'
+import LimiterMemoryStore from './stores/memory.js'
+import LimiterDatabaseStore from './stores/database.js'
+import type {
+ LimiterRedisStoreConfig,
+ LimiterMemoryStoreConfig,
+ LimiterManagerStoreFactory,
+ LimiterDatabaseStoreConfig,
+ LimiterConsumptionOptions,
+} from './types.js'
+
+/**
+ * Helper to define limiter config. This function exports a
+ * config provider and hence you cannot access raw config
+ * directly.
+ *
+ * Therefore use the "limiterManager.config" property to access
+ * raw config.
+ */
+export function defineConfig<
+ KnownStores extends Record<
+ string,
+ LimiterManagerStoreFactory | ConfigProvider
+ >,
+>(config: {
+ default: keyof KnownStores
+ stores: KnownStores
+}): ConfigProvider<{
+ default: keyof KnownStores
+ stores: {
+ [K in keyof KnownStores]: KnownStores[K] extends ConfigProvider ? A : KnownStores[K]
+ }
+}> {
+ /**
+ * Limiter stores should always be provided
+ */
+ if (!config.stores) {
+ throw new InvalidArgumentsException('Missing "stores" property in limiter config')
+ }
+
+ /**
+ * Default store should always be provided
+ */
+ if (!config.default) {
+ throw new InvalidArgumentsException(`Missing "default" store in limiter config`)
+ }
+
+ /**
+ * Default store should be configured within the stores
+ * object
+ */
+ if (!config.stores[config.default]) {
+ throw new InvalidArgumentsException(
+ `Missing "stores.${String(
+ config.default
+ )}" in limiter config. It is referenced by the "default" property`
+ )
+ }
+
+ return configProvider.create(async (app) => {
+ debug('resolving limiter config')
+
+ const storesList = Object.keys(config.stores)
+ const stores = {} as Record<
+ string,
+ LimiterManagerStoreFactory | ConfigProvider
+ >
+
+ /**
+ * Looping for stores collection and invoking
+ * config providers to resolve stores in use
+ */
+ for (let storeName of storesList) {
+ const store = config.stores[storeName]
+ if (typeof store === 'function') {
+ stores[storeName] = store
+ } else {
+ stores[storeName] = await store.resolver(app)
+ }
+ }
+
+ return {
+ default: config.default,
+ stores: stores as {
+ [K in keyof KnownStores]: KnownStores[K] extends ConfigProvider
+ ? A
+ : KnownStores[K]
+ },
+ }
+ })
+}
+
+/**
+ * Config helpers to instantiate limiter stores inside
+ * an AdonisJS application
+ */
+export const stores: {
+ /**
+ * Configure redis limiter store
+ */
+ redis: (
+ config: Omit & {
+ connectionName: keyof RedisConnectionsList
+ }
+ ) => ConfigProvider
+
+ /**
+ * Configure database limiter store
+ */
+ database: (
+ config: Omit & {
+ connectionName: string
+ }
+ ) => ConfigProvider
+
+ /**
+ * Configure memory limiter store
+ */
+ memory: (
+ config: Omit
+ ) => LimiterManagerStoreFactory
+} = {
+ redis: (config) => {
+ return configProvider.create(async (app) => {
+ const redis = await app.container.make('redis')
+ return (consumptionOptions) =>
+ new LimiterRedisStore(redis.connection(config.connectionName), {
+ ...config,
+ ...consumptionOptions,
+ })
+ })
+ },
+ database: (config) => {
+ return configProvider.create(async (app) => {
+ const db = await app.container.make('lucid.db')
+ return (consumptionOptions) =>
+ new LimiterDatabaseStore(db.connection(config.connectionName), {
+ ...config,
+ ...consumptionOptions,
+ })
+ })
+ },
+ memory: (config) => {
+ return (consumptionOptions) =>
+ new LimiterMemoryStore({
+ ...config,
+ ...consumptionOptions,
+ })
+ },
+}
diff --git a/src/stores/redis.ts b/src/stores/redis.ts
index db8fcea..acbc52f 100644
--- a/src/stores/redis.ts
+++ b/src/stores/redis.ts
@@ -8,8 +8,8 @@
*/
import string from '@adonisjs/core/helpers/string'
-import type { RedisConnection } from '@adonisjs/redis'
import { RateLimiterRedis } from 'rate-limiter-flexible'
+import type { RedisClusterConnection, RedisConnection } from '@adonisjs/redis'
import debug from '../debug.js'
import RateLimiterBridge from './bridge.js'
@@ -24,7 +24,7 @@ export default class LimiterRedisStore extends RateLimiterBridge {
return 'redis'
}
- constructor(client: RedisConnection, config: LimiterRedisStoreConfig) {
+ constructor(client: RedisConnection | RedisClusterConnection, config: LimiterRedisStoreConfig) {
debug('creating redis limiter store %O', config)
super(
new RateLimiterRedis({
diff --git a/tests/define_config.spec.ts b/tests/define_config.spec.ts
new file mode 100644
index 0000000..934b08f
--- /dev/null
+++ b/tests/define_config.spec.ts
@@ -0,0 +1,142 @@
+/*
+ * @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 { RedisService } from '@adonisjs/redis/types'
+import { ApplicationService } from '@adonisjs/core/types'
+import { AppFactory } from '@adonisjs/core/factories/app'
+
+import { Limiter } from '../src/limiter.js'
+import LimiterRedisStore from '../src/stores/redis.js'
+import LimiterMemoryStore from '../src/stores/memory.js'
+import { LimiterManager } from '../src/limiter_manager.js'
+import LimiterDatabaseStore from '../src/stores/database.js'
+import { defineConfig, stores } from '../src/define_config.js'
+import type { LimiterConsumptionOptions } from '../src/types.js'
+import { createDatabase, createRedis, createTables } from './helpers.js'
+
+test.group('Define config', () => {
+ test('define redis store', async ({ assert }) => {
+ const redis = createRedis() as unknown as RedisService
+ const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
+ await app.init()
+
+ app.container.singleton('redis', () => redis)
+ const redisProvider = stores.redis({
+ connectionName: 'main',
+ })
+
+ const storeFactory = await redisProvider.resolver(app)
+ const store = storeFactory({ duration: '1mins', requests: 5 })
+ assert.instanceOf(store, LimiterRedisStore)
+ assert.isNull(await store.get('ip_localhost'))
+ })
+
+ test('define database store', async ({ assert }) => {
+ const database = createDatabase()
+ await createTables(database)
+
+ const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
+ await app.init()
+
+ app.container.singleton('lucid.db', () => database)
+ const dbProvider = stores.database({
+ connectionName: process.env.DB as any,
+ dbName: 'limiter',
+ tableName: 'rate_limits',
+ })
+
+ const storeFactory = await dbProvider.resolver(app)
+ const store = storeFactory({ duration: '1mins', requests: 5 })
+ assert.instanceOf(store, LimiterDatabaseStore)
+ assert.isNull(await store.get('ip_localhost'))
+ })
+
+ test('define memory store', async ({ assert }) => {
+ const storeFactory = stores.memory({})
+ const store = storeFactory({ duration: '1mins', requests: 5 })
+ assert.instanceOf(store, LimiterMemoryStore)
+ assert.isNull(await store.get('ip_localhost'))
+ })
+
+ test('throw error when config is invalid', async ({ assert }) => {
+ const redis = createRedis() as unknown as RedisService
+ const database = createDatabase()
+ await createTables(database)
+
+ const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
+ await app.init()
+
+ app.container.singleton('redis', () => redis)
+ app.container.singleton('lucid.db', () => database)
+
+ assert.throws(
+ () =>
+ defineConfig({
+ // @ts-expect-error
+ default: 'redis',
+ stores: {},
+ }),
+ 'Missing "stores.redis" in limiter config. It is referenced by the "default" property'
+ )
+
+ assert.throws(
+ // @ts-expect-error
+ () => defineConfig({}),
+ 'Missing "stores" property in limiter config'
+ )
+
+ assert.throws(
+ // @ts-expect-error
+ () => defineConfig({ stores: {} }),
+ 'Missing "default" store in limiter config'
+ )
+ })
+
+ test('create manager from define config output', async ({ assert, expectTypeOf }) => {
+ const redis = createRedis() as unknown as RedisService
+ const database = createDatabase()
+ await createTables(database)
+
+ const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService
+ await app.init()
+
+ app.container.singleton('redis', () => redis)
+ app.container.singleton('lucid.db', () => database)
+
+ const config = defineConfig({
+ default: 'redis',
+ stores: {
+ redis: stores.redis({
+ connectionName: 'main',
+ }),
+ db: stores.database({
+ connectionName: process.env.DB as any,
+ dbName: 'limiter',
+ tableName: 'rate_limits',
+ }),
+ memory: stores.memory({}),
+ },
+ })
+
+ const limiter = new LimiterManager(await config.resolver(app))
+ expectTypeOf(limiter.use).parameters.toMatchTypeOf<
+ ['redis' | 'db' | 'memory' | undefined, LimiterConsumptionOptions]
+ >()
+ expectTypeOf(limiter.use).returns.toMatchTypeOf()
+
+ assert.isNull(
+ await limiter.use('redis', { duration: '1 min', requests: 5 }).get('ip_localhost')
+ )
+ assert.isNull(await limiter.use('db', { duration: '1 min', requests: 5 }).get('ip_localhost'))
+ assert.isNull(
+ await limiter.use('memory', { duration: '1 min', requests: 5 }).get('ip_localhost')
+ )
+ })
+})