Skip to content

Commit

Permalink
feat: add define config helper
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage committed Feb 5, 2024
1 parent d7650b0 commit 613ae20
Show file tree
Hide file tree
Showing 5 changed files with 317 additions and 5 deletions.
3 changes: 2 additions & 1 deletion bin/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/*
Expand All @@ -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,
})

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
168 changes: 168 additions & 0 deletions src/define_config.ts
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,
})
},
}
4 changes: 2 additions & 2 deletions src/stores/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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({
Expand Down
142 changes: 142 additions & 0 deletions tests/define_config.spec.ts
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')
)
})
})

0 comments on commit 613ae20

Please sign in to comment.