-
-
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
d7650b0
commit 613ae20
Showing
5 changed files
with
317 additions
and
5 deletions.
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
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,168 @@ | ||
/* | ||
* @adonisjs/limiter | ||
* | ||
* (c) AdonisJS | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
/// <reference types="@adonisjs/lucid/database_provider" /> | ||
/// <reference types="@adonisjs/redis/redis_provider" /> | ||
|
||
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<LimiterManagerStoreFactory> | ||
>, | ||
>(config: { | ||
default: keyof KnownStores | ||
stores: KnownStores | ||
}): ConfigProvider<{ | ||
default: keyof KnownStores | ||
stores: { | ||
[K in keyof KnownStores]: KnownStores[K] extends ConfigProvider<infer A> ? 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<LimiterManagerStoreFactory> | ||
> | ||
|
||
/** | ||
* 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<infer A> | ||
? A | ||
: KnownStores[K] | ||
}, | ||
} | ||
}) | ||
} | ||
|
||
/** | ||
* Config helpers to instantiate limiter stores inside | ||
* an AdonisJS application | ||
*/ | ||
export const stores: { | ||
/** | ||
* Configure redis limiter store | ||
*/ | ||
redis: ( | ||
config: Omit<LimiterRedisStoreConfig, keyof LimiterConsumptionOptions> & { | ||
connectionName: keyof RedisConnectionsList | ||
} | ||
) => ConfigProvider<LimiterManagerStoreFactory> | ||
|
||
/** | ||
* Configure database limiter store | ||
*/ | ||
database: ( | ||
config: Omit<LimiterDatabaseStoreConfig, keyof LimiterConsumptionOptions> & { | ||
connectionName: string | ||
} | ||
) => ConfigProvider<LimiterManagerStoreFactory> | ||
|
||
/** | ||
* Configure memory limiter store | ||
*/ | ||
memory: ( | ||
config: Omit<LimiterMemoryStoreConfig, keyof LimiterConsumptionOptions> | ||
) => 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, | ||
}) | ||
}, | ||
} |
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,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<Limiter>() | ||
|
||
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') | ||
) | ||
}) | ||
}) |