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