├── .editorconfig ├── .env ├── .env.example ├── .github ├── labels.json ├── lock.yml ├── stale.yml └── workflows │ ├── checks.yml │ ├── labels.yml │ └── release.yml ├── .gitignore ├── .husky └── commit-msg ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── configure.ts ├── docker-compose.yml ├── eslint.config.js ├── index.ts ├── package.json ├── providers └── limiter_provider.ts ├── services └── main.ts ├── src ├── debug.ts ├── define_config.ts ├── errors.ts ├── http_limiter.ts ├── limiter.ts ├── limiter_manager.ts ├── response.ts ├── stores │ ├── bridge.ts │ ├── database.ts │ ├── memory.ts │ └── redis.ts └── types.ts ├── stubs ├── config │ └── limiter.stub ├── main.ts ├── make │ └── migration │ │ └── rate_limits.stub └── start │ └── limiter.stub ├── tests ├── configure.spec.ts ├── define_config.spec.ts ├── helpers.ts ├── http_limiter.spec.ts ├── limiter.spec.ts ├── limiter_manager.spec.ts ├── limiter_provider.spec.ts ├── stores │ ├── database.spec.ts │ ├── memory.spec.ts │ └── redis.spec.ts ├── throttle_exception.spec.ts └── throttle_middleware.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | MYSQL_HOST=localhost 2 | MYSQL_PASSWORD=secret 3 | MYSQL_DATABASE=limiter 4 | MYSQL_PORT=3306 5 | MYSQL_USER=virk 6 | 7 | PG_HOST=localhost 8 | PG_PASSWORD=secret 9 | PG_DATABASE=limiter 10 | PG_PORT=5432 11 | PG_USER=virk 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MYSQL_HOST=localhost 2 | MYSQL_PASSWORD=secret 3 | MYSQL_DATABASE=limiter 4 | MYSQL_PORT=3306 5 | MYSQL_USER=virk 6 | 7 | PG_HOST=localhost 8 | PG_PASSWORD=secret 9 | PG_DATABASE=limiter 10 | PG_PORT=5432 11 | PG_USER=virk 12 | -------------------------------------------------------------------------------- /.github/labels.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Priority: Critical", 4 | "color": "ea0056", 5 | "description": "The issue needs urgent attention", 6 | "aliases": [] 7 | }, 8 | { 9 | "name": "Priority: High", 10 | "color": "5666ed", 11 | "description": "Look into this issue before picking up any new work", 12 | "aliases": [] 13 | }, 14 | { 15 | "name": "Priority: Medium", 16 | "color": "f4ff61", 17 | "description": "Try to fix the issue for the next patch/minor release", 18 | "aliases": [] 19 | }, 20 | { 21 | "name": "Priority: Low", 22 | "color": "87dfd6", 23 | "description": "Something worth considering, but not a top priority for the team", 24 | "aliases": [] 25 | }, 26 | { 27 | "name": "Semver: Alpha", 28 | "color": "008480", 29 | "description": "Will make it's way to the next alpha version of the package", 30 | "aliases": [] 31 | }, 32 | { 33 | "name": "Semver: Major", 34 | "color": "ea0056", 35 | "description": "Has breaking changes", 36 | "aliases": [] 37 | }, 38 | { 39 | "name": "Semver: Minor", 40 | "color": "fbe555", 41 | "description": "Mainly new features and improvements", 42 | "aliases": [] 43 | }, 44 | { 45 | "name": "Semver: Next", 46 | "color": "5666ed", 47 | "description": "Will make it's way to the bleeding edge version of the package", 48 | "aliases": [] 49 | }, 50 | { 51 | "name": "Semver: Patch", 52 | "color": "87dfd6", 53 | "description": "A bug fix", 54 | "aliases": [] 55 | }, 56 | { 57 | "name": "Status: Abandoned", 58 | "color": "ffffff", 59 | "description": "Dropped and not into consideration", 60 | "aliases": ["wontfix"] 61 | }, 62 | { 63 | "name": "Status: Accepted", 64 | "color": "e5fbf2", 65 | "description": "The proposal or the feature has been accepted for the future versions", 66 | "aliases": [] 67 | }, 68 | { 69 | "name": "Status: Blocked", 70 | "color": "ea0056", 71 | "description": "The work on the issue or the PR is blocked. Check comments for reasoning", 72 | "aliases": [] 73 | }, 74 | { 75 | "name": "Status: Completed", 76 | "color": "008672", 77 | "description": "The work has been completed, but not released yet", 78 | "aliases": [] 79 | }, 80 | { 81 | "name": "Status: In Progress", 82 | "color": "73dbc4", 83 | "description": "Still banging the keyboard", 84 | "aliases": ["in progress"] 85 | }, 86 | { 87 | "name": "Status: On Hold", 88 | "color": "f4ff61", 89 | "description": "The work was started earlier, but is on hold now. Check comments for reasoning", 90 | "aliases": ["On Hold"] 91 | }, 92 | { 93 | "name": "Status: Review Needed", 94 | "color": "fbe555", 95 | "description": "Review from the core team is required before moving forward", 96 | "aliases": [] 97 | }, 98 | { 99 | "name": "Status: Awaiting More Information", 100 | "color": "89f8ce", 101 | "description": "Waiting on the issue reporter or PR author to provide more information", 102 | "aliases": [] 103 | }, 104 | { 105 | "name": "Status: Need Contributors", 106 | "color": "7057ff", 107 | "description": "Looking for contributors to help us move forward with this issue or PR", 108 | "aliases": [] 109 | }, 110 | { 111 | "name": "Type: Bug", 112 | "color": "ea0056", 113 | "description": "The issue has indentified a bug", 114 | "aliases": ["bug"] 115 | }, 116 | { 117 | "name": "Type: Security", 118 | "color": "ea0056", 119 | "description": "Spotted security vulnerability and is a top priority for the core team", 120 | "aliases": [] 121 | }, 122 | { 123 | "name": "Type: Duplicate", 124 | "color": "00837e", 125 | "description": "Already answered or fixed previously", 126 | "aliases": ["duplicate"] 127 | }, 128 | { 129 | "name": "Type: Enhancement", 130 | "color": "89f8ce", 131 | "description": "Improving an existing feature", 132 | "aliases": ["enhancement"] 133 | }, 134 | { 135 | "name": "Type: Feature Request", 136 | "color": "483add", 137 | "description": "Request to add a new feature to the package", 138 | "aliases": [] 139 | }, 140 | { 141 | "name": "Type: Invalid", 142 | "color": "dbdbdb", 143 | "description": "Doesn't really belong here. Maybe use discussion threads?", 144 | "aliases": ["invalid"] 145 | }, 146 | { 147 | "name": "Type: Question", 148 | "color": "eceafc", 149 | "description": "Needs clarification", 150 | "aliases": ["help wanted", "question"] 151 | }, 152 | { 153 | "name": "Type: Documentation Change", 154 | "color": "7057ff", 155 | "description": "Documentation needs some improvements", 156 | "aliases": ["documentation"] 157 | }, 158 | { 159 | "name": "Type: Dependencies Update", 160 | "color": "00837e", 161 | "description": "Bump dependencies", 162 | "aliases": ["dependencies"] 163 | }, 164 | { 165 | "name": "Good First Issue", 166 | "color": "008480", 167 | "description": "Want to contribute? Just filter by this label", 168 | "aliases": ["good first issue"] 169 | } 170 | ] 171 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 60 5 | 6 | # Skip issues and pull requests created before a given timestamp. Timestamp must 7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 8 | skipCreatedBefore: false 9 | 10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 11 | exemptLabels: ['Type: Security'] 12 | 13 | # Label to add before locking, such as `outdated`. Set to `false` to disable 14 | lockLabel: false 15 | 16 | # Comment to post before locking. Set to `false` to disable 17 | lockComment: > 18 | This thread has been automatically locked since there has not been 19 | any recent activity after it was closed. Please open a new issue for 20 | related bugs. 21 | 22 | # Assign `resolved` as the reason for locking. Set to `false` to disable 23 | setLockReason: false 24 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - 'Type: Security' 10 | 11 | # Label to use when marking an issue as stale 12 | staleLabel: 'Status: Abandoned' 13 | 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | lint: 9 | uses: adonisjs/.github/.github/workflows/lint.yml@main 10 | 11 | typecheck: 12 | uses: adonisjs/.github/.github/workflows/typecheck.yml@main 13 | 14 | test-postgres: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | node-version: [20, 22] 20 | postgres-version: [11] 21 | services: 22 | postgres: 23 | image: postgres:${{ matrix.postgres-version }} 24 | env: 25 | POSTGRES_DB: limiter 26 | POSTGRES_USER: virk 27 | POSTGRES_PASSWORD: secret 28 | ports: 29 | - 5432:5432 30 | redis: 31 | image: redis 32 | options: >- 33 | --health-cmd "redis-cli ping" 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | ports: 38 | - 6379:6379 39 | steps: 40 | - uses: actions/checkout@v3 41 | - uses: actions/setup-node@v3 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | - name: Install Playwright Browsers 45 | run: npx playwright install --with-deps 46 | - name: Install 47 | run: npm install 48 | - name: Run Postgres Tests 49 | run: npm run test:pg 50 | 51 | test-mysql: 52 | runs-on: ubuntu-latest 53 | strategy: 54 | fail-fast: false 55 | matrix: 56 | mysql: [{ version: '8.0', command: 'mysql' }] 57 | node-version: [22] 58 | services: 59 | mysql: 60 | image: mysql:${{ matrix.mysql.version }} 61 | env: 62 | MYSQL_DATABASE: limiter 63 | MYSQL_USER: virk 64 | MYSQL_PASSWORD: secret 65 | MYSQL_ROOT_PASSWORD: secret 66 | MYSQL_PORT: 3306 67 | ports: 68 | - '3306:3306' 69 | redis: 70 | image: redis 71 | options: >- 72 | --health-cmd "redis-cli ping" 73 | --health-interval 10s 74 | --health-timeout 5s 75 | --health-retries 5 76 | ports: 77 | - 6379:6379 78 | steps: 79 | - uses: actions/checkout@v3 80 | - uses: actions/setup-node@v3 81 | with: 82 | node-version: ${{ matrix.node-version }} 83 | - name: Install 84 | run: npm install 85 | - name: Install Playwright Browsers 86 | run: npx playwright install --with-deps 87 | - name: Run Mysql Tests 88 | run: npm run test:${{ matrix.mysql.command }} 89 | test-sqlite: 90 | runs-on: ubuntu-latest 91 | strategy: 92 | matrix: 93 | node-version: [20, 22] 94 | services: 95 | redis: 96 | image: redis 97 | options: >- 98 | --health-cmd "redis-cli ping" 99 | --health-interval 10s 100 | --health-timeout 5s 101 | --health-retries 5 102 | ports: 103 | - 6379:6379 104 | steps: 105 | - uses: actions/checkout@v3 106 | - uses: actions/setup-node@v3 107 | with: 108 | node-version: ${{ matrix.node-version }} 109 | - name: Install Playwright Browsers 110 | run: npx playwright install --with-deps 111 | - name: Install 112 | run: npm install 113 | - name: Run SQLite Tests 114 | run: npm run test:sqlite 115 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: git config 20 | run: | 21 | git config user.name "${GITHUB_ACTOR}" 22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 23 | - name: Init npm config 24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: npm install 28 | - run: npm run release -- --ci 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | package-lock.json 15 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.html 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2022 Harminder Virk, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @adonisjs/limiter 2 | 3 |
4 | 5 | [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] 6 | 7 | ## Introduction 8 | 9 | A first-party package to implement rate limits in your AdonisJS application. The package is built on top of [node-rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible) with extensive changes to the API. 10 | 11 | ## Official Documentation 12 | 13 | The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/rate-limiter) 14 | 15 | ## Contributing 16 | 17 | One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. 18 | 19 | We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. 20 | 21 | ## Code of Conduct 22 | 23 | In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). 24 | 25 | ## License 26 | 27 | AdonisJS Limiter is open-sourced software licensed under the [MIT license](LICENSE.md). 28 | 29 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/limiter/checks.yml?style=for-the-badge 30 | [gh-workflow-url]: https://github.com/adonisjs/limiter/actions/workflows/checks.yml 'Github action' 31 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 32 | [typescript-url]: "typescript" 33 | [npm-image]: https://img.shields.io/npm/v/@adonisjs/limiter.svg?style=for-the-badge&logo=npm 34 | [npm-url]: https://npmjs.org/package/@adonisjs/limiter 'npm' 35 | [license-image]: https://img.shields.io/npm/l/@adonisjs/limiter?color=blueviolet&style=for-the-badge 36 | [license-url]: LICENSE.md 'license' 37 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { assert } from '@japa/assert' 11 | import { fileSystem } from '@japa/file-system' 12 | import { expectTypeOf } from '@japa/expect-type' 13 | import { processCLIArgs, configure, run } from '@japa/runner' 14 | 15 | /* 16 | |-------------------------------------------------------------------------- 17 | | Configure tests 18 | |-------------------------------------------------------------------------- 19 | | 20 | | The configure method accepts the configuration to configure the Japa 21 | | tests runner. 22 | | 23 | | The first method call "processCliArgs" process the command line arguments 24 | | and turns them into a config object. Using this method is not mandatory. 25 | | 26 | | Please consult japa.dev/runner-config for the config docs. 27 | */ 28 | processCLIArgs(process.argv.slice(2)) 29 | configure({ 30 | files: ['tests/**/*.spec.ts'], 31 | plugins: [assert(), fileSystem(), expectTypeOf()], 32 | forceExit: true, 33 | }) 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Run tests 38 | |-------------------------------------------------------------------------- 39 | | 40 | | The following "run" method is required to execute all the tests. 41 | | 42 | */ 43 | run() 44 | -------------------------------------------------------------------------------- /configure.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@adonisjs/core/helpers/string' 11 | import type Configure from '@adonisjs/core/commands/configure' 12 | import { stubsRoot } from './stubs/main.js' 13 | 14 | /** 15 | * Available stores for selection 16 | */ 17 | const KNOWN_STORES = ['database', 'redis'] 18 | 19 | /** 20 | * Configures the limiter package 21 | */ 22 | export async function configure(command: Configure) { 23 | /** 24 | * Read store from the "--store" CLI flag 25 | */ 26 | let selectedStore: string | undefined = command.parsedFlags.store 27 | 28 | /** 29 | * Display prompts when no store have been selected 30 | * via the CLI flag 31 | */ 32 | if (!selectedStore) { 33 | selectedStore = await command.prompt.choice( 34 | 'Select the storage layer you want to use', 35 | KNOWN_STORES, 36 | { 37 | validate(value) { 38 | return !value ? 'Please select a store' : true 39 | }, 40 | } 41 | ) 42 | } 43 | 44 | /** 45 | * Ensure the select store is valid 46 | */ 47 | if (!KNOWN_STORES.includes(selectedStore!)) { 48 | command.exitCode = 1 49 | command.logger.logError( 50 | `Invalid limiter store "${selectedStore}". Supported stores are: ${string.sentence( 51 | KNOWN_STORES 52 | )}` 53 | ) 54 | return 55 | } 56 | 57 | const codemods = await command.createCodemods() 58 | 59 | /** 60 | * Publish config file 61 | */ 62 | await codemods.makeUsingStub(stubsRoot, 'config/limiter.stub', { 63 | store: selectedStore, 64 | }) 65 | 66 | /** 67 | * Publish start/limiter file 68 | */ 69 | await codemods.makeUsingStub(stubsRoot, 'start/limiter.stub', {}) 70 | 71 | /** 72 | * Publish provider 73 | */ 74 | await codemods.updateRcFile((rcFile) => { 75 | rcFile.addProvider('@adonisjs/limiter/limiter_provider') 76 | }) 77 | 78 | /** 79 | * Publish migration when using database to store 80 | * rate limits 81 | */ 82 | if (selectedStore === 'database') { 83 | await codemods.makeUsingStub(stubsRoot, 'make/migration/rate_limits.stub', { 84 | entity: command.app.generators.createEntity('rate_limits'), 85 | migration: { 86 | folder: 'database/migrations', 87 | fileName: `${new Date().getTime()}_create_rate_limits_table.ts`, 88 | }, 89 | }) 90 | } 91 | 92 | /** 93 | * Define env variables for the selected store 94 | */ 95 | await codemods.defineEnvVariables({ 96 | LIMITER_STORE: selectedStore!, 97 | }) 98 | 99 | /** 100 | * Define env variables validation for the selected store 101 | */ 102 | await codemods.defineEnvValidations({ 103 | leadingComment: 'Variables for configuring the limiter package', 104 | variables: { 105 | LIMITER_STORE: `Env.schema.enum(['${selectedStore}', 'memory'] as const)`, 106 | }, 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | mysql: 5 | platform: linux/x86_64 6 | image: mysql:8.0 7 | command: --default-authentication-plugin=mysql_native_password --sync_binlog=0 --innodb_doublewrite=OFF --innodb-flush-log-at-trx-commit=0 --innodb-flush-method=nosync 8 | container_name: mysql 9 | env_file: ./.env 10 | environment: 11 | - MYSQL_USER=$MYSQL_USER 12 | - MYSQL_PASSWORD=$MYSQL_PASSWORD 13 | - MYSQL_DATABASE=$MYSQL_DATABASE 14 | - MYSQL_ROOT_PASSWORD=$MYSQL_PASSWORD 15 | ports: 16 | - $MYSQL_PORT:3306 17 | expose: 18 | - $MYSQL_PORT 19 | 20 | pg: 21 | image: postgres:11 22 | container_name: pg 23 | env_file: ./.env 24 | environment: 25 | - POSTGRES_DB=$PG_DATABASE 26 | - POSTGRES_USER=$PG_USER 27 | - POSTGRES_PASSWORD=$PG_PASSWORD 28 | ports: 29 | - $PG_PORT:5432 30 | expose: 31 | - $PG_PORT 32 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg() 3 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export * as errors from './src/errors.js' 11 | export { configure } from './configure.js' 12 | export { Limiter } from './src/limiter.js' 13 | export { stubsRoot } from './stubs/main.js' 14 | export { LimiterResponse } from './src/response.js' 15 | export { HttpLimiter } from './src/http_limiter.js' 16 | export { LimiterManager } from './src/limiter_manager.js' 17 | export { defineConfig, stores } from './src/define_config.js' 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/limiter", 3 | "description": "Rate limiting package for AdonisJS framework", 4 | "version": "2.4.0", 5 | "type": "module", 6 | "files": [ 7 | "build", 8 | "!build/bin", 9 | "!build/tests" 10 | ], 11 | "exports": { 12 | ".": "./build/index.js", 13 | "./limiter_provider": "./build/providers/limiter_provider.js", 14 | "./services/main": "./build/services/main.js", 15 | "./stores/*": "./build/src/stores/*.js", 16 | "./types": "./build/src/types.js" 17 | }, 18 | "scripts": { 19 | "pretest": "npm run lint && del-cli coverage", 20 | "test": "npm run test:pg && npm run test:mysql && npm run test:sqlite && mkdir coverage/tmp && cp -r coverage/*/tmp/. coverage/tmp && c8 report", 21 | "test:pg": "cross-env DB=pg c8 --reporter=json --report-dir=coverage/pg npm run quick:test", 22 | "test:mysql": "cross-env DB=mysql c8 --reporter=json --report-dir=coverage/mysql npm run quick:test", 23 | "test:sqlite": "cross-env DB=sqlite c8 --reporter=json --report-dir=coverage/sqlite npm run quick:test", 24 | "clean": "del-cli build", 25 | "typecheck": "tsc --noEmit", 26 | "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", 27 | "precompile": "npm run lint && npm run clean", 28 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", 29 | "postcompile": "npm run copy:templates", 30 | "build": "npm run compile", 31 | "prepublishOnly": "npm run build", 32 | "lint": "eslint", 33 | "format": "prettier --write .", 34 | "release": "release-it", 35 | "version": "npm run build", 36 | "quick:test": "cross-env NODE_DEBUG=adonisjs:limiter node --import=ts-node-maintained/register/esm --enable-source-maps bin/test.ts" 37 | }, 38 | "devDependencies": { 39 | "@adonisjs/assembler": "^7.8.2", 40 | "@adonisjs/core": "^6.17.2", 41 | "@adonisjs/eslint-config": "^2.0.0", 42 | "@adonisjs/i18n": "^2.2.0", 43 | "@adonisjs/lucid": "^21.6.1", 44 | "@adonisjs/prettier-config": "^1.4.4", 45 | "@adonisjs/redis": "^9.2.0", 46 | "@adonisjs/tsconfig": "^1.4.0", 47 | "@japa/assert": "^4.0.1", 48 | "@japa/expect-type": "^2.0.3", 49 | "@japa/file-system": "^2.3.2", 50 | "@japa/runner": "^4.2.0", 51 | "@libsql/sqlite3": "^0.3.1", 52 | "@release-it/conventional-changelog": "^10.0.0", 53 | "@swc/core": "1.10.7", 54 | "@types/node": "^22.14.1", 55 | "@types/sinon": "^17.0.4", 56 | "better-sqlite3": "^11.9.1", 57 | "c8": "^10.1.3", 58 | "copyfiles": "^2.4.1", 59 | "cross-env": "^7.0.3", 60 | "del-cli": "^6.0.0", 61 | "dotenv": "^16.5.0", 62 | "eslint": "^9.24.0", 63 | "luxon": "^3.6.1", 64 | "mysql2": "^3.14.0", 65 | "pg": "^8.14.1", 66 | "prettier": "^3.5.3", 67 | "release-it": "^18.1.2", 68 | "sinon": "^20.0.0", 69 | "timekeeper": "^2.3.1", 70 | "ts-node-maintained": "^10.9.5", 71 | "tsup": "^8.4.0", 72 | "typescript": "^5.8.3" 73 | }, 74 | "dependencies": { 75 | "rate-limiter-flexible": "^7.0.0" 76 | }, 77 | "peerDependencies": { 78 | "@adonisjs/core": "^6.12.1", 79 | "@adonisjs/lucid": "^20.1.0 || ^21.0.0", 80 | "@adonisjs/redis": "^8.0.1 || ^9.0.0" 81 | }, 82 | "peerDependenciesMeta": { 83 | "@adonisjs/lucid": { 84 | "optional": true 85 | }, 86 | "@adonisjs/redis": { 87 | "optional": true 88 | } 89 | }, 90 | "author": "virk,adonisjs", 91 | "license": "MIT", 92 | "homepage": "https://github.com/adonisjs/limiter#readme", 93 | "repository": { 94 | "type": "git", 95 | "url": "git+https://github.com/adonisjs/limiter.git" 96 | }, 97 | "bugs": { 98 | "url": "https://github.com/adonisjs/limiter/issues" 99 | }, 100 | "keywords": [ 101 | "adonis", 102 | "rate-limiter" 103 | ], 104 | "publishConfig": { 105 | "access": "public", 106 | "provenance": true 107 | }, 108 | "tsup": { 109 | "entry": [ 110 | "./index.ts", 111 | "./providers/limiter_provider.ts", 112 | "./services/main.ts", 113 | "./src/stores/*.ts", 114 | "./src/types.ts" 115 | ], 116 | "outDir": "./build", 117 | "clean": true, 118 | "format": "esm", 119 | "dts": false, 120 | "sourcemap": false, 121 | "target": "esnext" 122 | }, 123 | "release-it": { 124 | "git": { 125 | "requireCleanWorkingDir": true, 126 | "requireUpstream": true, 127 | "commitMessage": "chore(release): ${version}", 128 | "tagAnnotation": "v${version}", 129 | "push": true, 130 | "tagName": "v${version}" 131 | }, 132 | "github": { 133 | "release": true 134 | }, 135 | "npm": { 136 | "publish": true, 137 | "skipChecks": true 138 | }, 139 | "plugins": { 140 | "@release-it/conventional-changelog": { 141 | "preset": { 142 | "name": "angular" 143 | } 144 | } 145 | } 146 | }, 147 | "c8": { 148 | "reporter": [ 149 | "text", 150 | "html" 151 | ], 152 | "exclude": [ 153 | "tests/**" 154 | ] 155 | }, 156 | "prettier": "@adonisjs/prettier-config" 157 | } 158 | -------------------------------------------------------------------------------- /providers/limiter_provider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { configProvider } from '@adonisjs/core' 11 | import { ApplicationService } from '@adonisjs/core/types' 12 | import { RuntimeException } from '@adonisjs/core/exceptions' 13 | 14 | import { LimiterManager } from '../index.js' 15 | import type { LimiterService } from '../src/types.js' 16 | 17 | declare module '@adonisjs/core/types' { 18 | export interface ContainerBindings { 19 | 'limiter.manager': LimiterService 20 | } 21 | } 22 | 23 | export default class LimiterProvider { 24 | constructor(protected app: ApplicationService) {} 25 | 26 | register() { 27 | this.app.container.singleton('limiter.manager', async () => { 28 | const limiterConfigProvider = this.app.config.get('limiter', {}) 29 | 30 | /** 31 | * Resolve config from the provider 32 | */ 33 | const config = await configProvider.resolve(this.app, limiterConfigProvider) 34 | if (!config) { 35 | throw new RuntimeException( 36 | 'Invalid "config/limiter.ts" file. Make sure you are using the "defineConfig" method' 37 | ) 38 | } 39 | 40 | return new LimiterManager(config) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /services/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import app from '@adonisjs/core/services/app' 11 | import { LimiterService } from '../src/types.js' 12 | 13 | let limiter: LimiterService 14 | 15 | /** 16 | * Returns a singleton instance of the LimiterManager class from the 17 | * container. 18 | */ 19 | await app.booted(async () => { 20 | limiter = await app.container.make('limiter.manager') 21 | }) 22 | 23 | export { limiter as default } 24 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { debuglog } from 'node:util' 11 | 12 | export default debuglog('adonisjs:limiter') 13 | -------------------------------------------------------------------------------- /src/define_config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | /// 12 | 13 | import { configProvider } from '@adonisjs/core' 14 | import type { ConfigProvider } from '@adonisjs/core/types' 15 | import type { RedisConnections } from '@adonisjs/redis/types' 16 | import { InvalidArgumentsException, RuntimeException } from '@adonisjs/core/exceptions' 17 | 18 | import debug from './debug.js' 19 | import LimiterMemoryStore from './stores/memory.js' 20 | import type { 21 | LimiterRedisStoreConfig, 22 | LimiterMemoryStoreConfig, 23 | LimiterManagerStoreFactory, 24 | LimiterDatabaseStoreConfig, 25 | LimiterConsumptionOptions, 26 | } from './types.js' 27 | 28 | /** 29 | * Helper to define limiter config. This function exports a 30 | * config provider and hence you cannot access raw config 31 | * directly. 32 | * 33 | * Therefore use the "limiterManager.config" property to access 34 | * raw config. 35 | */ 36 | export function defineConfig< 37 | KnownStores extends Record< 38 | string, 39 | LimiterManagerStoreFactory | ConfigProvider 40 | >, 41 | >(config: { 42 | default: keyof KnownStores 43 | stores: KnownStores 44 | }): ConfigProvider<{ 45 | default: keyof KnownStores 46 | stores: { 47 | [K in keyof KnownStores]: KnownStores[K] extends ConfigProvider ? A : KnownStores[K] 48 | } 49 | }> { 50 | /** 51 | * Limiter stores should always be provided 52 | */ 53 | if (!config.stores) { 54 | throw new InvalidArgumentsException('Missing "stores" property in limiter config') 55 | } 56 | 57 | /** 58 | * Default store should always be provided 59 | */ 60 | if (!config.default) { 61 | throw new InvalidArgumentsException(`Missing "default" store in limiter config`) 62 | } 63 | 64 | /** 65 | * Default store should be configured within the stores 66 | * object 67 | */ 68 | if (!config.stores[config.default]) { 69 | throw new InvalidArgumentsException( 70 | `Missing "stores.${String( 71 | config.default 72 | )}" in limiter config. It is referenced by the "default" property` 73 | ) 74 | } 75 | 76 | return configProvider.create(async (app) => { 77 | debug('resolving limiter config') 78 | 79 | const storesList = Object.keys(config.stores) 80 | const stores = {} as Record< 81 | string, 82 | LimiterManagerStoreFactory | ConfigProvider 83 | > 84 | 85 | /** 86 | * Looping for stores collection and invoking 87 | * config providers to resolve stores in use 88 | */ 89 | for (let storeName of storesList) { 90 | const store = config.stores[storeName] 91 | if (typeof store === 'function') { 92 | stores[storeName] = store 93 | } else { 94 | stores[storeName] = await store.resolver(app) 95 | } 96 | } 97 | 98 | return { 99 | default: config.default, 100 | stores: stores as { 101 | [K in keyof KnownStores]: KnownStores[K] extends ConfigProvider 102 | ? A 103 | : KnownStores[K] 104 | }, 105 | } 106 | }) 107 | } 108 | 109 | /** 110 | * Config helpers to instantiate limiter stores inside 111 | * an AdonisJS application 112 | */ 113 | export const stores: { 114 | /** 115 | * Configure redis limiter store 116 | */ 117 | redis: ( 118 | config: Omit & { 119 | connectionName?: keyof RedisConnections 120 | } 121 | ) => ConfigProvider 122 | 123 | /** 124 | * Configure database limiter store 125 | */ 126 | database: ( 127 | config: Omit & { 128 | connectionName?: string 129 | } 130 | ) => ConfigProvider 131 | 132 | /** 133 | * Configure memory limiter store 134 | */ 135 | memory: ( 136 | config: Omit 137 | ) => LimiterManagerStoreFactory 138 | } = { 139 | redis: (config) => { 140 | return configProvider.create(async (app) => { 141 | const redis = await app.container.make('redis') 142 | const { default: LimiterRedisStore } = await import('./stores/redis.js') 143 | return (consumptionOptions) => 144 | new LimiterRedisStore(redis.connection(config.connectionName), { 145 | ...config, 146 | ...consumptionOptions, 147 | }) 148 | }) 149 | }, 150 | database: (config) => { 151 | return configProvider.create(async (app) => { 152 | const db = await app.container.make('lucid.db') 153 | const { default: LimiterDatabaseStore } = await import('./stores/database.js') 154 | 155 | config.connectionName = config.connectionName || db.primaryConnectionName 156 | const connection = db.manager.get(config.connectionName) 157 | 158 | /** 159 | * Throw error when mentioned connection is not specified 160 | * in the database file 161 | */ 162 | if (!connection) { 163 | throw new RuntimeException( 164 | `Invalid connection name "${config.connectionName}" referenced by "config/limiter.ts" file. First register the connection inside "config/database.ts" file` 165 | ) 166 | } 167 | 168 | /** 169 | * Infer database name from the connection config 170 | */ 171 | if ( 172 | !config.dbName && 173 | connection.config.connection && 174 | typeof connection.config.connection !== 'string' && 175 | 'database' in connection.config.connection 176 | ) { 177 | config.dbName = connection.config.connection.database 178 | } 179 | 180 | return (consumptionOptions) => 181 | new LimiterDatabaseStore(db.connection(config.connectionName), { 182 | ...config, 183 | ...consumptionOptions, 184 | }) 185 | }) 186 | }, 187 | memory: (config) => { 188 | return (consumptionOptions) => 189 | new LimiterMemoryStore({ 190 | ...config, 191 | ...consumptionOptions, 192 | }) 193 | }, 194 | } 195 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { I18n } from '@adonisjs/i18n' 11 | import { Exception } from '@adonisjs/core/exceptions' 12 | import type { HttpContext } from '@adonisjs/core/http' 13 | 14 | import type { LimiterResponse } from './response.js' 15 | 16 | /** 17 | * Throttle exception is raised when the user has exceeded 18 | * the number of requests allowed during a given duration 19 | */ 20 | export class ThrottleException extends Exception { 21 | message = 'Too many requests' 22 | status = 429 23 | code = 'E_TOO_MANY_REQUESTS' 24 | 25 | /** 26 | * Error identifier to lookup translation message 27 | */ 28 | identifier = 'errors.E_TOO_MANY_REQUESTS' 29 | 30 | /** 31 | * The response headers to set when converting exception 32 | * to response 33 | */ 34 | headers?: { [name: string]: any } 35 | 36 | /** 37 | * Translation identifier to use for creating throttle 38 | * response. 39 | */ 40 | translation?: { 41 | identifier: string 42 | data?: Record 43 | } 44 | 45 | constructor( 46 | public response: LimiterResponse, 47 | options?: ErrorOptions & { 48 | code?: string 49 | status?: number 50 | } 51 | ) { 52 | super('Too many requests', options) 53 | } 54 | 55 | /** 56 | * Returns the default headers for the response 57 | */ 58 | getDefaultHeaders(): { [K: string]: any } { 59 | return { 60 | 'X-RateLimit-Limit': this.response.limit, 61 | 'X-RateLimit-Remaining': this.response.remaining, 62 | 'Retry-After': this.response.availableIn, 63 | 'X-RateLimit-Reset': new Date(Date.now() + this.response.availableIn * 1000).toISOString(), 64 | } 65 | } 66 | 67 | /** 68 | * Returns the message to be sent in the HTTP response. 69 | * Feel free to override this method and return a custom 70 | * response. 71 | */ 72 | getResponseMessage(ctx: HttpContext) { 73 | /** 74 | * Use translation when using i18n package 75 | */ 76 | if ('i18n' in ctx) { 77 | /** 78 | * Give preference to response translation and fallback to static 79 | * identifier. 80 | */ 81 | const identifier = this.translation?.identifier || this.identifier 82 | const data = this.translation?.data || {} 83 | return (ctx.i18n as I18n).t(identifier, data, this.message) 84 | } 85 | 86 | return this.message 87 | } 88 | 89 | /** 90 | * Update the default error message 91 | */ 92 | setMessage(message: string): this { 93 | this.message = message 94 | return this 95 | } 96 | 97 | /** 98 | * Update the default error status code 99 | */ 100 | setStatus(status: number): this { 101 | this.status = status 102 | return this 103 | } 104 | 105 | /** 106 | * Define custom response headers. Existing headers will 107 | * be removed 108 | */ 109 | setHeaders(headers: { [name: string]: any }): this { 110 | this.headers = headers 111 | return this 112 | } 113 | 114 | /** 115 | * Define the translation identifier for the throttle response 116 | */ 117 | t(identifier: string, data?: Record) { 118 | this.translation = { identifier, data } 119 | return this 120 | } 121 | 122 | /** 123 | * Converts the throttle exception to an HTTP response 124 | */ 125 | async handle(error: ThrottleException, ctx: HttpContext) { 126 | const status = error.status 127 | const message = this.getResponseMessage(ctx) 128 | const headers = this.headers || this.getDefaultHeaders() 129 | 130 | Object.keys(headers).forEach((header) => ctx.response.header(header, headers[header])) 131 | 132 | switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { 133 | case 'html': 134 | case null: 135 | ctx.response.status(status).send(message) 136 | break 137 | case 'json': 138 | ctx.response.status(status).send({ 139 | errors: [ 140 | { 141 | message, 142 | retryAfter: this.response.availableIn, 143 | }, 144 | ], 145 | }) 146 | break 147 | case 'application/vnd.api+json': 148 | ctx.response.status(status).send({ 149 | errors: [ 150 | { 151 | code: this.code, 152 | title: message, 153 | meta: { 154 | retryAfter: this.response.availableIn, 155 | }, 156 | }, 157 | ], 158 | }) 159 | break 160 | } 161 | } 162 | } 163 | 164 | export const E_TOO_MANY_REQUESTS = ThrottleException 165 | -------------------------------------------------------------------------------- /src/http_limiter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { HttpContext } from '@adonisjs/core/http' 11 | import { RuntimeException } from '@adonisjs/core/exceptions' 12 | 13 | import debug from './debug.js' 14 | import { LimiterResponse } from './response.js' 15 | import type { LimiterManager } from './limiter_manager.js' 16 | import { E_TOO_MANY_REQUESTS, type ThrottleException } from './errors.js' 17 | import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js' 18 | 19 | /** 20 | * HttpLimiter is a special type of limiter instance created specifically 21 | * for HTTP requests. It exposes a single method to throttle the request 22 | * using the request ip address or the pre-defined unique key. 23 | */ 24 | export class HttpLimiter> { 25 | /** 26 | * The manager reference to create limiter instances 27 | * for a given store 28 | */ 29 | #manager: LimiterManager 30 | 31 | /** 32 | * The runtime options configured using the fluent 33 | * API 34 | */ 35 | #options: Partial 36 | 37 | /** 38 | * The selected store. Otherwise the default store will 39 | * be used 40 | */ 41 | #store?: keyof KnownStores 42 | 43 | /** 44 | * The key to unique identify the user. Defaults to "request.ip" 45 | */ 46 | #key?: string | number 47 | 48 | /** 49 | * A custom callback function to modify error messages. 50 | */ 51 | #exceptionModifier: (error: ThrottleException) => void = () => {} 52 | 53 | constructor(manager: LimiterManager, options?: LimiterConsumptionOptions) { 54 | this.#manager = manager 55 | this.#options = options || {} 56 | } 57 | 58 | /** 59 | * Specify the store you want to use during 60 | * the request 61 | */ 62 | store(store: keyof KnownStores) { 63 | this.#store = store 64 | return this 65 | } 66 | 67 | /** 68 | * Specify the number of requests to allow 69 | */ 70 | allowRequests(requests: number) { 71 | this.#options.requests = requests 72 | return this 73 | } 74 | 75 | /** 76 | * Specify the duration in seconds or a time expression 77 | * for which the requests to allow. 78 | * 79 | * For example: allowRequests(10).every('1 minute') 80 | */ 81 | every(duration: number | string) { 82 | this.#options.duration = duration 83 | return this 84 | } 85 | 86 | /** 87 | * Specify a custom unique key to identify the user. 88 | * Defaults to: request.ip() 89 | */ 90 | usingKey(key: string | number) { 91 | this.#key = key 92 | return this 93 | } 94 | 95 | /** 96 | * Register a callback function to modify the ThrottleException. 97 | */ 98 | limitExceeded(callback: (error: ThrottleException) => void) { 99 | this.#exceptionModifier = callback 100 | return this 101 | } 102 | 103 | /** 104 | * Define the block duration. The key will be blocked for the 105 | * specified duration after all the requests have been 106 | * exhausted 107 | */ 108 | blockFor(duration: number | string): this { 109 | this.#options.blockDuration = duration 110 | return this 111 | } 112 | 113 | /** 114 | * JSON representation of the HTTP limiter 115 | */ 116 | toJSON() { 117 | return { 118 | store: this.#store, 119 | ...this.#options, 120 | } 121 | } 122 | 123 | /** 124 | * Throttle request using the pre-defined options. Returns 125 | * LimiterResponse when request is allowed or throws 126 | * an exception. 127 | */ 128 | async throttle(prefix: string, ctx: HttpContext): Promise { 129 | if (!this.#options.requests || !this.#options.duration) { 130 | throw new RuntimeException( 131 | `Cannot throttle requests for "${prefix}" limiter. Make sure to define the allowed requests and duration` 132 | ) 133 | } 134 | 135 | const limiter = this.#store 136 | ? this.#manager.use(this.#store, this.#options as LimiterConsumptionOptions) 137 | : this.#manager.use(this.#options as LimiterConsumptionOptions) 138 | 139 | const key = `${prefix}_${this.#key || `ip_${ctx.request.ip()}`}` 140 | debug('throttling HTTP request for key "%s"', key) 141 | const limiterResponse = await limiter.get(key) 142 | 143 | /** 144 | * Abort when user has exhausted all the requests. 145 | * 146 | * We still run the "consume" method when consumed is same as 147 | * the limit, this will allow the consume method to trigger 148 | * the block logic. 149 | */ 150 | if (limiterResponse && limiterResponse.consumed > limiterResponse.limit) { 151 | debug('requests exhausted for key "%s"', key) 152 | const error = new E_TOO_MANY_REQUESTS(limiterResponse) 153 | this.#exceptionModifier(error) 154 | throw error 155 | } 156 | 157 | try { 158 | const consumeResponse = await limiter.consume(key) 159 | return consumeResponse 160 | } catch (error) { 161 | if (error instanceof E_TOO_MANY_REQUESTS) { 162 | debug('requests exhausted for key "%s"', key) 163 | this.#exceptionModifier(error) 164 | } 165 | throw error 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/limiter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { LimiterResponse } from './response.js' 11 | import { E_TOO_MANY_REQUESTS, ThrottleException } from './errors.js' 12 | import type { LimiterStoreContract } from './types.js' 13 | 14 | /** 15 | * Limiter acts as an adapter on top of the limiter 16 | * stores and offers additional APIs 17 | */ 18 | export class Limiter implements LimiterStoreContract { 19 | #store: LimiterStoreContract 20 | 21 | /** 22 | * The number of configured requests on the store 23 | */ 24 | get name() { 25 | return this.#store.name 26 | } 27 | 28 | /** 29 | * The number of configured requests on the store 30 | */ 31 | get requests() { 32 | return this.#store.requests 33 | } 34 | 35 | /** 36 | * The duration (in seconds) for which the requests are configured 37 | */ 38 | get duration() { 39 | return this.#store.duration 40 | } 41 | 42 | /** 43 | * The duration (in seconds) for which to block the key 44 | */ 45 | get blockDuration() { 46 | return this.#store.blockDuration 47 | } 48 | 49 | constructor(store: LimiterStoreContract) { 50 | this.#store = store 51 | } 52 | 53 | /** 54 | * Consume 1 request for a given key. An exception is raised 55 | * when all the requests have already been consumed or if 56 | * the key is blocked. 57 | */ 58 | consume(key: string | number): Promise { 59 | return this.#store.consume(key) 60 | } 61 | 62 | /** 63 | * Increment the number of consumed requests for a given key. 64 | * No errors are thrown when limit has reached 65 | */ 66 | increment(key: string | number): Promise { 67 | return this.#store.increment(key) 68 | } 69 | 70 | /** 71 | * Decrement the number of consumed requests for a given key. 72 | */ 73 | decrement(key: string | number): Promise { 74 | return this.#store.decrement(key) 75 | } 76 | 77 | /** 78 | * Consume 1 request for a given key and execute the provided 79 | * callback. 80 | */ 81 | async attempt(key: string | number, callback: () => T | Promise): Promise { 82 | /** 83 | * Return early when remaining requests are less than 84 | * zero. 85 | * 86 | * We still run the "consume" method when consumed is same as 87 | * the limit, this will allow the consume method to trigger 88 | * the block logic. 89 | */ 90 | const response = await this.get(key) 91 | if (response && response.consumed > response.limit) { 92 | return 93 | } 94 | 95 | try { 96 | await this.consume(key) 97 | return callback() 98 | } catch (error) { 99 | if (error instanceof E_TOO_MANY_REQUESTS === false) { 100 | throw error 101 | } 102 | } 103 | } 104 | 105 | /** 106 | * Consume 1 request for a given key when the executed method throws 107 | * an error. 108 | * 109 | * - Check if all the requests have been exhausted. If yes, throw limiter 110 | * error. 111 | * - Otherwise, execute the provided callback. 112 | * - Increment the requests counter, if provided callback throws an error and rethrow 113 | * the error 114 | * - Delete key, if the provided callback succeeds and return the results. 115 | */ 116 | async penalize( 117 | key: string | number, 118 | callback: () => T | Promise 119 | ): Promise<[null, T] | [ThrottleException, null]> { 120 | const response = await this.get(key) 121 | 122 | /** 123 | * Abort when user has exhausted all the requests 124 | */ 125 | if (response && response.remaining <= 0) { 126 | return [new E_TOO_MANY_REQUESTS(response), null] 127 | } 128 | 129 | let callbackResult: T 130 | let callbackError: unknown 131 | 132 | try { 133 | callbackResult = await callback() 134 | } catch (error) { 135 | callbackError = error 136 | } 137 | 138 | /** 139 | * Consume one point and block the key if there is 140 | * an error. 141 | */ 142 | if (callbackError) { 143 | const { consumed, limit } = await this.increment(key) 144 | if (consumed >= limit && this.blockDuration) { 145 | await this.block(key, this.blockDuration) 146 | } 147 | throw callbackError 148 | } 149 | 150 | /** 151 | * Reset key 152 | */ 153 | await this.delete(key) 154 | return [null, callbackResult!] 155 | } 156 | 157 | /** 158 | * Block a given key for the given duration. The duration must be 159 | * a value in seconds or a string expression. 160 | */ 161 | block(key: string | number, duration: string | number): Promise { 162 | return this.#store.block(key, duration) 163 | } 164 | 165 | /** 166 | * Manually set the number of requests exhausted for 167 | * a given key for the given time duration. 168 | * 169 | * For example: "ip_127.0.0.1" has made "20 requests" in "1 minute". 170 | * Now, if you allow 25 requests in 1 minute, then only 5 requests 171 | * are left. 172 | * 173 | * The duration must be a value in seconds or a string expression. 174 | */ 175 | set( 176 | key: string | number, 177 | requests: number, 178 | duration?: string | number 179 | ): Promise { 180 | return this.#store.set(key, requests, duration) 181 | } 182 | 183 | /** 184 | * Delete a given key 185 | */ 186 | delete(key: string | number): Promise { 187 | return this.#store.delete(key) 188 | } 189 | 190 | /** 191 | * Delete all keys blocked within the memory 192 | */ 193 | deleteInMemoryBlockedKeys(): void { 194 | return this.#store.deleteInMemoryBlockedKeys?.() 195 | } 196 | 197 | /** 198 | * Get limiter response for a given key. Returns null when 199 | * key doesn't exist. 200 | */ 201 | get(key: string | number): Promise { 202 | return this.#store.get(key) 203 | } 204 | 205 | /** 206 | * Find the number of remaining requests for a given key 207 | */ 208 | async remaining(key: string | number): Promise { 209 | const response = await this.get(key) 210 | if (!response) { 211 | return this.requests 212 | } 213 | 214 | return response.remaining 215 | } 216 | 217 | /** 218 | * Find the number of seconds remaining until the key will 219 | * be available for new request 220 | */ 221 | async availableIn(key: string | number): Promise { 222 | const response = await this.get(key) 223 | if (!response) { 224 | return 0 225 | } 226 | 227 | return response.remaining === 0 ? response.availableIn : 0 228 | } 229 | 230 | /** 231 | * Find if the current key is blocked. This method checks 232 | * if the consumed points are equal to or greater than 233 | * the allowed limit. 234 | */ 235 | async isBlocked(key: string | number): Promise { 236 | const response = await this.get(key) 237 | if (!response) { 238 | return false 239 | } 240 | 241 | return response.consumed >= response.limit 242 | } 243 | 244 | /** 245 | * Clear the storage database 246 | */ 247 | clear(): Promise { 248 | return this.#store.clear() 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/limiter_manager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@adonisjs/core/helpers/string' 11 | import type { HttpContext } from '@adonisjs/core/http' 12 | import type { MiddlewareFn } from '@adonisjs/core/types/http' 13 | import { RuntimeException } from '@adonisjs/core/exceptions' 14 | 15 | import debug from './debug.js' 16 | import { Limiter } from './limiter.js' 17 | import { HttpLimiter } from './http_limiter.js' 18 | import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js' 19 | 20 | /** 21 | * Limiter manager is used to manage multiple rate limiters 22 | * using different storage providers. 23 | * 24 | * Also, you can create limiter instances with runtime options 25 | * for "requests", "duration", and "blockDuration". 26 | */ 27 | export class LimiterManager> { 28 | /** 29 | * Cached limiters. One limiter is created for a unique combination 30 | * of "store,requests,duration,blockDuration" options 31 | */ 32 | #limiters: Map> = new Map() 33 | 34 | constructor(public config: { default: keyof KnownStores; stores: KnownStores }) { 35 | this.config = config 36 | } 37 | 38 | /** 39 | * Creates a unique key for a limiter instance. Since, we allow creating 40 | * limiters with runtime options for "requests", "duration" and "blockDuration". 41 | * The limiterKey is used to identify a limiter instance. 42 | */ 43 | protected makeLimiterKey(store: keyof KnownStores, options: LimiterConsumptionOptions) { 44 | const chunks = [`s:${String(store)}`, `r:${options.requests}`, `d:${options.duration}`] 45 | if (options.blockDuration) { 46 | chunks.push(`bd:${options.blockDuration}`) 47 | } 48 | if (options.inMemoryBlockOnConsumed) { 49 | chunks.push(`mbc:${options.inMemoryBlockOnConsumed}`) 50 | } 51 | if (options.inMemoryBlockDuration) { 52 | chunks.push(`mbd:${options.inMemoryBlockDuration}`) 53 | } 54 | return chunks.join(',') 55 | } 56 | 57 | /** 58 | * Make a limiter instance for a given store and with 59 | * runtime options. 60 | * 61 | * Caches instances forever for the lifecycle of the process. 62 | */ 63 | use(options: LimiterConsumptionOptions): Limiter 64 | use(store: K, options: LimiterConsumptionOptions): Limiter 65 | use( 66 | store: keyof KnownStores | LimiterConsumptionOptions, 67 | options?: LimiterConsumptionOptions 68 | ): Limiter { 69 | /** 70 | * Normalize options 71 | */ 72 | let storeToUse: keyof KnownStores = typeof store === 'string' ? store : this.config.default 73 | let optionsToUse: LimiterConsumptionOptions | undefined = 74 | typeof store === 'object' ? store : options 75 | 76 | /** 77 | * Ensure options are defined 78 | */ 79 | if (!optionsToUse) { 80 | throw new RuntimeException( 81 | 'Specify the number of allowed requests and duration to create a limiter' 82 | ) 83 | } 84 | 85 | optionsToUse.duration = string.seconds.parse(optionsToUse.duration) 86 | if (optionsToUse.blockDuration) { 87 | optionsToUse.blockDuration = string.seconds.parse(optionsToUse.blockDuration) 88 | } 89 | if (optionsToUse.inMemoryBlockDuration) { 90 | optionsToUse.inMemoryBlockDuration = string.seconds.parse(optionsToUse.inMemoryBlockDuration) 91 | } 92 | 93 | /** 94 | * Initiate the store map when it does not have any 95 | * cached limiters 96 | */ 97 | if (!this.#limiters.has(storeToUse as string)) { 98 | this.#limiters.set(storeToUse as string, new Map()) 99 | } 100 | 101 | const storeLimiters = this.#limiters.get(storeToUse as string)! 102 | 103 | /** 104 | * Make limiter key to uniquely identify a limiter 105 | */ 106 | const limiterKey = this.makeLimiterKey(storeToUse, optionsToUse) 107 | debug('created limiter key "%s"', limiterKey) 108 | 109 | /** 110 | * Read and return from cache 111 | */ 112 | if (storeLimiters.has(limiterKey)) { 113 | debug('re-using cached limiter store "%s", options %O', storeToUse, optionsToUse) 114 | return storeLimiters.get(limiterKey)! 115 | } 116 | 117 | /** 118 | * Create a fresh instance and cache it 119 | */ 120 | const limiter = new Limiter(this.config.stores[storeToUse](optionsToUse)) 121 | debug('creating new limiter instance "%s", options %O', storeToUse, optionsToUse) 122 | storeLimiters.set(limiterKey, limiter) 123 | return limiter 124 | } 125 | 126 | /** 127 | * Clear stored data with the stores 128 | */ 129 | async clear(stores?: Extract[]) { 130 | const storesToUse = stores || Object.keys(this.config.stores) 131 | 132 | /** 133 | * Loop over all the limiters created across all the stores 134 | * and clear their storage. 135 | * 136 | * Since, all stores uses a central database, we just need the 137 | * first instance and call clear on it. 138 | * 139 | * In case of memory store, we have to clear all the stores. 140 | */ 141 | for (let store of storesToUse) { 142 | const storeLimiters = this.#limiters.get(store) 143 | if (storeLimiters) { 144 | /** 145 | * Clear all instances in case of the memory 146 | * store 147 | */ 148 | if (store === 'memory') { 149 | for (let limiter of storeLimiters.values()) { 150 | await limiter.clear() 151 | } 152 | } else { 153 | /** 154 | * Clear first store 155 | */ 156 | const [limiter] = storeLimiters.values() 157 | limiter && (await limiter.clear()) 158 | } 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * Creates HTTP limiter instance 165 | */ 166 | allowRequests(requests: number) { 167 | return new HttpLimiter(this).allowRequests(requests) 168 | } 169 | 170 | /** 171 | * A shorthand method that returns null to disable 172 | * rate limiting 173 | */ 174 | noLimit() { 175 | return null 176 | } 177 | 178 | /** 179 | * Define a named HTTP middleware to apply rate 180 | * limits on specific routes 181 | */ 182 | define( 183 | name: string, 184 | builder: ( 185 | ctx: HttpContext 186 | ) => HttpLimiter | null | Promise> | Promise 187 | ): MiddlewareFn { 188 | const middlewareFn: MiddlewareFn = async (ctx, next) => { 189 | /** 190 | * Invoke the builder for every HTTP request and we use 191 | * the return value to decide how to apply the rate 192 | * limit on the request 193 | */ 194 | const limiter = await builder(ctx) 195 | 196 | /** 197 | * Do not throttle when no limiter is used for 198 | * the request 199 | */ 200 | if (!limiter) { 201 | return next() 202 | } 203 | 204 | /** 205 | * Throttle request using the HTTP limiter 206 | */ 207 | const limiterResponse = await limiter.throttle(name, ctx) 208 | 209 | /** 210 | * Invoke rest of the pipeline 211 | */ 212 | const response = await next() 213 | 214 | /** 215 | * Define appropriate headers 216 | */ 217 | ctx.response.header('X-RateLimit-Limit', limiterResponse.limit) 218 | ctx.response.header('X-RateLimit-Remaining', limiterResponse.remaining) 219 | 220 | /** 221 | * Return response 222 | */ 223 | return response 224 | } 225 | 226 | Object.defineProperty(middlewareFn, 'name', { 227 | value: `${name}Throttle`, 228 | }) 229 | return middlewareFn 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/response.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export class LimiterResponse { 11 | /** 12 | * Allowed number of requests for a pre-defined 13 | * duration 14 | */ 15 | limit: number 16 | 17 | /** 18 | * Requests remaining for the pre-defined duration 19 | */ 20 | remaining: number 21 | 22 | /** 23 | * Requests consumed for the pre-defined duration 24 | */ 25 | consumed: number 26 | 27 | /** 28 | * Number of seconds after which the requests count will 29 | * reset 30 | */ 31 | availableIn: number 32 | 33 | constructor(rawResponse: { 34 | limit: number 35 | remaining: number 36 | consumed: number 37 | availableIn: number 38 | }) { 39 | this.limit = rawResponse.limit 40 | this.remaining = rawResponse.remaining 41 | this.consumed = rawResponse.consumed 42 | this.availableIn = rawResponse.availableIn 43 | } 44 | 45 | toJSON() { 46 | return { 47 | limit: this.limit, 48 | remaining: this.remaining, 49 | consumed: this.consumed, 50 | availableIn: this.availableIn, 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/stores/bridge.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@adonisjs/core/helpers/string' 11 | import { 12 | RateLimiterRes, 13 | type RateLimiterAbstract, 14 | type RateLimiterStoreAbstract, 15 | } from 'rate-limiter-flexible' 16 | 17 | import debug from '../debug.js' 18 | import { LimiterResponse } from '../response.js' 19 | import { E_TOO_MANY_REQUESTS } from '../errors.js' 20 | import type { LimiterStoreContract } from '../types.js' 21 | 22 | /** 23 | * The bridget store acts as a bridge between the "rate-limiter-flexible" 24 | * package and the AdonisJS limiter store. 25 | * 26 | * If you are wrapping an existing "rate-limiter-flexible" store, then you 27 | * must inherit your implementation from this class. 28 | */ 29 | export default abstract class RateLimiterBridge implements LimiterStoreContract { 30 | protected rateLimiter: RateLimiterStoreAbstract | RateLimiterAbstract 31 | 32 | /** 33 | * A unique name for the store 34 | */ 35 | abstract readonly name: string 36 | 37 | /** 38 | * The number of configured requests on the store 39 | */ 40 | get requests() { 41 | return this.rateLimiter.points 42 | } 43 | 44 | /** 45 | * The duration (in seconds) for which the requests are configured 46 | */ 47 | get duration() { 48 | return this.rateLimiter.duration 49 | } 50 | 51 | /** 52 | * The duration (in seconds) for which to block the key 53 | */ 54 | get blockDuration() { 55 | return this.rateLimiter.blockDuration 56 | } 57 | 58 | constructor(rateLimiter: RateLimiterStoreAbstract | RateLimiterAbstract) { 59 | this.rateLimiter = rateLimiter 60 | } 61 | 62 | /** 63 | * Clear database 64 | */ 65 | abstract clear(): Promise 66 | 67 | /** 68 | * Makes LimiterResponse from "node-rate-limiter-flexible" response 69 | * object 70 | */ 71 | protected makeLimiterResponse(response: RateLimiterRes): LimiterResponse { 72 | return new LimiterResponse({ 73 | limit: this.rateLimiter.points, 74 | remaining: response.remainingPoints, 75 | consumed: response.consumedPoints, 76 | availableIn: Math.ceil(response.msBeforeNext / 1000), 77 | }) 78 | } 79 | 80 | /** 81 | * Consume 1 request for a given key. An exception is raised 82 | * when all the requests have already been consumed or if 83 | * the key is blocked. 84 | */ 85 | async consume(key: string | number): Promise { 86 | try { 87 | const response = await this.rateLimiter.consume(key, 1) 88 | debug('request consumed for key %s', key) 89 | return this.makeLimiterResponse(response) 90 | } catch (errorResponse: unknown) { 91 | debug('unable to consume request for key %s, %O', key, errorResponse) 92 | if (errorResponse instanceof RateLimiterRes) { 93 | throw new E_TOO_MANY_REQUESTS(this.makeLimiterResponse(errorResponse)) 94 | } 95 | 96 | throw errorResponse 97 | } 98 | } 99 | 100 | /** 101 | * Increment the number of consumed requests for a given key. 102 | * No errors are thrown when limit has reached 103 | */ 104 | async increment(key: string | number): Promise { 105 | const response = await this.rateLimiter.penalty(key, 1) 106 | debug('increased requests count for key %s', key) 107 | 108 | return this.makeLimiterResponse(response) 109 | } 110 | 111 | /** 112 | * Decrement the number of consumed requests for a given key. 113 | */ 114 | async decrement(key: string | number): Promise { 115 | const existingKey = await this.rateLimiter.get(key) 116 | 117 | /** 118 | * Set key with zero when key does not exists 119 | */ 120 | if (!existingKey) { 121 | return this.set(key, 0, this.duration) 122 | } 123 | 124 | /** 125 | * Do not decrement beyond zero 126 | */ 127 | if (existingKey.consumedPoints <= 0) { 128 | return this.makeLimiterResponse(existingKey) 129 | } 130 | 131 | /** 132 | * Decrement 133 | */ 134 | const response = await this.rateLimiter.reward(key, 1) 135 | debug('decreased requests count for key %s', key) 136 | 137 | return this.makeLimiterResponse(response) 138 | } 139 | 140 | /** 141 | * Block a given key for the given duration. The duration must be 142 | * a value in seconds or a string expression. 143 | */ 144 | async block(key: string | number, duration: string | number): Promise { 145 | const response = await this.rateLimiter.block(key, string.seconds.parse(duration)) 146 | debug('blocked key %s', key) 147 | return this.makeLimiterResponse(response) 148 | } 149 | 150 | /** 151 | * Manually set the number of requests exhausted for 152 | * a given key for the given time duration. 153 | * 154 | * For example: "ip_127.0.0.1" has made "20 requests" in "1 minute". 155 | * Now, if you allow 25 requests in 1 minute, then only 5 requests 156 | * are left. 157 | * 158 | * The duration must be a value in seconds or a string expression. 159 | */ 160 | async set( 161 | key: string | number, 162 | requests: number, 163 | duration?: string | number 164 | ): Promise { 165 | const response = await this.rateLimiter.set( 166 | key, 167 | requests, 168 | duration ? string.seconds.parse(duration) : this.duration 169 | ) 170 | debug('updated key %s with requests: %s, duration: %s', key, requests, duration) 171 | 172 | /** 173 | * The value of "response.remainingPoints" in a set method call 174 | * is always zero. It is hard coded as such in 175 | * the "rate-limiter-flexible" package. 176 | * 177 | * Therefore, we compute it locally 178 | */ 179 | const remaining = this.requests - response.consumedPoints 180 | 181 | const limiterResponse = this.makeLimiterResponse(response) 182 | limiterResponse.remaining = remaining < 0 ? 0 : remaining 183 | return limiterResponse 184 | } 185 | 186 | /** 187 | * Delete a given key 188 | */ 189 | delete(key: string | number): Promise { 190 | debug('deleting key %s', key) 191 | return this.rateLimiter.delete(key) 192 | } 193 | 194 | /** 195 | * Delete all keys blocked within the memory 196 | */ 197 | deleteInMemoryBlockedKeys(): void { 198 | if ('deleteInMemoryBlockedAll' in this.rateLimiter) { 199 | return this.rateLimiter.deleteInMemoryBlockedAll() 200 | } 201 | } 202 | 203 | /** 204 | * Get limiter response for a given key. Returns null when 205 | * key doesn't exist. 206 | */ 207 | async get(key: string | number): Promise { 208 | const response = await this.rateLimiter.get(key) 209 | debug('fetching key %s, %O', key, response) 210 | if (!response || Number.isNaN(response.remainingPoints)) { 211 | return null 212 | } 213 | 214 | return this.makeLimiterResponse(response) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/stores/database.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@adonisjs/core/helpers/string' 11 | import { RuntimeException } from '@adonisjs/core/exceptions' 12 | import type { DialectContract, QueryClientContract } from '@adonisjs/lucid/types/database' 13 | import { RateLimiterMySQL, RateLimiterPostgres, RateLimiterSQLite } from 'rate-limiter-flexible' 14 | 15 | import debug from '../debug.js' 16 | import RateLimiterBridge from './bridge.js' 17 | import type { LimiterDatabaseStoreConfig } from '../types.js' 18 | 19 | const SUPPORTED_CLIENTS = [ 20 | 'mysql', 21 | 'postgres', 22 | 'better-sqlite3', 23 | 'sqlite3', 24 | ] satisfies DialectContract['name'][] 25 | 26 | /** 27 | * Limiter database store wraps the "RateLimiterMySQL" or "RateLimiterPostgres" 28 | * implementations from the "rate-limiter-flixible" package. 29 | */ 30 | export default class LimiterDatabaseStore extends RateLimiterBridge { 31 | #config: LimiterDatabaseStoreConfig 32 | #client: QueryClientContract 33 | 34 | get name() { 35 | return 'database' 36 | } 37 | 38 | constructor(client: QueryClientContract, config: LimiterDatabaseStoreConfig) { 39 | const dialectName = client.dialect.name as (typeof SUPPORTED_CLIENTS)[number] 40 | if (!SUPPORTED_CLIENTS.includes(dialectName)) { 41 | throw new RuntimeException( 42 | `Unsupported database "${dialectName}". The limiter can only work with PostgreSQL, MySQL, and SQLite databases` 43 | ) 44 | } 45 | 46 | debug('creating %s limiter store %O', dialectName, config) 47 | 48 | switch (dialectName) { 49 | case 'mysql': 50 | super( 51 | new RateLimiterMySQL({ 52 | storeType: 'knex', 53 | storeClient: client.getWriteClient(), 54 | tableCreated: true, 55 | dbName: config.dbName, 56 | tableName: config.tableName, 57 | keyPrefix: config.keyPrefix, 58 | execEvenly: config.execEvenly, 59 | points: config.requests, 60 | clearExpiredByTimeout: config.clearExpiredByTimeout, 61 | duration: string.seconds.parse(config.duration), 62 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed, 63 | blockDuration: config.blockDuration 64 | ? string.seconds.parse(config.blockDuration) 65 | : undefined, 66 | inMemoryBlockDuration: config.inMemoryBlockDuration 67 | ? string.seconds.parse(config.inMemoryBlockDuration) 68 | : undefined, 69 | }) 70 | ) 71 | this.#client = client 72 | this.#config = config 73 | break 74 | case 'postgres': 75 | super( 76 | new RateLimiterPostgres({ 77 | storeType: 'knex', 78 | schemaName: config.schemaName, 79 | storeClient: client.getWriteClient(), 80 | tableCreated: true, 81 | dbName: config.dbName, 82 | tableName: config.tableName, 83 | keyPrefix: config.keyPrefix, 84 | execEvenly: config.execEvenly, 85 | points: config.requests, 86 | clearExpiredByTimeout: config.clearExpiredByTimeout, 87 | duration: string.seconds.parse(config.duration), 88 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed, 89 | blockDuration: config.blockDuration 90 | ? string.seconds.parse(config.blockDuration) 91 | : undefined, 92 | inMemoryBlockDuration: config.inMemoryBlockDuration 93 | ? string.seconds.parse(config.inMemoryBlockDuration) 94 | : undefined, 95 | }) 96 | ) 97 | this.#client = client 98 | this.#config = config 99 | break 100 | case 'better-sqlite3': 101 | case 'sqlite3': 102 | super( 103 | new RateLimiterSQLite({ 104 | storeType: 'knex', 105 | storeClient: client.getWriteClient(), 106 | tableCreated: true, 107 | dbName: config.dbName, 108 | tableName: config.tableName, 109 | keyPrefix: config.keyPrefix, 110 | execEvenly: config.execEvenly, 111 | points: config.requests, 112 | clearExpiredByTimeout: config.clearExpiredByTimeout, 113 | duration: string.seconds.parse(config.duration), 114 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed, 115 | blockDuration: config.blockDuration 116 | ? string.seconds.parse(config.blockDuration) 117 | : undefined, 118 | inMemoryBlockDuration: config.inMemoryBlockDuration 119 | ? string.seconds.parse(config.inMemoryBlockDuration) 120 | : undefined, 121 | }) 122 | ) 123 | this.#client = client 124 | this.#config = config 125 | break 126 | } 127 | } 128 | 129 | /** 130 | * Deletes all rows from the database table. Make sure to 131 | * use separate database tables for every rate limiter 132 | * your configure. 133 | */ 134 | async clear() { 135 | debug('truncating database table %s', this.#config.tableName) 136 | this.deleteInMemoryBlockedKeys() 137 | await this.#client.dialect.truncate(this.#config.tableName, true) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/stores/memory.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@adonisjs/core/helpers/string' 11 | import { type IRateLimiterOptions, RateLimiterMemory } from 'rate-limiter-flexible' 12 | 13 | import debug from '../debug.js' 14 | import RateLimiterBridge from './bridge.js' 15 | import type { LimiterMemoryStoreConfig } from '../types.js' 16 | 17 | /** 18 | * Limiter memory store wraps the "RateLimiterMemory" implementation 19 | * from the "rate-limiter-flixible" package. 20 | */ 21 | export default class LimiterMemoryStore extends RateLimiterBridge { 22 | #config: IRateLimiterOptions 23 | 24 | get name() { 25 | return 'memory' 26 | } 27 | 28 | constructor(config: LimiterMemoryStoreConfig) { 29 | debug('creating memory limiter store %O', config) 30 | const resolvedConfig = { 31 | keyPrefix: config.keyPrefix, 32 | execEvenly: config.execEvenly, 33 | points: config.requests, 34 | duration: string.seconds.parse(config.duration), 35 | blockDuration: config.blockDuration ? string.seconds.parse(config.blockDuration) : undefined, 36 | } 37 | 38 | super(new RateLimiterMemory(resolvedConfig)) 39 | this.#config = resolvedConfig 40 | } 41 | 42 | /** 43 | * Clears the existing memory store to reset 44 | * rate limits 45 | */ 46 | async clear() { 47 | debug('clearing memory store') 48 | this.rateLimiter = new RateLimiterMemory(this.#config) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/stores/redis.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@adonisjs/core/helpers/string' 11 | import { RateLimiterRedis } from 'rate-limiter-flexible' 12 | import { RedisClusterConnection, RedisConnection } from '@adonisjs/redis' 13 | 14 | import debug from '../debug.js' 15 | import RateLimiterBridge from './bridge.js' 16 | import type { LimiterRedisStoreConfig } from '../types.js' 17 | 18 | /** 19 | * Limiter redis store wraps the "RateLimiterRedis" implementation 20 | * from the "rate-limiter-flixible" package. 21 | */ 22 | export default class LimiterRedisStore extends RateLimiterBridge { 23 | #client: RedisConnection | RedisClusterConnection 24 | 25 | get name() { 26 | return 'redis' 27 | } 28 | 29 | constructor(client: RedisConnection | RedisClusterConnection, config: LimiterRedisStoreConfig) { 30 | debug('creating redis limiter store %O', config) 31 | super( 32 | new RateLimiterRedis({ 33 | rejectIfRedisNotReady: config.rejectIfRedisNotReady, 34 | storeClient: client.ioConnection, 35 | keyPrefix: config.keyPrefix, 36 | execEvenly: config.execEvenly, 37 | points: config.requests, 38 | duration: string.seconds.parse(config.duration), 39 | inMemoryBlockOnConsumed: config.inMemoryBlockOnConsumed, 40 | blockDuration: config.blockDuration 41 | ? string.seconds.parse(config.blockDuration) 42 | : undefined, 43 | inMemoryBlockDuration: config.inMemoryBlockDuration 44 | ? string.seconds.parse(config.inMemoryBlockDuration) 45 | : undefined, 46 | }) 47 | ) 48 | this.#client = client 49 | } 50 | 51 | /** 52 | * Flushes the redis database to clear existing 53 | * rate limits. 54 | * 55 | * Make sure to use a separate db for store rate limits 56 | * as this method flushes the entire database 57 | */ 58 | async clear() { 59 | this.deleteInMemoryBlockedKeys() 60 | if (this.#client instanceof RedisClusterConnection) { 61 | debug('flushing redis cluster') 62 | for (let node of this.#client.nodes('master')) { 63 | await node.flushdb() 64 | } 65 | } else { 66 | debug('flushing redis database') 67 | await this.#client.flushdb() 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ConfigProvider } from '@adonisjs/core/types' 11 | import { LimiterManager } from './limiter_manager.js' 12 | import type { LimiterResponse } from './response.js' 13 | 14 | /** 15 | * The base configuration shared across all the stores. 16 | * 17 | * These options are inherited from the "rate-limiter-flexible" 18 | * package. However, a custom store can ignore these options 19 | * and create the implementation from scratch with custom 20 | * set of options. 21 | */ 22 | export type LimiterStoreBaseConfig = { 23 | /** 24 | * The prefix to apply to all keys to ensure they are 25 | * unique across different limiter instances. 26 | * 27 | * Defaults to the key of the stores collection. 28 | */ 29 | keyPrefix?: string 30 | 31 | /** 32 | * Define the number of requests after which the key should 33 | * be blocked within memory and avoid hitting the store. 34 | * 35 | * Let's understand this with an example: 36 | * - You allow 100 requests per minute to a user 37 | * - They make 140 requests. The last 40 requests will be denied 38 | * - However, the store still has to consult the database to know 39 | * if there are any requests left for a user on a given key. 40 | * - With this option, you can tell the store to stop consulting 41 | * the database after the count reaches 120. 42 | */ 43 | inMemoryBlockOnConsumed?: number 44 | 45 | /** 46 | * The duration for which to block the user within memory after the 47 | * user has consumed all the requests. The value of this property 48 | * must match the "blockDuration" property in most case. 49 | * 50 | * The value must be a number in seconds or a string expression 51 | */ 52 | inMemoryBlockDuration?: number | string 53 | 54 | /** 55 | * Delay the subsequent requests, so that all requests finish at the 56 | * end of the duration timeframe. 57 | * 58 | * Let's understand with an example 59 | * 60 | * - You allow a user to make 10 requests every 5 mins. 61 | * - Now, if they make all 10 requests within the first minute, they 62 | * will be sitting idle for next 4 mins. 63 | * - Now multiply this behavior across all the users of your app and 64 | * therefore you might see a peak in traffic during the first min 65 | * but no traffic in the last 4 mins. 66 | * 67 | * 68 | * - With "execEvenly" enabled, if a user makes 10 requests within the 69 | * first minute, they all will be kept waiting with incremental 70 | * timeouts. 71 | * - Hence, the last request made during that 1st minute will finish 72 | * after 5mins. 73 | * 74 | * Learn more 75 | * https://github.com/animir/node-rate-limiter-flexible/wiki/Smooth-out-traffic-peaks 76 | */ 77 | execEvenly?: boolean 78 | } 79 | 80 | /** 81 | * The options accepted by stores to consume request/points 82 | * for a given key. 83 | */ 84 | export type LimiterConsumptionOptions = Pick< 85 | LimiterStoreBaseConfig, 86 | 'inMemoryBlockDuration' | 'inMemoryBlockOnConsumed' 87 | > & { 88 | /** 89 | * Number of requests to allow during the specific 90 | * duration 91 | */ 92 | requests: number 93 | 94 | /** 95 | * The duration after which the requests will be reset. 96 | * 97 | * The value must be a number in seconds or a string expression. 98 | */ 99 | duration: number | string 100 | 101 | /** 102 | * The duration for which the key will be blocked after 103 | * consuming all the requests. 104 | * 105 | * The blocking should be performed when you want to penalize 106 | * a user for consuming all the requests. 107 | * 108 | * The value must be a number in seconds or a string expression 109 | */ 110 | blockDuration?: number | string 111 | } 112 | 113 | /** 114 | * Config accepted by the limiter's memory store 115 | */ 116 | export type LimiterMemoryStoreConfig = LimiterStoreBaseConfig & LimiterConsumptionOptions 117 | 118 | /** 119 | * Config accepted by the limiter's redis store 120 | */ 121 | export type LimiterRedisStoreConfig = LimiterStoreBaseConfig & 122 | LimiterConsumptionOptions & { 123 | /** 124 | * Reject limiter instance creation when redis is not 125 | * ready to accept connection 126 | */ 127 | rejectIfRedisNotReady?: boolean 128 | } 129 | 130 | /** 131 | * Config accepted by the limiter's database store 132 | */ 133 | export type LimiterDatabaseStoreConfig = LimiterStoreBaseConfig & 134 | LimiterConsumptionOptions & { 135 | /** 136 | * The database to connect with. 137 | */ 138 | dbName?: string 139 | 140 | /** 141 | * The database table to use for storing keys. Defaults 142 | * to "keyPrefix" 143 | */ 144 | tableName: string 145 | 146 | /** 147 | * Define schema to use for making database queries. 148 | * 149 | * Applicable for postgres only 150 | */ 151 | schemaName?: string 152 | 153 | /** 154 | * Automatically clear expired keys every 5 minutes. 155 | */ 156 | clearExpiredByTimeout?: boolean 157 | } 158 | 159 | /** 160 | * The limiter store contract that all stores should 161 | * implement. 162 | */ 163 | export interface LimiterStoreContract { 164 | /** 165 | * A unique name for the store 166 | */ 167 | readonly name: string 168 | 169 | /** 170 | * The number of configured requests on the store 171 | */ 172 | readonly requests: number 173 | 174 | /** 175 | * The duration (in seconds) for which the requests are configured 176 | */ 177 | readonly duration: number 178 | 179 | /** 180 | * The duration (in seconds) for which to block the key 181 | */ 182 | readonly blockDuration: number 183 | 184 | /** 185 | * Consume 1 request for a given key. An exception is raised 186 | * when all the requests have already been consumed or if 187 | * the key is blocked. 188 | */ 189 | consume(key: string | number): Promise 190 | 191 | /** 192 | * Increment the number of consumed requests for a given key. 193 | * No errors are thrown when limit has reached 194 | */ 195 | increment(key: string | number): Promise 196 | 197 | /** 198 | * Decrement the number of consumed requests for a given key. 199 | */ 200 | decrement(key: string | number): Promise 201 | 202 | /** 203 | * Block a given key for the given duration. The duration must be 204 | * a value in seconds or a string expression. 205 | */ 206 | block(key: string | number, duration: string | number): Promise 207 | 208 | /** 209 | * Manually set the number of requests exhausted for 210 | * a given key for the given time duration. 211 | * 212 | * For example: "ip_127.0.0.1" has made "20 requests" in "1 minute". 213 | * Now, if you allow 25 requests in 1 minute, then only 5 requests 214 | * are left. 215 | * 216 | * The duration must be a value in seconds or a string expression. 217 | */ 218 | set(key: string | number, requests: number, duration?: string | number): Promise 219 | 220 | /** 221 | * Delete a given key 222 | */ 223 | delete(key: string | number): Promise 224 | 225 | /** 226 | * Delete all keys blocked within the memory 227 | */ 228 | deleteInMemoryBlockedKeys?(): void 229 | 230 | /** 231 | * Clear the storage database 232 | */ 233 | clear(): Promise 234 | 235 | /** 236 | * Get limiter response for a given key. Returns null when 237 | * key doesn't exist. 238 | */ 239 | get(key: string | number): Promise 240 | } 241 | 242 | /** 243 | * The manager factory is used to create an instance of the 244 | * store with consumption options 245 | */ 246 | export type LimiterManagerStoreFactory = ( 247 | options: LimiterConsumptionOptions 248 | ) => LimiterStoreContract 249 | 250 | /** 251 | * A list of known limiters inferred from the user config 252 | */ 253 | export interface LimitersList {} 254 | 255 | /** 256 | * Helper method to resolve configured limiters 257 | * inside user app 258 | */ 259 | export type InferLimiters< 260 | T extends ConfigProvider<{ stores: Record }>, 261 | > = Awaited>['stores'] 262 | 263 | /** 264 | * Limiter service is a singleton instance of limiter 265 | * manager configured using user app's config 266 | */ 267 | export interface LimiterService 268 | extends LimiterManager< 269 | LimitersList extends Record ? LimitersList : never 270 | > {} 271 | -------------------------------------------------------------------------------- /stubs/config/limiter.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.configPath('limiter.ts') }) 3 | }}} 4 | import env from '#start/env' 5 | import { defineConfig, stores } from '@adonisjs/limiter' 6 | 7 | const limiterConfig = defineConfig({ 8 | default: env.get('LIMITER_STORE'), 9 | stores: { 10 | {{#if store === 'redis'}} 11 | /** 12 | * Redis store to save rate limiting data inside a 13 | * redis database. 14 | * 15 | * It is recommended to use a separate database for 16 | * the limiter connection. 17 | */ 18 | redis: stores.redis({}), 19 | {{/if}} 20 | {{#if store === 'database'}} 21 | /** 22 | * Database store to save rate limiting data inside a 23 | * MYSQL or PostgreSQL database. 24 | */ 25 | database: stores.database({ 26 | tableName: 'rate_limits' 27 | }), 28 | {{/if}} 29 | /** 30 | * Memory store could be used during 31 | * testing 32 | */ 33 | memory: stores.memory({}) 34 | }, 35 | }) 36 | 37 | export default limiterConfig 38 | 39 | declare module '@adonisjs/limiter/types' { 40 | export interface LimitersList extends InferLimiters {} 41 | } 42 | -------------------------------------------------------------------------------- /stubs/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { getDirname } from '@adonisjs/core/helpers' 11 | 12 | export const stubsRoot = getDirname(import.meta.url) 13 | -------------------------------------------------------------------------------- /stubs/make/migration/rate_limits.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ 3 | to: app.makePath(migration.folder, entity.path, migration.fileName) 4 | }) 5 | }}} 6 | import { BaseSchema } from '@adonisjs/lucid/schema' 7 | 8 | export default class extends BaseSchema { 9 | protected tableName = 'rate_limits' 10 | 11 | async up() { 12 | this.schema.createTable(this.tableName, (table) => { 13 | table.string('key', 255).notNullable().primary() 14 | table.integer('points', 9).notNullable().defaultTo(0) 15 | table.bigint('expire').unsigned() 16 | }) 17 | } 18 | 19 | async down() { 20 | this.schema.dropTable(this.tableName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stubs/start/limiter.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.startPath('limiter.ts') }) 3 | }}} 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Define HTTP limiters 7 | |-------------------------------------------------------------------------- 8 | | 9 | | The "limiter.define" method creates an HTTP middleware to apply rate 10 | | limits on a route or a group of routes. Feel free to define as many 11 | | throttle middleware as needed. 12 | | 13 | */ 14 | 15 | import limiter from '@adonisjs/limiter/services/main' 16 | 17 | export const throttle = limiter.define('global', () => { 18 | return limiter.allowRequests(10).every('1 minute') 19 | }) 20 | -------------------------------------------------------------------------------- /tests/configure.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import timekeeper from 'timekeeper' 11 | import { test } from '@japa/runner' 12 | import { fileURLToPath } from 'node:url' 13 | import { IgnitorFactory } from '@adonisjs/core/factories' 14 | import Configure from '@adonisjs/core/commands/configure' 15 | 16 | const BASE_URL = new URL('../tmp/', import.meta.url) 17 | 18 | test.group('Configure', (group) => { 19 | group.each.setup(({ context }) => { 20 | context.fs.baseUrl = BASE_URL 21 | context.fs.basePath = fileURLToPath(BASE_URL) 22 | }) 23 | 24 | group.each.timeout(0) 25 | 26 | test('publish provider and env variables', async ({ assert, fs }) => { 27 | const ignitor = new IgnitorFactory() 28 | .withCoreProviders() 29 | .withCoreConfig() 30 | .create(fs.baseUrl, { 31 | importer: (filePath) => { 32 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 33 | return import(new URL(filePath, fs.baseUrl).href) 34 | } 35 | 36 | return import(filePath) 37 | }, 38 | }) 39 | 40 | const app = ignitor.createApp('console') 41 | await app.init() 42 | await app.boot() 43 | 44 | await fs.create('.env', '') 45 | await fs.createJson('tsconfig.json', {}) 46 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) 47 | await fs.create('adonisrc.ts', `export default defineConfig({})`) 48 | 49 | const ace = await app.container.make('ace') 50 | ace.prompt 51 | .trap('Select the storage layer you want to use') 52 | .assertFails('', 'Please select a store') 53 | .assertPasses('redis') 54 | .chooseOption(1) 55 | 56 | const command = await ace.create(Configure, ['../index.js']) 57 | await command.exec() 58 | 59 | await assert.fileExists('config/limiter.ts') 60 | await assert.fileExists('start/limiter.ts') 61 | await assert.fileContains('adonisrc.ts', '@adonisjs/limiter/limiter_provider') 62 | 63 | await assert.fileContains('config/limiter.ts', [ 64 | ` default: env.get('LIMITER_STORE'),`, 65 | `redis: stores.redis`, 66 | `memory: stores.memory`, 67 | ]) 68 | await assert.fileContains('.env', 'LIMITER_STORE') 69 | await assert.fileContains( 70 | 'start/env.ts', 71 | `LIMITER_STORE: Env.schema.enum(['redis', 'memory'] as const)` 72 | ) 73 | }) 74 | 75 | test('configure using the --store CLI flag', async ({ assert, fs }) => { 76 | const ignitor = new IgnitorFactory() 77 | .withCoreProviders() 78 | .withCoreConfig() 79 | .create(fs.baseUrl, { 80 | importer: (filePath) => { 81 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 82 | return import(new URL(filePath, fs.baseUrl).href) 83 | } 84 | 85 | return import(filePath) 86 | }, 87 | }) 88 | 89 | const app = ignitor.createApp('console') 90 | await app.init() 91 | await app.boot() 92 | 93 | await fs.create('.env', '') 94 | await fs.createJson('tsconfig.json', {}) 95 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) 96 | await fs.create('adonisrc.ts', `export default defineConfig({})`) 97 | 98 | const ace = await app.container.make('ace') 99 | const command = await ace.create(Configure, ['../index.js', '--store=database']) 100 | await command.exec() 101 | 102 | await assert.fileExists('config/limiter.ts') 103 | await assert.fileContains('adonisrc.ts', '@adonisjs/limiter/limiter_provider') 104 | 105 | await assert.fileContains('config/limiter.ts', [ 106 | ` default: env.get('LIMITER_STORE'),`, 107 | `database: stores.database`, 108 | `memory: stores.memory`, 109 | ]) 110 | await assert.fileContains('.env', 'LIMITER_STORE') 111 | await assert.fileContains( 112 | 'start/env.ts', 113 | `LIMITER_STORE: Env.schema.enum(['database', 'memory'] as const)` 114 | ) 115 | }) 116 | 117 | test('throw error when select store is invalid', async ({ fs }) => { 118 | const ignitor = new IgnitorFactory() 119 | .withCoreProviders() 120 | .withCoreConfig() 121 | .create(fs.baseUrl, { 122 | importer: (filePath) => { 123 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 124 | return import(new URL(filePath, fs.baseUrl).href) 125 | } 126 | 127 | return import(filePath) 128 | }, 129 | }) 130 | 131 | const app = ignitor.createApp('console') 132 | await app.init() 133 | await app.boot() 134 | 135 | await fs.create('.env', '') 136 | await fs.createJson('tsconfig.json', {}) 137 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) 138 | await fs.create('adonisrc.ts', `export default defineConfig({})`) 139 | 140 | const ace = await app.container.make('ace') 141 | ace.ui.switchMode('raw') 142 | const command = await ace.create(Configure, ['../index.js', '--store=foo']) 143 | await command.exec() 144 | 145 | command.assertFailed() 146 | command.assertLog('Invalid limiter store "foo". Supported stores are: database and redis') 147 | }) 148 | 149 | test('create migration file when database store is used', async ({ assert, fs }) => { 150 | timekeeper.freeze() 151 | 152 | const ignitor = new IgnitorFactory() 153 | .withCoreProviders() 154 | .withCoreConfig() 155 | .create(fs.baseUrl, { 156 | importer: (filePath) => { 157 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 158 | return import(new URL(filePath, fs.baseUrl).href) 159 | } 160 | 161 | return import(filePath) 162 | }, 163 | }) 164 | 165 | const app = ignitor.createApp('console') 166 | await app.init() 167 | await app.boot() 168 | 169 | await fs.create('.env', '') 170 | await fs.createJson('tsconfig.json', {}) 171 | await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) 172 | await fs.create('adonisrc.ts', `export default defineConfig({})`) 173 | 174 | const ace = await app.container.make('ace') 175 | const command = await ace.create(Configure, ['../index.js', '--store=database']) 176 | await command.exec() 177 | 178 | await assert.fileExists( 179 | `database/migrations/${new Date().getTime()}_create_rate_limits_table.ts` 180 | ) 181 | }) 182 | }) 183 | -------------------------------------------------------------------------------- /tests/define_config.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { RedisService } from '@adonisjs/redis/types' 12 | import { ApplicationService } from '@adonisjs/core/types' 13 | import { AppFactory } from '@adonisjs/core/factories/app' 14 | 15 | import { Limiter } from '../src/limiter.js' 16 | import LimiterRedisStore from '../src/stores/redis.js' 17 | import LimiterMemoryStore from '../src/stores/memory.js' 18 | import { LimiterManager } from '../src/limiter_manager.js' 19 | import LimiterDatabaseStore from '../src/stores/database.js' 20 | import { defineConfig, stores } from '../src/define_config.js' 21 | import type { LimiterConsumptionOptions } from '../src/types.js' 22 | import { createDatabase, createRedis, createTables } from './helpers.js' 23 | 24 | test.group('Define config', () => { 25 | test('define redis store', async ({ assert }) => { 26 | const redis = createRedis() as unknown as RedisService 27 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService 28 | await app.init() 29 | 30 | app.container.singleton('redis', () => redis) 31 | const redisProvider = stores.redis({ 32 | connectionName: 'main', 33 | }) 34 | 35 | const storeFactory = await redisProvider.resolver(app) 36 | const store = storeFactory({ duration: '1mins', requests: 5 }) 37 | assert.instanceOf(store, LimiterRedisStore) 38 | assert.isNull(await store.get('ip_localhost')) 39 | }) 40 | 41 | test('define database store', async ({ assert }) => { 42 | const database = createDatabase() 43 | await createTables(database) 44 | 45 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService 46 | await app.init() 47 | 48 | app.container.singleton('lucid.db', () => database) 49 | const dbProvider = stores.database({ 50 | connectionName: process.env.DB as any, 51 | dbName: 'limiter', 52 | tableName: 'rate_limits', 53 | }) 54 | 55 | const storeFactory = await dbProvider.resolver(app) 56 | const store = storeFactory({ duration: '1mins', requests: 5 }) 57 | assert.instanceOf(store, LimiterDatabaseStore) 58 | assert.isNull(await store.get('ip_localhost')) 59 | }) 60 | 61 | test('use default database when no explicit database is configured', async ({ assert }) => { 62 | const database = createDatabase() 63 | await createTables(database) 64 | 65 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService 66 | await app.init() 67 | 68 | app.container.singleton('lucid.db', () => database) 69 | const dbProvider = stores.database({ 70 | tableName: 'rate_limits', 71 | }) 72 | 73 | const storeFactory = await dbProvider.resolver(app) 74 | const store = storeFactory({ duration: '1mins', requests: 5 }) 75 | assert.instanceOf(store, LimiterDatabaseStore) 76 | assert.isNull(await store.get('ip_localhost')) 77 | }) 78 | 79 | test('throw error when unregistered db connection is used', async () => { 80 | const database = createDatabase() 81 | await createTables(database) 82 | 83 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService 84 | await app.init() 85 | 86 | app.container.singleton('lucid.db', () => database) 87 | const dbProvider = stores.database({ 88 | connectionName: 'foo', 89 | tableName: 'rate_limits', 90 | }) 91 | 92 | await dbProvider.resolver(app) 93 | }).throws( 94 | 'Invalid connection name "foo" referenced by "config/limiter.ts" file. First register the connection inside "config/database.ts" file' 95 | ) 96 | 97 | test('define memory store', async ({ assert }) => { 98 | const storeFactory = stores.memory({}) 99 | const store = storeFactory({ duration: '1mins', requests: 5 }) 100 | assert.instanceOf(store, LimiterMemoryStore) 101 | assert.isNull(await store.get('ip_localhost')) 102 | }) 103 | 104 | test('throw error when config is invalid', async ({ assert }) => { 105 | const redis = createRedis() as unknown as RedisService 106 | const database = createDatabase() 107 | await createTables(database) 108 | 109 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService 110 | await app.init() 111 | 112 | app.container.singleton('redis', () => redis) 113 | app.container.singleton('lucid.db', () => database) 114 | 115 | assert.throws( 116 | () => 117 | defineConfig({ 118 | // @ts-expect-error 119 | default: 'redis', 120 | stores: {}, 121 | }), 122 | 'Missing "stores.redis" in limiter config. It is referenced by the "default" property' 123 | ) 124 | 125 | assert.throws( 126 | // @ts-expect-error 127 | () => defineConfig({}), 128 | 'Missing "stores" property in limiter config' 129 | ) 130 | 131 | assert.throws( 132 | // @ts-expect-error 133 | () => defineConfig({ stores: {} }), 134 | 'Missing "default" store in limiter config' 135 | ) 136 | }) 137 | 138 | test('create manager from define config output', async ({ assert, expectTypeOf }) => { 139 | const redis = createRedis() as unknown as RedisService 140 | const database = createDatabase() 141 | await createTables(database) 142 | 143 | const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService 144 | await app.init() 145 | 146 | app.container.singleton('redis', () => redis) 147 | app.container.singleton('lucid.db', () => database) 148 | 149 | const config = defineConfig({ 150 | default: 'redis', 151 | stores: { 152 | redis: stores.redis({ 153 | connectionName: 'main', 154 | }), 155 | db: stores.database({ 156 | connectionName: process.env.DB as any, 157 | dbName: 'limiter', 158 | tableName: 'rate_limits', 159 | }), 160 | memory: stores.memory({}), 161 | }, 162 | }) 163 | 164 | const limiter = new LimiterManager(await config.resolver(app)) 165 | expectTypeOf(limiter.use).parameters.toEqualTypeOf< 166 | [LimiterConsumptionOptions] | ['redis' | 'db' | 'memory', LimiterConsumptionOptions] 167 | >() 168 | expectTypeOf(limiter.use).returns.toEqualTypeOf() 169 | 170 | assert.isNull( 171 | await limiter.use('redis', { duration: '1 min', requests: 5 }).get('ip_localhost') 172 | ) 173 | assert.isNull(await limiter.use('db', { duration: '1 min', requests: 5 }).get('ip_localhost')) 174 | assert.isNull( 175 | await limiter.use('memory', { duration: '1 min', requests: 5 }).get('ip_localhost') 176 | ) 177 | }) 178 | }) 179 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { configDotenv } from 'dotenv' 12 | import { getActiveTest } from '@japa/runner' 13 | import { Emitter } from '@adonisjs/core/events' 14 | import { Database } from '@adonisjs/lucid/database' 15 | import { AppFactory } from '@adonisjs/core/factories/app' 16 | import { RedisConnection, RedisManager } from '@adonisjs/redis' 17 | import { LoggerFactory } from '@adonisjs/core/factories/logger' 18 | 19 | configDotenv() 20 | 21 | declare module '@adonisjs/redis/types' { 22 | interface RedisConnections { 23 | main: RedisConnection 24 | } 25 | } 26 | 27 | /** 28 | * Creates an instance of the database class for making queries 29 | */ 30 | export function createDatabase() { 31 | const test = getActiveTest() 32 | if (!test) { 33 | throw new Error('Cannot use "createDatabase" outside of a Japa test') 34 | } 35 | 36 | const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) 37 | const logger = new LoggerFactory().create() 38 | const emitter = new Emitter(app) 39 | const db = new Database( 40 | { 41 | connection: process.env.DB || 'pg', 42 | connections: { 43 | sqlite: { 44 | client: 'better-sqlite3', 45 | connection: { 46 | filename: ':memory:', 47 | }, 48 | }, 49 | libsql: { 50 | client: 'libsql', 51 | connection: { 52 | filename: join(test.context.fs.basePath, `file:libsql.db`), 53 | }, 54 | }, 55 | pg: { 56 | client: 'pg', 57 | connection: { 58 | host: process.env.PG_HOST as string, 59 | port: Number(process.env.PG_PORT), 60 | database: process.env.PG_DATABASE as string, 61 | user: process.env.PG_USER as string, 62 | password: process.env.PG_PASSWORD as string, 63 | }, 64 | }, 65 | mysql: { 66 | client: 'mysql2', 67 | connection: { 68 | host: process.env.MYSQL_HOST as string, 69 | port: Number(process.env.MYSQL_PORT), 70 | database: process.env.MYSQL_DATABASE as string, 71 | user: process.env.MYSQL_USER as string, 72 | password: process.env.MYSQL_PASSWORD as string, 73 | }, 74 | }, 75 | }, 76 | }, 77 | logger, 78 | emitter 79 | ) 80 | 81 | test.cleanup(() => db.manager.closeAll()) 82 | return db 83 | } 84 | 85 | /** 86 | * Creates redis manager instance to execute redis 87 | * commands 88 | */ 89 | export function createRedis(keysToClear?: string[]) { 90 | const test = getActiveTest() 91 | if (!test) { 92 | throw new Error('Cannot use "createDatabase" outside of a Japa test') 93 | } 94 | 95 | const logger = new LoggerFactory().create() 96 | const redis = new RedisManager( 97 | { 98 | connection: 'main', 99 | connections: { 100 | main: { 101 | host: process.env.REDIS_HOST || '0.0.0.0', 102 | port: process.env.REDIS_PORT || 6379, 103 | }, 104 | }, 105 | }, 106 | logger 107 | ) 108 | 109 | test.cleanup(async () => { 110 | if (keysToClear) { 111 | await redis.del(...keysToClear) 112 | } 113 | 114 | await redis.disconnectAll() 115 | }) 116 | return redis 117 | } 118 | 119 | /** 120 | * Creates needed database tables 121 | */ 122 | export async function createTables(db: Database) { 123 | const test = getActiveTest() 124 | if (!test) { 125 | throw new Error('Cannot use "createTables" outside of a Japa test') 126 | } 127 | 128 | test.cleanup(async () => { 129 | await db.connection().schema.dropTable('rate_limits') 130 | }) 131 | 132 | await db.connection().schema.createTable('rate_limits', (table) => { 133 | table.string('key', 255).notNullable().primary() 134 | table.integer('points', 9).notNullable().defaultTo(0) 135 | table.bigint('expire').unsigned() 136 | }) 137 | } 138 | -------------------------------------------------------------------------------- /tests/http_limiter.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { HttpContextFactory } from '@adonisjs/core/factories/http' 12 | 13 | import { createRedis } from './helpers.js' 14 | import { HttpLimiter } from '../src/http_limiter.js' 15 | import LimiterRedisStore from '../src/stores/redis.js' 16 | import { LimiterManager } from '../src/limiter_manager.js' 17 | 18 | test.group('Http limiter', () => { 19 | test('define http limiter', async ({ assert }) => { 20 | const redis = createRedis(['rlflx:ip_localhost']).connection() 21 | const limiterManager = new LimiterManager({ 22 | default: 'redis', 23 | stores: { 24 | redis: (options) => new LimiterRedisStore(redis, options), 25 | }, 26 | }) 27 | 28 | const ctx = new HttpContextFactory().create() 29 | ctx.request.ip = function () { 30 | return 'localhost' 31 | } 32 | 33 | const limiter = new HttpLimiter(limiterManager) 34 | limiter.allowRequests(10).every('1 minute').blockFor('20 mins') 35 | 36 | assert.instanceOf(limiter, HttpLimiter) 37 | assert.deepEqual(limiter!.toJSON(), { 38 | duration: '1 minute', 39 | requests: 10, 40 | blockDuration: '20 mins', 41 | store: undefined, 42 | }) 43 | }) 44 | 45 | test('define custom unique key', async ({ assert }) => { 46 | const redis = createRedis(['rlflx:api_1']).connection() 47 | const limiterManager = new LimiterManager({ 48 | default: 'redis', 49 | stores: { 50 | redis: (options) => new LimiterRedisStore(redis, options), 51 | }, 52 | }) 53 | 54 | const limiter = new HttpLimiter(limiterManager) 55 | limiter.allowRequests(10).every('1 minute').usingKey(1) 56 | 57 | assert.instanceOf(limiter, HttpLimiter) 58 | assert.deepEqual(limiter!.toJSON(), { 59 | duration: '1 minute', 60 | requests: 10, 61 | store: undefined, 62 | }) 63 | }) 64 | 65 | test('define named store', async ({ assert }) => { 66 | const redis = createRedis(['rlflx:api_1']).connection() 67 | const limiterManager = new LimiterManager({ 68 | default: 'redis', 69 | stores: { 70 | redis: (options) => new LimiterRedisStore(redis, options), 71 | }, 72 | }) 73 | 74 | const limiter = new HttpLimiter(limiterManager) 75 | limiter.allowRequests(10).every('1 minute').usingKey(1).store('redis') 76 | 77 | assert.instanceOf(limiter, HttpLimiter) 78 | assert.deepEqual(limiter!.toJSON(), { 79 | duration: '1 minute', 80 | requests: 10, 81 | store: 'redis', 82 | }) 83 | }) 84 | 85 | test('throttle requests', async ({ assert }) => { 86 | const redis = createRedis(['rlflx:api_1']).connection() 87 | const limiterManager = new LimiterManager({ 88 | default: 'redis', 89 | stores: { 90 | redis: (options) => new LimiterRedisStore(redis, options), 91 | }, 92 | }) 93 | 94 | const ctx = new HttpContextFactory().create() 95 | const limiter = new HttpLimiter(limiterManager) 96 | limiter.allowRequests(1).every('1 minute').usingKey(1) 97 | 98 | await assert.doesNotReject(() => limiter.throttle('api', ctx)) 99 | await assert.rejects(() => limiter.throttle('api', ctx)) 100 | }) 101 | 102 | test('customize exception', async ({ assert }) => { 103 | assert.plan(2) 104 | 105 | const redis = createRedis(['rlflx:api_1']).connection() 106 | const limiterManager = new LimiterManager({ 107 | default: 'redis', 108 | stores: { 109 | redis: (options) => new LimiterRedisStore(redis, options), 110 | }, 111 | }) 112 | 113 | const ctx = new HttpContextFactory().create() 114 | const limiter = new HttpLimiter(limiterManager) 115 | limiter 116 | .allowRequests(1) 117 | .every('1 minute') 118 | .usingKey(1) 119 | .limitExceeded((error) => { 120 | error.setMessage('Requests exhaused').setStatus(400) 121 | }) 122 | 123 | await limiter.throttle('api', ctx) 124 | try { 125 | await limiter.throttle('api', ctx) 126 | } catch (error) { 127 | assert.equal(error.message, 'Requests exhaused') 128 | assert.equal(error.status, 400) 129 | } 130 | }) 131 | 132 | test('throttle concurrent requests', async ({ assert }) => { 133 | const redis = createRedis(['rlflx:api_1']).connection() 134 | const limiterManager = new LimiterManager({ 135 | default: 'redis', 136 | stores: { 137 | redis: (options) => new LimiterRedisStore(redis, options), 138 | }, 139 | }) 140 | 141 | const ctx = new HttpContextFactory().create() 142 | const limiter = new HttpLimiter(limiterManager) 143 | limiter 144 | .allowRequests(1) 145 | .every('1 minute') 146 | .usingKey(1) 147 | .store('redis') 148 | .limitExceeded((error) => { 149 | error.setMessage('Requests exhaused').setStatus(400) 150 | }) 151 | 152 | const [first, second] = await Promise.allSettled([ 153 | limiter!.throttle('api', ctx), 154 | limiter!.throttle('api', ctx), 155 | ]) 156 | assert.equal(first.status, 'fulfilled') 157 | assert.equal(second.status, 'rejected') 158 | }) 159 | 160 | test('throw error when requests are not configured', async ({ assert }) => { 161 | const redis = createRedis(['rlflx:api_1']).connection() 162 | const limiterManager = new LimiterManager({ 163 | default: 'redis', 164 | stores: { 165 | redis: (options) => new LimiterRedisStore(redis, options), 166 | }, 167 | }) 168 | 169 | const ctx = new HttpContextFactory().create() 170 | 171 | const noRequests = new HttpLimiter(limiterManager) 172 | noRequests.every('1 minute').usingKey(1) 173 | 174 | const noDuration = new HttpLimiter(limiterManager) 175 | noDuration.allowRequests(100).usingKey(1) 176 | 177 | const noConfig = new HttpLimiter(limiterManager) 178 | 179 | await assert.rejects( 180 | async () => noRequests.throttle('api', ctx), 181 | 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration' 182 | ) 183 | await assert.rejects( 184 | async () => noDuration.throttle('api', ctx), 185 | 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration' 186 | ) 187 | await assert.rejects( 188 | async () => noConfig.throttle('api', ctx), 189 | 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration' 190 | ) 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /tests/limiter.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import sinon from 'sinon' 11 | import { test } from '@japa/runner' 12 | 13 | import { createRedis } from './helpers.js' 14 | import { Limiter } from '../src/limiter.js' 15 | import LimiterRedisStore from '../src/stores/redis.js' 16 | import { ThrottleException } from '../src/errors.js' 17 | 18 | test.group('Limiter', () => { 19 | test('proxy store methods', async ({ assert }) => { 20 | const redis = createRedis(['rlflx:ip_localhost']).connection() 21 | const store = new LimiterRedisStore(redis, { 22 | duration: '1 minute', 23 | requests: 5, 24 | }) 25 | 26 | const limiter = new Limiter(store) 27 | 28 | assert.equal(limiter.requests, 5) 29 | assert.equal(limiter.duration, 60) 30 | assert.equal(limiter.name, 'redis') 31 | 32 | /** 33 | * consume call 34 | */ 35 | const consumeCall = sinon.spy(store, 'consume') 36 | await limiter.consume('ip_localhost') 37 | assert.isTrue(consumeCall.calledOnceWithExactly('ip_localhost'), 'consume called') 38 | 39 | /** 40 | * increment call 41 | */ 42 | const incrementCall = sinon.spy(store, 'increment') 43 | await limiter.increment('ip_localhost') 44 | assert.isTrue(incrementCall.calledOnceWithExactly('ip_localhost'), 'increment called') 45 | 46 | /** 47 | * decrement call 48 | */ 49 | const decrementCall = sinon.spy(store, 'decrement') 50 | await limiter.decrement('ip_localhost') 51 | assert.isTrue(decrementCall.calledOnceWithExactly('ip_localhost'), 'decrement called') 52 | 53 | /** 54 | * get call 55 | */ 56 | const getCall = sinon.spy(store, 'get') 57 | await limiter.get('ip_localhost') 58 | assert.isTrue(getCall.calledOnceWithExactly('ip_localhost'), 'get called') 59 | 60 | /** 61 | * set call 62 | */ 63 | const setCall = sinon.spy(store, 'set') 64 | await limiter.set('ip_localhost', 10, '1 minute') 65 | assert.isTrue(setCall.calledOnceWithExactly('ip_localhost', 10, '1 minute'), 'set called') 66 | 67 | /** 68 | * block call 69 | */ 70 | const blockCall = sinon.spy(store, 'block') 71 | await limiter.block('ip_localhost', '2 minutes') 72 | assert.isTrue(blockCall.calledOnceWithExactly('ip_localhost', '2 minutes'), 'block called') 73 | 74 | /** 75 | * delete call 76 | */ 77 | const deleteCall = sinon.spy(store, 'delete') 78 | await limiter.delete('ip_localhost') 79 | assert.isTrue(deleteCall.calledOnceWithExactly('ip_localhost'), 'delete called') 80 | 81 | /** 82 | * Consume call 83 | */ 84 | const deleteInMemoryBlockedKeys = sinon.spy(store, 'deleteInMemoryBlockedKeys') 85 | limiter.deleteInMemoryBlockedKeys() 86 | assert.isTrue( 87 | deleteInMemoryBlockedKeys.calledOnceWithExactly(), 88 | 'deleteInMemoryBlockedKeys called' 89 | ) 90 | }) 91 | 92 | test('increment requests count without throwing an error', async ({ assert }) => { 93 | const redis = createRedis(['rlflx:ip_localhost']).connection() 94 | const store = new LimiterRedisStore(redis, { 95 | duration: '1 minute', 96 | requests: 2, 97 | }) 98 | 99 | const limiter = new Limiter(store) 100 | 101 | await limiter.increment('ip_localhost') 102 | await limiter.increment('ip_localhost') 103 | await assert.doesNotReject(() => limiter.increment('ip_localhost')) 104 | await assert.doesNotReject(() => limiter.increment('ip_localhost')) 105 | }) 106 | 107 | test('do not run action when all requests have been exhausted', async ({ assert }) => { 108 | const executionStack: string[] = [] 109 | const redis = createRedis(['rlflx:ip_localhost']).connection() 110 | const store = new LimiterRedisStore(redis, { 111 | duration: '1 minute', 112 | requests: 2, 113 | }) 114 | 115 | const limiter = new Limiter(store) 116 | 117 | await limiter.attempt('ip_localhost', () => { 118 | executionStack.push('executed 1') 119 | }) 120 | await limiter.attempt('ip_localhost', () => { 121 | executionStack.push('executed 2') 122 | }) 123 | await limiter.attempt('ip_localhost', () => { 124 | executionStack.push('executed 3') 125 | }) 126 | await limiter.attempt('ip_localhost', () => { 127 | executionStack.push('executed 4') 128 | }) 129 | 130 | assert.deepEqual(executionStack, ['executed 1', 'executed 2']) 131 | assert.equal(await limiter.remaining('ip_localhost'), 0) 132 | }) 133 | 134 | test('block key when trying to attempt after exhausting all requests', async ({ assert }) => { 135 | const executionStack: string[] = [] 136 | const redis = createRedis(['rlflx:ip_localhost']).connection() 137 | const store = new LimiterRedisStore(redis, { 138 | duration: '1 minute', 139 | requests: 2, 140 | blockDuration: '30 mins', 141 | }) 142 | 143 | const limiter = new Limiter(store) 144 | 145 | await limiter.attempt('ip_localhost', () => { 146 | executionStack.push('executed 1') 147 | }) 148 | await limiter.attempt('ip_localhost', () => { 149 | executionStack.push('executed 2') 150 | }) 151 | await limiter.attempt('ip_localhost', () => { 152 | executionStack.push('executed 3') 153 | }) 154 | await limiter.attempt('ip_localhost', () => { 155 | executionStack.push('executed 4') 156 | }) 157 | 158 | assert.deepEqual(executionStack, ['executed 1', 'executed 2']) 159 | assert.closeTo(await limiter.availableIn('ip_localhost'), 30 * 60, 5) 160 | }) 161 | 162 | test('get seconds left until the key will be available for new request', async ({ assert }) => { 163 | const redis = createRedis(['rlflx:ip_localhost']).connection() 164 | const store = new LimiterRedisStore(redis, { 165 | duration: '1 minute', 166 | requests: 2, 167 | }) 168 | 169 | const limiter = new Limiter(store) 170 | 171 | /** 172 | * Non-existing key is available right away 173 | */ 174 | assert.equal(await limiter.availableIn('ip_localhost'), 0) 175 | 176 | /** 177 | * Key with pending requests is also available right away 178 | */ 179 | await limiter.increment('ip_localhost') 180 | assert.equal(await limiter.availableIn('ip_localhost'), 0) 181 | 182 | /** 183 | * Exhausted keys have to wait 184 | */ 185 | await limiter.increment('ip_localhost') 186 | assert.closeTo(await limiter.availableIn('ip_localhost'), 60, 5) 187 | }) 188 | 189 | test('get remaining counts of a key', async ({ assert }) => { 190 | const redis = createRedis(['rlflx:ip_localhost']).connection() 191 | const store = new LimiterRedisStore(redis, { 192 | duration: '1 minute', 193 | requests: 2, 194 | }) 195 | 196 | const limiter = new Limiter(store) 197 | 198 | assert.equal(await limiter.remaining('ip_localhost'), 2) 199 | 200 | await limiter.increment('ip_localhost') 201 | assert.equal(await limiter.remaining('ip_localhost'), 1) 202 | 203 | await limiter.increment('ip_localhost') 204 | await limiter.increment('ip_localhost') 205 | await limiter.increment('ip_localhost') 206 | assert.equal(await limiter.remaining('ip_localhost'), 0) 207 | }) 208 | 209 | test('check if a key has exhausted all attempts', async ({ assert }) => { 210 | const redis = createRedis(['rlflx:ip_localhost']).connection() 211 | const store = new LimiterRedisStore(redis, { 212 | duration: '1 minute', 213 | requests: 2, 214 | }) 215 | 216 | const limiter = new Limiter(store) 217 | 218 | assert.isFalse(await limiter.isBlocked('ip_localhost')) 219 | 220 | await limiter.increment('ip_localhost') 221 | assert.isFalse(await limiter.isBlocked('ip_localhost')) 222 | 223 | await limiter.increment('ip_localhost') 224 | assert.isTrue(await limiter.isBlocked('ip_localhost')) 225 | }) 226 | 227 | test('consume point when the provided callback throws exception', async ({ assert }) => { 228 | const redis = createRedis(['rlflx:ip_localhost']).connection() 229 | const store = new LimiterRedisStore(redis, { 230 | duration: '1 minute', 231 | requests: 2, 232 | }) 233 | 234 | const limiter = new Limiter(store) 235 | 236 | await assert.rejects(async () => { 237 | await limiter.penalize('ip_localhost', () => { 238 | throw new Error('Something went wrong') 239 | }) 240 | }, 'Something went wrong') 241 | assert.equal(await limiter.remaining('ip_localhost'), 1) 242 | 243 | const [, result] = await limiter.penalize('ip_localhost', () => { 244 | return true 245 | }) 246 | assert.isTrue(result) 247 | 248 | assert.isNull(await limiter.get('ip_localhost')) 249 | }) 250 | 251 | test('return error via penalize when all requests has been exhausted', async ({ 252 | assert, 253 | expectTypeOf, 254 | }) => { 255 | const redis = createRedis(['rlflx:ip_localhost']).connection() 256 | const store = new LimiterRedisStore(redis, { 257 | duration: '1 minute', 258 | requests: 2, 259 | }) 260 | 261 | const limiter = new Limiter(store) 262 | 263 | await assert.rejects(async () => { 264 | await limiter.penalize('ip_localhost', () => { 265 | throw new Error('Something went wrong') 266 | }) 267 | }, 'Something went wrong') 268 | 269 | await assert.rejects(async () => { 270 | await limiter.penalize('ip_localhost', () => { 271 | throw new Error('Something went wrong') 272 | }) 273 | }, 'Something went wrong') 274 | 275 | const [error, user] = await limiter.penalize('ip_localhost', () => { 276 | return { 277 | id: 1, 278 | } 279 | }) 280 | 281 | if (error) { 282 | expectTypeOf(error).toEqualTypeOf() 283 | expectTypeOf(user).toEqualTypeOf() 284 | } else { 285 | expectTypeOf(user).toEqualTypeOf<{ id: number }>() 286 | expectTypeOf(error).toEqualTypeOf() 287 | } 288 | 289 | assert.instanceOf(error, ThrottleException) 290 | assert.equal(error?.response.remaining, 0) 291 | assert.equal(await limiter.remaining('ip_localhost'), 0) 292 | assert.closeTo(await limiter.availableIn('ip_localhost'), 60, 5) 293 | }) 294 | 295 | test('block key when all requests have been exhausted', async ({ assert }) => { 296 | const redis = createRedis(['rlflx:ip_localhost']).connection() 297 | const store = new LimiterRedisStore(redis, { 298 | duration: '1 minute', 299 | requests: 2, 300 | blockDuration: '30 mins', 301 | }) 302 | 303 | const limiter = new Limiter(store) 304 | 305 | await assert.rejects(async () => { 306 | await limiter.penalize('ip_localhost', () => { 307 | throw new Error('Something went wrong') 308 | }) 309 | }, 'Something went wrong') 310 | 311 | await assert.rejects(async () => { 312 | await limiter.penalize('ip_localhost', () => { 313 | throw new Error('Something went wrong') 314 | }) 315 | }, 'Something went wrong') 316 | 317 | assert.closeTo(await limiter.availableIn('ip_localhost'), 60 * 30, 5) 318 | }) 319 | }) 320 | -------------------------------------------------------------------------------- /tests/limiter_manager.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { createRedis } from './helpers.js' 13 | import { Limiter } from '../src/limiter.js' 14 | import LimiterRedisStore from '../src/stores/redis.js' 15 | import { LimiterManager } from '../src/limiter_manager.js' 16 | import LimiterMemoryStore from '../src/stores/memory.js' 17 | 18 | test.group('Limiter manager', () => { 19 | test('create limiter instances using manager', async ({ assert }) => { 20 | const redis = createRedis(['rlflx:ip_localhost']).connection() 21 | const limiterManager = new LimiterManager({ 22 | default: 'redis', 23 | stores: { 24 | redis: (options) => new LimiterRedisStore(redis, options), 25 | }, 26 | }) 27 | 28 | const limiter = limiterManager.use('redis', { requests: 10, duration: '2 minutes' }) 29 | assert.instanceOf(limiter, Limiter) 30 | 31 | const response = await limiter.consume('ip_localhost') 32 | assert.containsSubset(response.toJSON(), { 33 | limit: 10, 34 | remaining: 9, 35 | consumed: 1, 36 | }) 37 | assert.closeTo(response.availableIn, 120, 5) 38 | }) 39 | 40 | test('re-use instances as long as all options are the same', async ({ assert }) => { 41 | const redis = createRedis(['rlflx:ip_localhost']).connection() 42 | const limiterManager = new LimiterManager({ 43 | default: 'redis', 44 | stores: { 45 | redis: (options) => new LimiterRedisStore(redis, options), 46 | }, 47 | }) 48 | 49 | assert.strictEqual( 50 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), 51 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }) 52 | ) 53 | assert.strictEqual( 54 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), 55 | limiterManager.use({ requests: 10, duration: '2 minutes' }) 56 | ) 57 | assert.notStrictEqual( 58 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), 59 | limiterManager.use('redis', { requests: 10, duration: '1 minute' }) 60 | ) 61 | assert.notStrictEqual( 62 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), 63 | limiterManager.use('redis', { requests: 5, duration: '2 minutes' }) 64 | ) 65 | assert.notStrictEqual( 66 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), 67 | limiterManager.use('redis', { requests: 10, duration: '2 minutes', blockDuration: '2 mins' }) 68 | ) 69 | assert.notStrictEqual( 70 | limiterManager.use('redis', { requests: 10, duration: '2 minutes' }), 71 | limiterManager.use('redis', { 72 | requests: 10, 73 | duration: '2 minutes', 74 | inMemoryBlockOnConsumed: 12, 75 | inMemoryBlockDuration: '2 minutes', 76 | }) 77 | ) 78 | }) 79 | 80 | test('throw error when no options are provided', async ({ assert }) => { 81 | const redis = createRedis(['rlflx:ip_localhost']).connection() 82 | const limiterManager = new LimiterManager({ 83 | default: 'redis', 84 | stores: { 85 | redis: (options) => new LimiterRedisStore(redis, options), 86 | }, 87 | }) 88 | 89 | assert.throws( 90 | // @ts-expect-error 91 | () => limiterManager.use('redis'), 92 | 'Specify the number of allowed requests and duration to create a limiter' 93 | ) 94 | assert.throws( 95 | // @ts-expect-error 96 | () => limiterManager.use(), 97 | 'Specify the number of allowed requests and duration to create a limiter' 98 | ) 99 | }) 100 | 101 | test('clear all stores', async ({ assert }) => { 102 | const redis = createRedis(['rlflx:ip_localhost', 'rlflx:id_1']).connection() 103 | 104 | const limiterManager = new LimiterManager({ 105 | default: 'redis', 106 | stores: { 107 | redis: (options) => new LimiterRedisStore(redis, options), 108 | memory: (options) => new LimiterMemoryStore(options), 109 | }, 110 | }) 111 | 112 | const global = limiterManager.use('redis', { duration: 60, requests: 2 }) 113 | const user = limiterManager.use('redis', { duration: 60, requests: 4 }) 114 | 115 | const memoryGlobal = limiterManager.use('memory', { duration: 60, requests: 2 }) 116 | const memoryUser = limiterManager.use('memory', { duration: 60, requests: 4 }) 117 | 118 | await global.consume('ip_localhost') 119 | await user.consume('id_1') 120 | await memoryGlobal.consume('ip_localhost') 121 | await memoryUser.consume('id_1') 122 | 123 | assert.equal(await global.remaining('ip_localhost'), 1) 124 | assert.equal(await user.remaining('id_1'), 3) 125 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 1) 126 | assert.equal(await memoryUser.remaining('id_1'), 3) 127 | 128 | await limiterManager.clear() 129 | assert.equal(await global.remaining('ip_localhost'), 2) 130 | assert.equal(await user.remaining('id_1'), 4) 131 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 2) 132 | assert.equal(await memoryUser.remaining('id_1'), 4) 133 | }) 134 | 135 | test('clear selected stores', async ({ assert }) => { 136 | const redis = createRedis(['rlflx:ip_localhost', 'rlflx:id_1']).connection() 137 | 138 | const limiterManager = new LimiterManager({ 139 | default: 'redis', 140 | stores: { 141 | redis: (options) => new LimiterRedisStore(redis, options), 142 | memory: (options) => new LimiterMemoryStore(options), 143 | }, 144 | }) 145 | 146 | const global = limiterManager.use('redis', { duration: 60, requests: 2 }) 147 | const user = limiterManager.use('redis', { duration: 60, requests: 4 }) 148 | 149 | const memoryGlobal = limiterManager.use('memory', { duration: 60, requests: 2 }) 150 | const memoryUser = limiterManager.use('memory', { duration: 60, requests: 4 }) 151 | 152 | await global.consume('ip_localhost') 153 | await user.consume('id_1') 154 | await memoryGlobal.consume('ip_localhost') 155 | await memoryUser.consume('id_1') 156 | 157 | assert.equal(await global.remaining('ip_localhost'), 1) 158 | assert.equal(await user.remaining('id_1'), 3) 159 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 1) 160 | assert.equal(await memoryUser.remaining('id_1'), 3) 161 | 162 | await limiterManager.clear(['redis']) 163 | assert.equal(await global.remaining('ip_localhost'), 2) 164 | assert.equal(await user.remaining('id_1'), 4) 165 | assert.equal(await memoryGlobal.remaining('ip_localhost'), 1) 166 | assert.equal(await memoryUser.remaining('id_1'), 3) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /tests/limiter_provider.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { IgnitorFactory } from '@adonisjs/core/factories' 12 | import type { RedisService } from '@adonisjs/redis/types' 13 | 14 | import { createRedis } from './helpers.js' 15 | import { LimiterManager, defineConfig, stores } from '../index.js' 16 | 17 | const BASE_URL = new URL('./tmp/', import.meta.url) 18 | const IMPORTER = (filePath: string) => { 19 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 20 | return import(new URL(filePath, BASE_URL).href) 21 | } 22 | return import(filePath) 23 | } 24 | 25 | test.group('Limiter provider', () => { 26 | test('register limiter provider', async ({ assert }) => { 27 | const redis = createRedis() as unknown as RedisService 28 | 29 | const ignitor = new IgnitorFactory() 30 | .merge({ 31 | rcFileContents: { 32 | providers: [() => import('../providers/limiter_provider.js')], 33 | }, 34 | }) 35 | .withCoreConfig() 36 | .withCoreProviders() 37 | .merge({ 38 | config: { 39 | limiter: defineConfig({ 40 | default: 'redis', 41 | stores: { 42 | redis: stores.redis({ 43 | connectionName: 'main', 44 | }), 45 | }, 46 | }), 47 | }, 48 | }) 49 | .create(BASE_URL, { 50 | importer: IMPORTER, 51 | }) 52 | 53 | const app = ignitor.createApp('web') 54 | await app.init() 55 | app.container.singleton('redis', () => redis) 56 | await app.boot() 57 | 58 | assert.instanceOf(await app.container.make('limiter.manager'), LimiterManager) 59 | }) 60 | 61 | test('throw error when config is invalid', async () => { 62 | const redis = createRedis() as unknown as RedisService 63 | 64 | const ignitor = new IgnitorFactory() 65 | .merge({ 66 | rcFileContents: { 67 | providers: [() => import('../providers/limiter_provider.js')], 68 | }, 69 | }) 70 | .withCoreConfig() 71 | .withCoreProviders() 72 | .merge({ 73 | config: { 74 | limiter: {}, 75 | }, 76 | }) 77 | .create(BASE_URL, { 78 | importer: IMPORTER, 79 | }) 80 | 81 | const app = ignitor.createApp('web') 82 | await app.init() 83 | app.container.singleton('redis', () => redis) 84 | await app.boot() 85 | 86 | await app.container.make('limiter.manager') 87 | }).throws('Invalid "config/limiter.ts" file. Make sure you are using the "defineConfig" method') 88 | }) 89 | -------------------------------------------------------------------------------- /tests/stores/database.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { LimiterResponse } from '../../src/response.js' 12 | import { E_TOO_MANY_REQUESTS } from '../../src/errors.js' 13 | import { createDatabase, createTables } from '../helpers.js' 14 | import LimiterDatabaseStore from '../../src/stores/database.js' 15 | 16 | test.group('Limiter database store | wrapper', () => { 17 | test('throw error when trying to use connection other than mysql, sqlite or pg', async () => { 18 | const db = createDatabase() 19 | await createTables(db) 20 | 21 | new LimiterDatabaseStore(db.connection('libsql'), { 22 | dbName: 'limiter', 23 | tableName: 'rate_limits', 24 | duration: '1 minute', 25 | requests: 5, 26 | }) 27 | }).throws( 28 | 'Unsupported database "libsql". The limiter can only work with PostgreSQL, MySQL, and SQLite databases' 29 | ) 30 | 31 | test('define readonly properties', async ({ assert }) => { 32 | const db = createDatabase() 33 | await createTables(db) 34 | 35 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 36 | dbName: 'limiter', 37 | tableName: 'rate_limits', 38 | duration: '1 minute', 39 | requests: 5, 40 | }) 41 | 42 | assert.equal(store.name, 'database') 43 | assert.equal(store.requests, 5) 44 | assert.equal(store.duration, 60) 45 | }) 46 | }) 47 | 48 | test.group('Limiter database store | wrapper | consume', () => { 49 | test('consume points using the database store', async ({ assert }) => { 50 | const db = createDatabase() 51 | await createTables(db) 52 | 53 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 54 | dbName: 'limiter', 55 | tableName: 'rate_limits', 56 | duration: '1 minute', 57 | requests: 5, 58 | }) 59 | 60 | const response = await store.consume('ip_localhost') 61 | assert.instanceOf(response, LimiterResponse) 62 | assert.containsSubset(response.toJSON(), { 63 | limit: 5, 64 | remaining: 4, 65 | consumed: 1, 66 | }) 67 | assert.closeTo(response.availableIn, 60, 5) 68 | }) 69 | 70 | test('throw error when no points are left', async ({ assert }) => { 71 | const db = createDatabase() 72 | await createTables(db) 73 | 74 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 75 | dbName: 'limiter', 76 | tableName: 'rate_limits', 77 | duration: '1 minute', 78 | requests: 1, 79 | }) 80 | 81 | await store.consume('ip_localhost') 82 | try { 83 | await store.consume('ip_localhost') 84 | } catch (error) { 85 | assert.instanceOf(error, E_TOO_MANY_REQUESTS) 86 | assert.containsSubset(error.response.toJSON(), { 87 | limit: 1, 88 | remaining: 0, 89 | consumed: 2, 90 | }) 91 | assert.closeTo(error.response.availableIn, 60, 5) 92 | } 93 | }) 94 | 95 | test('block key when all points have been consumed', async ({ assert }) => { 96 | const db = createDatabase() 97 | await createTables(db) 98 | 99 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 100 | dbName: 'limiter', 101 | tableName: 'rate_limits', 102 | duration: '1 minute', 103 | requests: 1, 104 | blockDuration: '2 minutes', 105 | }) 106 | 107 | await store.consume('ip_localhost') 108 | try { 109 | await store.consume('ip_localhost') 110 | } catch (error) { 111 | assert.instanceOf(error, E_TOO_MANY_REQUESTS) 112 | assert.containsSubset(error.response.toJSON(), { 113 | limit: 1, 114 | remaining: 0, 115 | consumed: 2, 116 | }) 117 | assert.closeTo(error.response.availableIn, 120, 5) 118 | } 119 | }) 120 | 121 | test('increment request counter even when the key has consumed all requests', async ({ 122 | assert, 123 | }) => { 124 | const db = createDatabase() 125 | await createTables(db) 126 | 127 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 128 | dbName: 'limiter', 129 | tableName: 'rate_limits', 130 | duration: '1 minute', 131 | requests: 2, 132 | }) 133 | 134 | await store.consume('ip_localhost') 135 | await store.consume('ip_localhost') 136 | await assert.rejects(() => store.consume('ip_localhost')) 137 | await assert.rejects(() => store.consume('ip_localhost')) 138 | 139 | const response = await store.get('ip_localhost') 140 | assert.instanceOf(response, LimiterResponse) 141 | assert.equal(response!.consumed, 4) 142 | }) 143 | 144 | test('do not increment request counter when blocking keys in memory', async ({ assert }) => { 145 | const db = createDatabase() 146 | await createTables(db) 147 | 148 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 149 | dbName: 'limiter', 150 | tableName: 'rate_limits', 151 | duration: '1 minute', 152 | inMemoryBlockOnConsumed: 2, 153 | inMemoryBlockDuration: '1 minute', 154 | requests: 2, 155 | }) 156 | 157 | await store.consume('ip_localhost') 158 | await store.consume('ip_localhost') 159 | await assert.rejects(() => store.consume('ip_localhost')) 160 | await assert.rejects(() => store.consume('ip_localhost')) 161 | await assert.rejects(() => store.consume('ip_localhost')) 162 | await assert.rejects(() => store.consume('ip_localhost')) 163 | 164 | const response = await store.get('ip_localhost') 165 | assert.instanceOf(response, LimiterResponse) 166 | assert.equal(response!.consumed, 3) 167 | }) 168 | 169 | test('reset in memory blocked keys', async ({ assert }) => { 170 | const db = createDatabase() 171 | await createTables(db) 172 | 173 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 174 | dbName: 'limiter', 175 | tableName: 'rate_limits', 176 | duration: '1 minute', 177 | inMemoryBlockOnConsumed: 2, 178 | inMemoryBlockDuration: '1 minute', 179 | requests: 2, 180 | }) 181 | 182 | await store.consume('ip_localhost') 183 | await store.consume('ip_localhost') 184 | await assert.rejects(() => store.consume('ip_localhost')) 185 | await assert.rejects(() => store.consume('ip_localhost')) 186 | await assert.rejects(() => store.consume('ip_localhost')) 187 | 188 | const response = await store.get('ip_localhost') 189 | assert.equal(response!.consumed, 3) 190 | 191 | store.deleteInMemoryBlockedKeys() 192 | await assert.rejects(() => store.consume('ip_localhost')) 193 | 194 | const freshResponse = await store.get('ip_localhost') 195 | assert.equal(freshResponse!.consumed, 4) 196 | }) 197 | 198 | test('throw error when no database table does not exists', async ({ assert }) => { 199 | const db = createDatabase() 200 | await createTables(db) 201 | 202 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 203 | dbName: 'limiter', 204 | tableName: 'foo', 205 | duration: '1 minute', 206 | requests: 1, 207 | }) 208 | 209 | try { 210 | await store.consume('ip_localhost') 211 | } catch (error) { 212 | assert.match( 213 | error.message, 214 | /relation "foo" does not exist|Table 'limiter.foo' doesn't exist|no such table: foo/ 215 | ) 216 | } 217 | }) 218 | }) 219 | 220 | test.group('Limiter database store | wrapper | get', () => { 221 | test('get response for a pre-existing key', async ({ assert }) => { 222 | const db = createDatabase() 223 | await createTables(db) 224 | 225 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 226 | dbName: 'limiter', 227 | tableName: 'rate_limits', 228 | duration: '1 minute', 229 | requests: 5, 230 | }) 231 | 232 | await store.consume('ip_localhost') 233 | const response = await store.get('ip_localhost') 234 | assert.instanceOf(response, LimiterResponse) 235 | assert.containsSubset(response!.toJSON(), { 236 | limit: 5, 237 | remaining: 4, 238 | consumed: 1, 239 | }) 240 | assert.closeTo(response!.availableIn, 60, 5) 241 | }) 242 | 243 | test('return null when key does not exists', async ({ assert }) => { 244 | const db = createDatabase() 245 | await createTables(db) 246 | 247 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 248 | dbName: 'limiter', 249 | tableName: 'rate_limits', 250 | duration: '1 minute', 251 | requests: 5, 252 | }) 253 | 254 | const response = await store.get('ip_localhost') 255 | assert.isNull(response) 256 | }) 257 | }) 258 | 259 | test.group('Limiter database store | wrapper | set', () => { 260 | test('set requests consumed for a given key', async ({ assert }) => { 261 | const db = createDatabase() 262 | await createTables(db) 263 | 264 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 265 | dbName: 'limiter', 266 | tableName: 'rate_limits', 267 | duration: '1 minute', 268 | requests: 5, 269 | }) 270 | 271 | const response = await store.set('ip_localhost', 2, '1 minute') 272 | const freshResponse = await store.get('ip_localhost') 273 | assert.instanceOf(response, LimiterResponse) 274 | assert.containsSubset(response!.toJSON(), { 275 | limit: 5, 276 | remaining: 3, 277 | consumed: 2, 278 | }) 279 | assert.closeTo(response.availableIn, 60, 5) 280 | assert.equal(response.remaining, freshResponse?.remaining) 281 | assert.equal(response.consumed, freshResponse?.consumed) 282 | }) 283 | 284 | test('overwrite existing points of a key', async ({ assert }) => { 285 | const db = createDatabase() 286 | await createTables(db) 287 | 288 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 289 | dbName: 'limiter', 290 | tableName: 'rate_limits', 291 | duration: '1 minute', 292 | requests: 5, 293 | }) 294 | 295 | await store.consume('ip_localhost') 296 | await store.consume('ip_localhost') 297 | await store.consume('ip_localhost') 298 | 299 | const response = await store.set('ip_localhost', 2, '1 minute') 300 | const freshResponse = await store.get('ip_localhost') 301 | assert.instanceOf(response, LimiterResponse) 302 | assert.containsSubset(response!.toJSON(), { 303 | limit: 5, 304 | remaining: 3, 305 | consumed: 2, 306 | }) 307 | 308 | assert.closeTo(response.availableIn, 60, 5) 309 | assert.equal(response.remaining, freshResponse?.remaining) 310 | assert.equal(response.consumed, freshResponse?.consumed) 311 | }) 312 | }) 313 | 314 | test.group('Limiter database store | wrapper | block', () => { 315 | test('block a given key', async ({ assert }) => { 316 | const db = createDatabase() 317 | await createTables(db) 318 | 319 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 320 | dbName: 'limiter', 321 | tableName: 'rate_limits', 322 | duration: '1 minute', 323 | requests: 5, 324 | }) 325 | 326 | const response = await store.block('ip_localhost', '2 minutes') 327 | const freshResponse = await store.get('ip_localhost') 328 | assert.instanceOf(response, LimiterResponse) 329 | assert.containsSubset(response!.toJSON(), { 330 | limit: 5, 331 | remaining: 0, 332 | consumed: 6, 333 | availableIn: 120, 334 | }) 335 | 336 | assert.closeTo(response.availableIn, 120, 5) 337 | assert.equal(response.remaining, freshResponse?.remaining) 338 | assert.equal(response.consumed, freshResponse?.consumed) 339 | }) 340 | 341 | test('disallow consume calls on a blocked key', async () => { 342 | const db = createDatabase() 343 | await createTables(db) 344 | 345 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 346 | dbName: 'limiter', 347 | tableName: 'rate_limits', 348 | duration: '1 minute', 349 | requests: 5, 350 | }) 351 | 352 | await store.block('ip_localhost', '2 minutes') 353 | await store.consume('ip_localhost') 354 | }).throws('Too many requests') 355 | }) 356 | 357 | test.group('Limiter database store | wrapper | delete', () => { 358 | test('delete blocked key', async ({ assert }) => { 359 | const db = createDatabase() 360 | await createTables(db) 361 | 362 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 363 | dbName: 'limiter', 364 | tableName: 'rate_limits', 365 | duration: '1 minute', 366 | requests: 5, 367 | }) 368 | 369 | await store.block('ip_localhost', '2 minutes') 370 | const response = await store.get('ip_localhost') 371 | assert.instanceOf(response, LimiterResponse) 372 | assert.containsSubset(response!.toJSON(), { 373 | limit: 5, 374 | remaining: 0, 375 | consumed: 6, 376 | }) 377 | assert.closeTo(response!.availableIn, 120, 5) 378 | 379 | await store.delete('ip_localhost') 380 | const freshResponse = await store.get('ip_localhost') 381 | assert.isNull(freshResponse) 382 | }) 383 | 384 | test('allow consume calls after delete', async ({ assert }) => { 385 | const db = createDatabase() 386 | await createTables(db) 387 | 388 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 389 | dbName: 'limiter', 390 | tableName: 'rate_limits', 391 | duration: '1 minute', 392 | requests: 5, 393 | }) 394 | 395 | await store.block('ip_localhost', '2 minutes') 396 | await assert.rejects(() => store.consume('ip_localhost')) 397 | 398 | await store.delete('ip_localhost') 399 | await assert.doesNotReject(() => store.consume('ip_localhost')) 400 | }) 401 | }) 402 | 403 | test.group('Limiter database store | wrapper | clear', () => { 404 | test('clear db', async ({ assert }) => { 405 | const db = createDatabase() 406 | await createTables(db) 407 | 408 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 409 | dbName: 'limiter', 410 | tableName: 'rate_limits', 411 | duration: '1 minute', 412 | requests: 5, 413 | }) 414 | 415 | await store.consume('ip_localhost') 416 | const response = await store.get('ip_localhost') 417 | assert.instanceOf(response, LimiterResponse) 418 | 419 | await store.clear() 420 | assert.isNull(await store.get('ip_localhost')) 421 | }) 422 | }) 423 | 424 | test.group('Limiter database store | wrapper | increment', () => { 425 | test('increment the requests count', async ({ assert }) => { 426 | const db = createDatabase() 427 | await createTables(db) 428 | 429 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 430 | dbName: 'limiter', 431 | tableName: 'rate_limits', 432 | duration: '1 minute', 433 | requests: 5, 434 | }) 435 | 436 | await store.consume('ip_localhost') 437 | const response = await store.increment('ip_localhost') 438 | assert.instanceOf(response, LimiterResponse) 439 | assert.containsSubset(response.toJSON(), { 440 | limit: 5, 441 | remaining: 3, 442 | consumed: 2, 443 | }) 444 | }) 445 | 446 | test('do not throw when incrementing beyond the limit', async ({ assert }) => { 447 | const db = createDatabase() 448 | await createTables(db) 449 | 450 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 451 | dbName: 'limiter', 452 | tableName: 'rate_limits', 453 | duration: '1 minute', 454 | requests: 1, 455 | }) 456 | 457 | await store.consume('ip_localhost') 458 | await store.increment('ip_localhost') 459 | const response = await store.increment('ip_localhost') 460 | assert.instanceOf(response, LimiterResponse) 461 | assert.containsSubset(response.toJSON(), { 462 | limit: 1, 463 | remaining: 0, 464 | consumed: 3, 465 | }) 466 | }) 467 | 468 | test('increment for non-existing key', async ({ assert }) => { 469 | const db = createDatabase() 470 | await createTables(db) 471 | 472 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 473 | dbName: 'limiter', 474 | tableName: 'rate_limits', 475 | duration: '1 minute', 476 | requests: 1, 477 | }) 478 | 479 | const response = await store.increment('ip_localhost') 480 | assert.instanceOf(response, LimiterResponse) 481 | assert.containsSubset(response.toJSON(), { 482 | limit: 1, 483 | remaining: 0, 484 | consumed: 1, 485 | }) 486 | }) 487 | }) 488 | 489 | test.group('Limiter database store | wrapper | decrement', () => { 490 | test('decrement the requests count', async ({ assert }) => { 491 | const db = createDatabase() 492 | await createTables(db) 493 | 494 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 495 | dbName: 'limiter', 496 | tableName: 'rate_limits', 497 | duration: '1 minute', 498 | requests: 5, 499 | }) 500 | 501 | await store.consume('ip_localhost') 502 | const response = await store.decrement('ip_localhost') 503 | assert.instanceOf(response, LimiterResponse) 504 | assert.containsSubset(response.toJSON(), { 505 | limit: 5, 506 | remaining: 5, 507 | consumed: 0, 508 | }) 509 | }) 510 | 511 | test('do not throw when decrementing beyond zero', async ({ assert }) => { 512 | const db = createDatabase() 513 | await createTables(db) 514 | 515 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 516 | dbName: 'limiter', 517 | tableName: 'rate_limits', 518 | duration: '1 minute', 519 | requests: 1, 520 | }) 521 | 522 | await store.consume('ip_localhost') 523 | await store.decrement('ip_localhost') 524 | const response = await store.decrement('ip_localhost') 525 | const freshResponse = await store.get('ip_localhost') 526 | 527 | assert.instanceOf(response, LimiterResponse) 528 | assert.containsSubset(response.toJSON(), { 529 | limit: 1, 530 | remaining: 1, 531 | consumed: 0, 532 | }) 533 | 534 | assert.instanceOf(freshResponse, LimiterResponse) 535 | assert.containsSubset(freshResponse!.toJSON(), { 536 | limit: 1, 537 | remaining: 1, 538 | consumed: 0, 539 | }) 540 | }) 541 | 542 | test('decrement non-existing key', async ({ assert }) => { 543 | const db = createDatabase() 544 | await createTables(db) 545 | 546 | const store = new LimiterDatabaseStore(db.connection(process.env.DB), { 547 | dbName: 'limiter', 548 | tableName: 'rate_limits', 549 | duration: '1 minute', 550 | requests: 1, 551 | }) 552 | 553 | const response = await store.decrement('ip_localhost') 554 | assert.instanceOf(response, LimiterResponse) 555 | assert.containsSubset(response.toJSON(), { 556 | limit: 1, 557 | remaining: 1, 558 | consumed: 0, 559 | }) 560 | 561 | await assert.doesNotReject(() => store.consume('ip_localhost')) 562 | await assert.rejects(() => store.consume('ip_localhost')) 563 | }) 564 | }) 565 | -------------------------------------------------------------------------------- /tests/stores/memory.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { LimiterResponse } from '../../src/response.js' 12 | import { E_TOO_MANY_REQUESTS } from '../../src/errors.js' 13 | import LimiterMemoryStore from '../../src/stores/memory.js' 14 | 15 | test.group('Limiter memory store | wrapper', () => { 16 | test('define readonly properties', async ({ assert }) => { 17 | const store = new LimiterMemoryStore({ 18 | duration: '1 minute', 19 | requests: 5, 20 | }) 21 | 22 | assert.equal(store.name, 'memory') 23 | assert.equal(store.requests, 5) 24 | assert.equal(store.duration, 60) 25 | }) 26 | }) 27 | 28 | test.group('Limiter memory store | wrapper | consume', () => { 29 | test('consume points using the memory store', async ({ assert }) => { 30 | const store = new LimiterMemoryStore({ 31 | duration: '1 minute', 32 | requests: 5, 33 | }) 34 | 35 | const response = await store.consume('ip_localhost') 36 | assert.instanceOf(response, LimiterResponse) 37 | assert.containsSubset(response.toJSON(), { 38 | limit: 5, 39 | remaining: 4, 40 | consumed: 1, 41 | }) 42 | assert.closeTo(response.availableIn, 60, 5) 43 | }) 44 | 45 | test('throw error when no points are left', async ({ assert }) => { 46 | const store = new LimiterMemoryStore({ 47 | duration: '1 minute', 48 | requests: 1, 49 | }) 50 | 51 | await store.consume('ip_localhost') 52 | try { 53 | await store.consume('ip_localhost') 54 | } catch (error) { 55 | assert.instanceOf(error, E_TOO_MANY_REQUESTS) 56 | assert.containsSubset(error.response.toJSON(), { 57 | limit: 1, 58 | remaining: 0, 59 | consumed: 2, 60 | }) 61 | assert.closeTo(error.response.availableIn, 60, 5) 62 | } 63 | }) 64 | 65 | test('block key when all points have been consumed', async ({ assert }) => { 66 | const store = new LimiterMemoryStore({ 67 | duration: '1 minute', 68 | requests: 1, 69 | blockDuration: '2 minutes', 70 | }) 71 | 72 | await store.consume('ip_localhost') 73 | try { 74 | await store.consume('ip_localhost') 75 | } catch (error) { 76 | assert.instanceOf(error, E_TOO_MANY_REQUESTS) 77 | assert.containsSubset(error.response.toJSON(), { 78 | limit: 1, 79 | remaining: 0, 80 | consumed: 2, 81 | }) 82 | assert.closeTo(error.response.availableIn, 120, 5) 83 | } 84 | }) 85 | }) 86 | 87 | test.group('Limiter memory store | wrapper | get', () => { 88 | test('get response for a pre-existing key', async ({ assert }) => { 89 | const store = new LimiterMemoryStore({ 90 | duration: '1 minute', 91 | requests: 5, 92 | }) 93 | 94 | await store.consume('ip_localhost') 95 | const response = await store.get('ip_localhost') 96 | assert.instanceOf(response, LimiterResponse) 97 | assert.containsSubset(response!.toJSON(), { 98 | limit: 5, 99 | remaining: 4, 100 | consumed: 1, 101 | }) 102 | assert.closeTo(response!.availableIn, 60, 5) 103 | }) 104 | 105 | test('return null when key does not exists', async ({ assert }) => { 106 | const store = new LimiterMemoryStore({ 107 | duration: '1 minute', 108 | requests: 5, 109 | }) 110 | 111 | const response = await store.get('ip_localhost') 112 | assert.isNull(response) 113 | }) 114 | }) 115 | 116 | test.group('Limiter memory store | wrapper | set', () => { 117 | test('set requests consumed for a given key', async ({ assert }) => { 118 | const store = new LimiterMemoryStore({ 119 | duration: '1 minute', 120 | requests: 5, 121 | }) 122 | 123 | const response = await store.set('ip_localhost', 2, '1 minute') 124 | const freshResponse = await store.get('ip_localhost') 125 | assert.instanceOf(response, LimiterResponse) 126 | assert.containsSubset(response!.toJSON(), { 127 | limit: 5, 128 | remaining: 3, 129 | consumed: 2, 130 | }) 131 | 132 | assert.closeTo(response.availableIn, 60, 5) 133 | assert.equal(response.remaining, freshResponse?.remaining) 134 | assert.equal(response.consumed, freshResponse?.consumed) 135 | }) 136 | 137 | test('overwrite existing points of a key', async ({ assert }) => { 138 | const store = new LimiterMemoryStore({ 139 | duration: '1 minute', 140 | requests: 5, 141 | }) 142 | 143 | await store.consume('ip_localhost') 144 | await store.consume('ip_localhost') 145 | await store.consume('ip_localhost') 146 | 147 | const response = await store.set('ip_localhost', 2, '1 minute') 148 | const freshResponse = await store.get('ip_localhost') 149 | assert.instanceOf(response, LimiterResponse) 150 | assert.containsSubset(response!.toJSON(), { 151 | limit: 5, 152 | remaining: 3, 153 | consumed: 2, 154 | }) 155 | 156 | assert.closeTo(response.availableIn, 60, 5) 157 | assert.equal(response.remaining, freshResponse?.remaining) 158 | assert.equal(response.consumed, freshResponse?.consumed) 159 | }) 160 | }) 161 | 162 | test.group('Limiter memory store | wrapper | block', () => { 163 | test('block a given key', async ({ assert }) => { 164 | const store = new LimiterMemoryStore({ 165 | duration: '1 minute', 166 | requests: 5, 167 | }) 168 | 169 | const response = await store.block('ip_localhost', '2 minutes') 170 | const freshResponse = await store.get('ip_localhost') 171 | assert.instanceOf(response, LimiterResponse) 172 | assert.containsSubset(response!.toJSON(), { 173 | limit: 5, 174 | remaining: 0, 175 | consumed: 6, 176 | }) 177 | 178 | assert.closeTo(response.availableIn, 120, 5) 179 | assert.equal(response.remaining, freshResponse?.remaining) 180 | assert.equal(response.consumed, freshResponse?.consumed) 181 | }) 182 | 183 | test('disallow consume calls on a blocked key', async () => { 184 | const store = new LimiterMemoryStore({ 185 | duration: '1 minute', 186 | requests: 5, 187 | }) 188 | 189 | await store.block('ip_localhost', '2 minutes') 190 | await store.consume('ip_localhost') 191 | }).throws('Too many requests') 192 | }) 193 | 194 | test.group('Limiter memory store | wrapper | delete', () => { 195 | test('delete blocked key', async ({ assert }) => { 196 | const store = new LimiterMemoryStore({ 197 | duration: '1 minute', 198 | requests: 5, 199 | }) 200 | 201 | await store.block('ip_localhost', '2 minutes') 202 | const response = await store.get('ip_localhost') 203 | assert.instanceOf(response, LimiterResponse) 204 | assert.containsSubset(response!.toJSON(), { 205 | limit: 5, 206 | remaining: 0, 207 | consumed: 6, 208 | }) 209 | assert.closeTo(response!.availableIn, 120, 5) 210 | 211 | await store.delete('ip_localhost') 212 | const freshResponse = await store.get('ip_localhost') 213 | assert.isNull(freshResponse) 214 | }) 215 | 216 | test('allow consume calls after delete', async ({ assert }) => { 217 | const store = new LimiterMemoryStore({ 218 | duration: '1 minute', 219 | requests: 5, 220 | }) 221 | 222 | await store.block('ip_localhost', '2 minutes') 223 | await assert.rejects(() => store.consume('ip_localhost')) 224 | 225 | await store.delete('ip_localhost') 226 | await assert.doesNotReject(() => store.consume('ip_localhost')) 227 | }) 228 | }) 229 | 230 | test.group('Limiter memory store | wrapper | clear', () => { 231 | test('clear db', async ({ assert }) => { 232 | const store = new LimiterMemoryStore({ 233 | duration: '1 minute', 234 | requests: 5, 235 | }) 236 | 237 | await store.consume('ip_localhost') 238 | const response = await store.get('ip_localhost') 239 | assert.instanceOf(response, LimiterResponse) 240 | 241 | await store.clear() 242 | assert.isNull(await store.get('ip_localhost')) 243 | }) 244 | }) 245 | 246 | test.group('Limiter database store | wrapper | increment', () => { 247 | test('increment the requests count', async ({ assert }) => { 248 | const store = new LimiterMemoryStore({ 249 | duration: '1 minute', 250 | requests: 5, 251 | }) 252 | 253 | await store.consume('ip_localhost') 254 | const response = await store.increment('ip_localhost') 255 | assert.instanceOf(response, LimiterResponse) 256 | assert.containsSubset(response.toJSON(), { 257 | limit: 5, 258 | remaining: 3, 259 | consumed: 2, 260 | }) 261 | }) 262 | 263 | test('do not throw when incrementing beyond the limit', async ({ assert }) => { 264 | const store = new LimiterMemoryStore({ 265 | duration: '1 minute', 266 | requests: 1, 267 | }) 268 | 269 | await store.consume('ip_localhost') 270 | await store.increment('ip_localhost') 271 | const response = await store.increment('ip_localhost') 272 | assert.instanceOf(response, LimiterResponse) 273 | assert.containsSubset(response.toJSON(), { 274 | limit: 1, 275 | remaining: 0, 276 | consumed: 3, 277 | }) 278 | }) 279 | 280 | test('increment for non-existing key', async ({ assert }) => { 281 | const store = new LimiterMemoryStore({ 282 | duration: '1 minute', 283 | requests: 1, 284 | }) 285 | 286 | const response = await store.increment('ip_localhost') 287 | assert.instanceOf(response, LimiterResponse) 288 | assert.containsSubset(response.toJSON(), { 289 | limit: 1, 290 | remaining: 0, 291 | consumed: 1, 292 | }) 293 | }) 294 | }) 295 | 296 | test.group('Limiter database store | wrapper | decrement', () => { 297 | test('decrement the requests count', async ({ assert }) => { 298 | const store = new LimiterMemoryStore({ 299 | duration: '1 minute', 300 | requests: 5, 301 | }) 302 | 303 | await store.consume('ip_localhost') 304 | const response = await store.decrement('ip_localhost') 305 | assert.instanceOf(response, LimiterResponse) 306 | assert.containsSubset(response.toJSON(), { 307 | limit: 5, 308 | remaining: 5, 309 | consumed: 0, 310 | }) 311 | }) 312 | 313 | test('do not throw when decrementing beyond zero', async ({ assert }) => { 314 | const store = new LimiterMemoryStore({ 315 | duration: '1 minute', 316 | requests: 1, 317 | }) 318 | 319 | await store.consume('ip_localhost') 320 | await store.decrement('ip_localhost') 321 | const response = await store.decrement('ip_localhost') 322 | const freshResponse = await store.get('ip_localhost') 323 | 324 | assert.instanceOf(response, LimiterResponse) 325 | assert.containsSubset(response.toJSON(), { 326 | limit: 1, 327 | remaining: 1, 328 | consumed: 0, 329 | }) 330 | 331 | assert.instanceOf(freshResponse, LimiterResponse) 332 | assert.containsSubset(freshResponse!.toJSON(), { 333 | limit: 1, 334 | remaining: 1, 335 | consumed: 0, 336 | }) 337 | }) 338 | 339 | test('decrement non-existing key', async ({ assert }) => { 340 | const store = new LimiterMemoryStore({ 341 | duration: '1 minute', 342 | requests: 1, 343 | }) 344 | 345 | const response = await store.decrement('ip_localhost') 346 | assert.instanceOf(response, LimiterResponse) 347 | assert.containsSubset(response.toJSON(), { 348 | limit: 1, 349 | remaining: 1, 350 | consumed: 0, 351 | }) 352 | 353 | await assert.doesNotReject(() => store.consume('ip_localhost')) 354 | await assert.rejects(() => store.consume('ip_localhost')) 355 | }) 356 | }) 357 | -------------------------------------------------------------------------------- /tests/stores/redis.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { createRedis } from '../helpers.js' 13 | import { LimiterResponse } from '../../src/response.js' 14 | import { E_TOO_MANY_REQUESTS } from '../../src/errors.js' 15 | import LimiterRedisStore from '../../src/stores/redis.js' 16 | 17 | test.group('Limiter redis store | wrapper', () => { 18 | test('define readonly properties', async ({ assert }) => { 19 | const redis = createRedis(['rlflx:ip_localhost']).connection() 20 | const store = new LimiterRedisStore(redis, { 21 | duration: '1 minute', 22 | requests: 5, 23 | }) 24 | 25 | assert.equal(store.name, 'redis') 26 | assert.equal(store.requests, 5) 27 | assert.equal(store.duration, 60) 28 | }) 29 | }) 30 | 31 | test.group('Limiter redis store | wrapper | consume', () => { 32 | test('consume points using the redis store', async ({ assert }) => { 33 | const redis = createRedis(['rlflx:ip_localhost']).connection() 34 | const store = new LimiterRedisStore(redis, { 35 | duration: '1 minute', 36 | requests: 5, 37 | }) 38 | 39 | const response = await store.consume('ip_localhost') 40 | assert.instanceOf(response, LimiterResponse) 41 | assert.containsSubset(response.toJSON(), { 42 | limit: 5, 43 | remaining: 4, 44 | consumed: 1, 45 | }) 46 | assert.closeTo(response.availableIn, 60, 5) 47 | }) 48 | 49 | test('throw error when no points are left', async ({ assert }) => { 50 | const redis = createRedis(['rlflx:ip_localhost']).connection() 51 | const store = new LimiterRedisStore(redis, { 52 | duration: '1 minute', 53 | requests: 1, 54 | }) 55 | 56 | await store.consume('ip_localhost') 57 | try { 58 | await store.consume('ip_localhost') 59 | } catch (error) { 60 | assert.instanceOf(error, E_TOO_MANY_REQUESTS) 61 | assert.containsSubset(error.response.toJSON(), { 62 | limit: 1, 63 | remaining: 0, 64 | consumed: 2, 65 | }) 66 | assert.closeTo(error.response.availableIn, 60, 5) 67 | } 68 | }) 69 | 70 | test('block key when all points have been consumed', async ({ assert }) => { 71 | const redis = createRedis(['rlflx:ip_localhost']).connection() 72 | const store = new LimiterRedisStore(redis, { 73 | duration: '1 minute', 74 | requests: 1, 75 | blockDuration: '2 minutes', 76 | }) 77 | 78 | await store.consume('ip_localhost') 79 | try { 80 | await store.consume('ip_localhost') 81 | } catch (error) { 82 | assert.instanceOf(error, E_TOO_MANY_REQUESTS) 83 | assert.containsSubset(error.response.toJSON(), { 84 | limit: 1, 85 | remaining: 0, 86 | consumed: 2, 87 | }) 88 | assert.closeTo(error.response.availableIn, 120, 5) 89 | } 90 | }) 91 | 92 | test('increment request counter even when the key has consumed all requests', async ({ 93 | assert, 94 | }) => { 95 | const redis = createRedis(['rlflx:ip_localhost']).connection() 96 | const store = new LimiterRedisStore(redis, { 97 | duration: '1 minute', 98 | requests: 2, 99 | }) 100 | 101 | await store.consume('ip_localhost') 102 | await store.consume('ip_localhost') 103 | await assert.rejects(() => store.consume('ip_localhost')) 104 | await assert.rejects(() => store.consume('ip_localhost')) 105 | 106 | const response = await store.get('ip_localhost') 107 | assert.instanceOf(response, LimiterResponse) 108 | assert.equal(response!.consumed, 4) 109 | }) 110 | 111 | test('do not increment request counter when blocking keys in memory', async ({ assert }) => { 112 | const redis = createRedis(['rlflx:ip_localhost']).connection() 113 | const store = new LimiterRedisStore(redis, { 114 | duration: '1 minute', 115 | inMemoryBlockOnConsumed: 2, 116 | inMemoryBlockDuration: '1 minute', 117 | requests: 2, 118 | }) 119 | 120 | await store.consume('ip_localhost') 121 | await store.consume('ip_localhost') 122 | await assert.rejects(() => store.consume('ip_localhost')) 123 | await assert.rejects(() => store.consume('ip_localhost')) 124 | await assert.rejects(() => store.consume('ip_localhost')) 125 | await assert.rejects(() => store.consume('ip_localhost')) 126 | 127 | const response = await store.get('ip_localhost') 128 | assert.instanceOf(response, LimiterResponse) 129 | assert.equal(response!.consumed, 3) 130 | }) 131 | 132 | test('reset in memory blocked keys', async ({ assert }) => { 133 | const redis = createRedis(['rlflx:ip_localhost']).connection() 134 | const store = new LimiterRedisStore(redis, { 135 | duration: '1 minute', 136 | inMemoryBlockOnConsumed: 2, 137 | inMemoryBlockDuration: '1 minute', 138 | requests: 2, 139 | }) 140 | 141 | await store.consume('ip_localhost') 142 | await store.consume('ip_localhost') 143 | await assert.rejects(() => store.consume('ip_localhost')) 144 | await assert.rejects(() => store.consume('ip_localhost')) 145 | await assert.rejects(() => store.consume('ip_localhost')) 146 | 147 | const response = await store.get('ip_localhost') 148 | assert.equal(response!.consumed, 3) 149 | 150 | store.deleteInMemoryBlockedKeys() 151 | await assert.rejects(() => store.consume('ip_localhost')) 152 | 153 | const freshResponse = await store.get('ip_localhost') 154 | assert.equal(freshResponse!.consumed, 4) 155 | }) 156 | }) 157 | 158 | test.group('Limiter redis store | wrapper | get', () => { 159 | test('get response for a pre-existing key', async ({ assert }) => { 160 | const redis = createRedis(['rlflx:ip_localhost']).connection() 161 | const store = new LimiterRedisStore(redis, { 162 | duration: '1 minute', 163 | requests: 5, 164 | }) 165 | 166 | await store.consume('ip_localhost') 167 | const response = await store.get('ip_localhost') 168 | assert.instanceOf(response, LimiterResponse) 169 | assert.containsSubset(response!.toJSON(), { 170 | limit: 5, 171 | remaining: 4, 172 | consumed: 1, 173 | }) 174 | assert.closeTo(response!.availableIn, 60, 5) 175 | }) 176 | 177 | test('return null when key does not exists', async ({ assert }) => { 178 | const redis = createRedis(['rlflx:ip_localhost']).connection() 179 | const store = new LimiterRedisStore(redis, { 180 | duration: '1 minute', 181 | requests: 5, 182 | }) 183 | 184 | const response = await store.get('ip_localhost') 185 | assert.isNull(response) 186 | }) 187 | 188 | test('get response for negative points', async ({ assert }) => { 189 | const redis = createRedis(['rlflx:ip_localhost']).connection() 190 | const store = new LimiterRedisStore(redis, { 191 | duration: '1 minute', 192 | requests: 1, 193 | }) 194 | 195 | await store.consume('ip_localhost') 196 | await assert.rejects(() => store.consume('ip_localhost')) 197 | const response = await store.get('ip_localhost') 198 | assert.instanceOf(response, LimiterResponse) 199 | assert.containsSubset(response!.toJSON(), { 200 | limit: 1, 201 | remaining: 0, 202 | consumed: 2, 203 | }) 204 | assert.closeTo(response!.availableIn, 60, 5) 205 | }) 206 | }) 207 | 208 | test.group('Limiter redis store | wrapper | set', () => { 209 | test('set requests consumed for a given key', async ({ assert }) => { 210 | const redis = createRedis(['rlflx:ip_localhost']).connection() 211 | const store = new LimiterRedisStore(redis, { 212 | duration: '1 minute', 213 | requests: 5, 214 | }) 215 | 216 | const response = await store.set('ip_localhost', 2, '1 minute') 217 | const freshResponse = await store.get('ip_localhost') 218 | assert.instanceOf(response, LimiterResponse) 219 | assert.containsSubset(response!.toJSON(), { 220 | limit: 5, 221 | remaining: 3, 222 | consumed: 2, 223 | }) 224 | assert.closeTo(response.availableIn, 60, 5) 225 | assert.equal(response.remaining, freshResponse?.remaining) 226 | assert.equal(response.consumed, freshResponse?.consumed) 227 | }) 228 | 229 | test('overwrite existing points of a key', async ({ assert }) => { 230 | const redis = createRedis(['rlflx:ip_localhost']).connection() 231 | const store = new LimiterRedisStore(redis, { 232 | duration: '1 minute', 233 | requests: 5, 234 | }) 235 | 236 | await store.consume('ip_localhost') 237 | await store.consume('ip_localhost') 238 | await store.consume('ip_localhost') 239 | 240 | const response = await store.set('ip_localhost', 2, '1 minute') 241 | const freshResponse = await store.get('ip_localhost') 242 | assert.instanceOf(response, LimiterResponse) 243 | assert.containsSubset(response!.toJSON(), { 244 | limit: 5, 245 | remaining: 3, 246 | consumed: 2, 247 | }) 248 | 249 | assert.closeTo(response.availableIn, 60, 5) 250 | assert.equal(response.remaining, freshResponse?.remaining) 251 | assert.equal(response.consumed, freshResponse?.consumed) 252 | }) 253 | }) 254 | 255 | test.group('Limiter redis store | wrapper | block', () => { 256 | test('block a given key', async ({ assert }) => { 257 | const redis = createRedis(['rlflx:ip_localhost']).connection() 258 | const store = new LimiterRedisStore(redis, { 259 | duration: '1 minute', 260 | requests: 5, 261 | }) 262 | 263 | const response = await store.block('ip_localhost', '2 minutes') 264 | const freshResponse = await store.get('ip_localhost') 265 | assert.instanceOf(response, LimiterResponse) 266 | assert.containsSubset(response!.toJSON(), { 267 | limit: 5, 268 | remaining: 0, 269 | consumed: 6, 270 | availableIn: 120, 271 | }) 272 | 273 | assert.closeTo(response.availableIn, 120, 5) 274 | assert.equal(response.remaining, freshResponse?.remaining) 275 | assert.equal(response.consumed, freshResponse?.consumed) 276 | }) 277 | 278 | test('disallow consume calls on a blocked key', async () => { 279 | const redis = createRedis(['rlflx:ip_localhost']).connection() 280 | const store = new LimiterRedisStore(redis, { 281 | duration: '1 minute', 282 | requests: 5, 283 | }) 284 | 285 | await store.block('ip_localhost', '2 minutes') 286 | await store.consume('ip_localhost') 287 | }).throws('Too many requests') 288 | }) 289 | 290 | test.group('Limiter redis store | wrapper | delete', () => { 291 | test('delete blocked key', async ({ assert }) => { 292 | const redis = createRedis(['rlflx:ip_localhost']).connection() 293 | const store = new LimiterRedisStore(redis, { 294 | duration: '1 minute', 295 | requests: 5, 296 | }) 297 | 298 | await store.block('ip_localhost', '2 minutes') 299 | const response = await store.get('ip_localhost') 300 | assert.instanceOf(response, LimiterResponse) 301 | assert.containsSubset(response!.toJSON(), { 302 | limit: 5, 303 | remaining: 0, 304 | consumed: 6, 305 | }) 306 | assert.closeTo(response!.availableIn, 120, 5) 307 | 308 | await store.delete('ip_localhost') 309 | const freshResponse = await store.get('ip_localhost') 310 | assert.isNull(freshResponse) 311 | }) 312 | 313 | test('allow consume calls after delete', async ({ assert }) => { 314 | const redis = createRedis(['rlflx:ip_localhost']).connection() 315 | const store = new LimiterRedisStore(redis, { 316 | duration: '1 minute', 317 | requests: 5, 318 | }) 319 | 320 | await store.block('ip_localhost', '2 minutes') 321 | await assert.rejects(() => store.consume('ip_localhost')) 322 | 323 | await store.delete('ip_localhost') 324 | await assert.doesNotReject(() => store.consume('ip_localhost')) 325 | }) 326 | }) 327 | 328 | test.group('Limiter redis store | wrapper | clear', () => { 329 | test('clear db', async ({ assert }) => { 330 | const redis = createRedis(['rlflx:ip_localhost']).connection() 331 | const store = new LimiterRedisStore(redis, { 332 | duration: '1 minute', 333 | requests: 5, 334 | }) 335 | 336 | await store.consume('ip_localhost') 337 | const response = await store.get('ip_localhost') 338 | assert.instanceOf(response, LimiterResponse) 339 | 340 | await store.clear() 341 | assert.isNull(await store.get('ip_localhost')) 342 | }) 343 | }) 344 | 345 | test.group('Limiter redis store | wrapper | increment', () => { 346 | test('increment the requests count', async ({ assert }) => { 347 | const redis = createRedis(['rlflx:ip_localhost']).connection() 348 | const store = new LimiterRedisStore(redis, { 349 | duration: '1 minute', 350 | requests: 5, 351 | }) 352 | 353 | await store.consume('ip_localhost') 354 | const response = await store.increment('ip_localhost') 355 | assert.instanceOf(response, LimiterResponse) 356 | assert.containsSubset(response.toJSON(), { 357 | limit: 5, 358 | remaining: 3, 359 | consumed: 2, 360 | }) 361 | }) 362 | 363 | test('do not throw when incrementing beyond the limit', async ({ assert }) => { 364 | const redis = createRedis(['rlflx:ip_localhost']).connection() 365 | const store = new LimiterRedisStore(redis, { 366 | duration: '1 minute', 367 | requests: 1, 368 | }) 369 | 370 | await store.consume('ip_localhost') 371 | await store.increment('ip_localhost') 372 | const response = await store.increment('ip_localhost') 373 | assert.instanceOf(response, LimiterResponse) 374 | assert.containsSubset(response.toJSON(), { 375 | limit: 1, 376 | remaining: 0, 377 | consumed: 3, 378 | }) 379 | }) 380 | 381 | test('increment for non-existing key', async ({ assert }) => { 382 | const redis = createRedis(['rlflx:ip_localhost']).connection() 383 | const store = new LimiterRedisStore(redis, { 384 | duration: '1 minute', 385 | requests: 1, 386 | }) 387 | 388 | const response = await store.increment('ip_localhost') 389 | assert.instanceOf(response, LimiterResponse) 390 | assert.containsSubset(response.toJSON(), { 391 | limit: 1, 392 | remaining: 0, 393 | consumed: 1, 394 | }) 395 | }) 396 | }) 397 | 398 | test.group('Limiter redis store | wrapper | decrement', () => { 399 | test('decrement the requests count', async ({ assert }) => { 400 | const redis = createRedis(['rlflx:ip_localhost']).connection() 401 | const store = new LimiterRedisStore(redis, { 402 | duration: '1 minute', 403 | requests: 5, 404 | }) 405 | 406 | await store.consume('ip_localhost') 407 | const response = await store.decrement('ip_localhost') 408 | assert.instanceOf(response, LimiterResponse) 409 | assert.containsSubset(response.toJSON(), { 410 | limit: 5, 411 | remaining: 5, 412 | consumed: 0, 413 | }) 414 | }) 415 | 416 | test('do not throw when decrementing beyond zero', async ({ assert }) => { 417 | const redis = createRedis(['rlflx:ip_localhost']).connection() 418 | const store = new LimiterRedisStore(redis, { 419 | duration: '1 minute', 420 | requests: 1, 421 | }) 422 | 423 | await store.consume('ip_localhost') 424 | await store.decrement('ip_localhost') 425 | const response = await store.decrement('ip_localhost') 426 | const freshResponse = await store.get('ip_localhost') 427 | 428 | assert.instanceOf(response, LimiterResponse) 429 | assert.containsSubset(response.toJSON(), { 430 | limit: 1, 431 | remaining: 1, 432 | consumed: 0, 433 | }) 434 | 435 | assert.instanceOf(freshResponse, LimiterResponse) 436 | assert.containsSubset(freshResponse!.toJSON(), { 437 | limit: 1, 438 | remaining: 1, 439 | consumed: 0, 440 | }) 441 | }) 442 | 443 | test('decrement non-existing key', async ({ assert }) => { 444 | const redis = createRedis(['rlflx:ip_localhost']).connection() 445 | const store = new LimiterRedisStore(redis, { 446 | duration: '1 minute', 447 | requests: 1, 448 | }) 449 | 450 | const response = await store.decrement('ip_localhost') 451 | assert.instanceOf(response, LimiterResponse) 452 | assert.containsSubset(response.toJSON(), { 453 | limit: 1, 454 | remaining: 1, 455 | consumed: 0, 456 | }) 457 | 458 | await assert.doesNotReject(() => store.consume('ip_localhost')) 459 | await assert.rejects(() => store.consume('ip_localhost')) 460 | }) 461 | }) 462 | -------------------------------------------------------------------------------- /tests/throttle_exception.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/bouncer 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { E_TOO_MANY_REQUESTS } from '../src/errors.js' 12 | import { LimiterResponse } from '../src/response.js' 13 | import { I18nManagerFactory } from '@adonisjs/i18n/factories' 14 | import { HttpContextFactory } from '@adonisjs/core/factories/http' 15 | 16 | test.group('LimiterException', () => { 17 | test('make HTTP response with default message, headers and status code', async ({ assert }) => { 18 | const exception = new E_TOO_MANY_REQUESTS( 19 | new LimiterResponse({ 20 | availableIn: 10, 21 | consumed: 10, 22 | limit: 10, 23 | remaining: 0, 24 | }) 25 | ) 26 | const ctx = new HttpContextFactory().create() 27 | 28 | await exception.handle(exception, ctx) 29 | assert.equal(ctx.response.getBody(), 'Too many requests') 30 | assert.equal(ctx.response.getStatus(), 429) 31 | assert.containsSubset(ctx.response.getHeaders(), { 32 | 'retry-after': '10', 33 | 'x-ratelimit-limit': '10', 34 | 'x-ratelimit-remaining': '0', 35 | }) 36 | }) 37 | 38 | test('use default translation identifier for message when using i18n', async ({ assert }) => { 39 | const i18nManager = new I18nManagerFactory() 40 | .merge({ 41 | config: { 42 | loaders: [ 43 | () => { 44 | return { 45 | async load() { 46 | return { 47 | en: { 48 | 'errors.E_TOO_MANY_REQUESTS': 'You have made too many requests', 49 | }, 50 | } 51 | }, 52 | } 53 | }, 54 | ], 55 | }, 56 | }) 57 | .create() 58 | 59 | await i18nManager.loadTranslations() 60 | 61 | const exception = new E_TOO_MANY_REQUESTS( 62 | new LimiterResponse({ 63 | availableIn: 10, 64 | consumed: 10, 65 | limit: 10, 66 | remaining: 0, 67 | }) 68 | ) 69 | const ctx = new HttpContextFactory().create() 70 | ctx.i18n = i18nManager.locale('en') 71 | 72 | await exception.handle(exception, ctx) 73 | assert.equal(ctx.response.getBody(), 'You have made too many requests') 74 | assert.equal(ctx.response.getStatus(), 429) 75 | }) 76 | 77 | test('use custom translation identifier for message when using i18n', async ({ assert }) => { 78 | const i18nManager = new I18nManagerFactory() 79 | .merge({ 80 | config: { 81 | loaders: [ 82 | () => { 83 | return { 84 | async load() { 85 | return { 86 | en: { 87 | 'errors.limit_exceeded': 'You have made too many requests', 88 | }, 89 | } 90 | }, 91 | } 92 | }, 93 | ], 94 | }, 95 | }) 96 | .create() 97 | 98 | await i18nManager.loadTranslations() 99 | 100 | const exception = new E_TOO_MANY_REQUESTS( 101 | new LimiterResponse({ 102 | availableIn: 10, 103 | consumed: 10, 104 | limit: 10, 105 | remaining: 0, 106 | }) 107 | ) 108 | exception.t('errors.limit_exceeded').setStatus(400) 109 | const ctx = new HttpContextFactory().create() 110 | ctx.i18n = i18nManager.locale('en') 111 | 112 | await exception.handle(exception, ctx) 113 | assert.equal(ctx.response.getBody(), 'You have made too many requests') 114 | assert.equal(ctx.response.getStatus(), 400) 115 | }) 116 | 117 | test('make JSON response', async ({ assert }) => { 118 | const exception = new E_TOO_MANY_REQUESTS( 119 | new LimiterResponse({ 120 | availableIn: 10, 121 | consumed: 10, 122 | limit: 10, 123 | remaining: 0, 124 | }) 125 | ) 126 | const ctx = new HttpContextFactory().create() 127 | ctx.request.request.headers.accept = 'application/json' 128 | 129 | await exception.handle(exception, ctx) 130 | assert.deepEqual(ctx.response.getBody(), { 131 | errors: [{ message: 'Too many requests', retryAfter: 10 }], 132 | }) 133 | assert.equal(ctx.response.getStatus(), 429) 134 | assert.containsSubset(ctx.response.getHeaders(), { 135 | 'retry-after': '10', 136 | 'x-ratelimit-limit': '10', 137 | 'x-ratelimit-remaining': '0', 138 | }) 139 | }) 140 | 141 | test('make JSONAPI response', async ({ assert }) => { 142 | const exception = new E_TOO_MANY_REQUESTS( 143 | new LimiterResponse({ 144 | availableIn: 10, 145 | consumed: 10, 146 | limit: 10, 147 | remaining: 0, 148 | }) 149 | ) 150 | const ctx = new HttpContextFactory().create() 151 | ctx.request.request.headers.accept = 'application/vnd.api+json' 152 | 153 | await exception.handle(exception, ctx) 154 | assert.deepEqual(ctx.response.getBody(), { 155 | errors: [ 156 | { title: 'Too many requests', code: 'E_TOO_MANY_REQUESTS', meta: { retryAfter: 10 } }, 157 | ], 158 | }) 159 | assert.equal(ctx.response.getStatus(), 429) 160 | assert.containsSubset(ctx.response.getHeaders(), { 161 | 'retry-after': '10', 162 | 'x-ratelimit-limit': '10', 163 | 'x-ratelimit-remaining': '0', 164 | }) 165 | }) 166 | 167 | test('overwrite default message', async ({ assert }) => { 168 | const exception = new E_TOO_MANY_REQUESTS( 169 | new LimiterResponse({ 170 | availableIn: 10, 171 | consumed: 10, 172 | limit: 10, 173 | remaining: 0, 174 | }) 175 | ) 176 | exception.setMessage('You have made too many requests') 177 | 178 | const ctx = new HttpContextFactory().create() 179 | await exception.handle(exception, ctx) 180 | assert.equal(ctx.response.getBody(), 'You have made too many requests') 181 | }) 182 | 183 | test('overwrite default headers', async ({ assert }) => { 184 | const exception = new E_TOO_MANY_REQUESTS( 185 | new LimiterResponse({ 186 | availableIn: 10, 187 | consumed: 10, 188 | limit: 10, 189 | remaining: 0, 190 | }) 191 | ) 192 | exception.setHeaders({ 'x-blocked': true }) 193 | 194 | const ctx = new HttpContextFactory().create() 195 | await exception.handle(exception, ctx) 196 | assert.deepEqual(ctx.response.getHeaders(), { 197 | 'x-blocked': 'true', 198 | }) 199 | }) 200 | }) 201 | -------------------------------------------------------------------------------- /tests/throttle_middleware.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/limiter 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { HttpContextFactory } from '@adonisjs/core/factories/http' 12 | 13 | import { createRedis } from './helpers.js' 14 | import LimiterRedisStore from '../src/stores/redis.js' 15 | import { LimiterManager } from '../src/limiter_manager.js' 16 | 17 | test.group('Throttle middleware', () => { 18 | test('throttle requests using the middleware', async ({ assert }) => { 19 | let nextCalled: boolean = false 20 | 21 | const redis = createRedis(['rlflx:api_1']).connection() 22 | const limiterManager = new LimiterManager({ 23 | default: 'redis', 24 | stores: { 25 | redis: (options) => new LimiterRedisStore(redis, options), 26 | }, 27 | }) 28 | 29 | const apiLimiter = limiterManager.define('api', () => { 30 | return limiterManager.allowRequests(1).every('1 minute').usingKey(1) 31 | }) 32 | assert.equal(apiLimiter.name, 'apiThrottle') 33 | 34 | const ctx = new HttpContextFactory().create() 35 | await apiLimiter(ctx, () => { 36 | nextCalled = true 37 | }) 38 | 39 | assert.equal(await limiterManager.use({ duration: 60, requests: 1 }).remaining('api_1'), 0) 40 | assert.isTrue(nextCalled) 41 | }) 42 | 43 | test('do not call next when key has exhausted all requests', async ({ assert }) => { 44 | let nextCalled: boolean = false 45 | 46 | const redis = createRedis(['rlflx:api_1']).connection() 47 | const limiterManager = new LimiterManager({ 48 | default: 'redis', 49 | stores: { 50 | redis: (options) => new LimiterRedisStore(redis, options), 51 | }, 52 | }) 53 | 54 | const apiLimiter = limiterManager.define('api', () => { 55 | return limiterManager.allowRequests(1).every('1 minute').usingKey(1) 56 | }) 57 | 58 | /** 59 | * This will consume all the requests the 60 | * key has 61 | */ 62 | await limiterManager.use({ duration: 60, requests: 1 }).consume('api_1') 63 | 64 | const ctx = new HttpContextFactory().create() 65 | 66 | try { 67 | await apiLimiter(ctx, () => { 68 | nextCalled = true 69 | }) 70 | } catch (error) { 71 | assert.equal(error.message, 'Too many requests') 72 | assert.isFalse(nextCalled) 73 | } 74 | }) 75 | 76 | test('block key when requests are made even after rate limited', async ({ assert }) => { 77 | let nextCalled: boolean = false 78 | 79 | const redis = createRedis(['rlflx:api_1']).connection() 80 | const limiterManager = new LimiterManager({ 81 | default: 'redis', 82 | stores: { 83 | redis: (options) => new LimiterRedisStore(redis, options), 84 | }, 85 | }) 86 | 87 | const apiLimiter = limiterManager.define('api', () => { 88 | return limiterManager.allowRequests(1).every('1 minute').usingKey(1).blockFor('30 mins') 89 | }) 90 | 91 | /** 92 | * This will consume all the requests the 93 | * key has 94 | */ 95 | await limiterManager.use({ duration: 60, requests: 1 }).consume('api_1') 96 | 97 | const ctx = new HttpContextFactory().create() 98 | 99 | try { 100 | await apiLimiter(ctx, () => { 101 | nextCalled = true 102 | }) 103 | } catch (error) { 104 | assert.equal(error.message, 'Too many requests') 105 | assert.closeTo(error.response.availableIn, 30 * 60, 5) 106 | assert.isFalse(nextCalled) 107 | } 108 | }) 109 | 110 | test('do not throttle request when no limiter is used', async ({ assert }) => { 111 | let nextCalled: boolean = false 112 | 113 | const redis = createRedis(['rlflx:api_1']).connection() 114 | const limiterManager = new LimiterManager({ 115 | default: 'redis', 116 | stores: { 117 | redis: (options) => new LimiterRedisStore(redis, options), 118 | }, 119 | }) 120 | 121 | const apiLimiter = limiterManager.define('api', () => { 122 | return limiterManager.noLimit() 123 | }) 124 | const ctx = new HttpContextFactory().create() 125 | 126 | await apiLimiter(ctx, () => { 127 | nextCalled = true 128 | }) 129 | assert.isTrue(nextCalled) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build", 6 | }, 7 | } 8 | --------------------------------------------------------------------------------