Skip to content

Commit

Permalink
feat: Added support for Medusa V2
Browse files Browse the repository at this point in the history
The plugin is now just a tiny middleware that supports plugin options and uses directly the CacheModule available on your Medusa.js project.
The `defaultRateLimit` middleware can now accepts the same PluginOptions, allowing you to have a better granular control on each API route and how they should be limitted for your end users.
  • Loading branch information
Adil committed Oct 5, 2024
1 parent 981c3d8 commit 4cecaa2
Show file tree
Hide file tree
Showing 18 changed files with 358 additions and 528 deletions.
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
bun.lockb
eslint.config.mjs

/dist

/api
/services
/models
/subscribers
/__mocks__
/__mocks__

tsconfig.tsbuildinfo
198 changes: 78 additions & 120 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<p align="center">
<a href="https://www.github.com/perseidesjs">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./.r/dark.png" width="128" height="128">
<source media="(prefers-color-scheme: light)" srcset="./.r/light.png" width="128" height="128">
<img alt="Perseides logo" src="./.r/light.png">
<source media="(prefers-color-scheme: dark)" srcset="./.r/dark.png" width="64" height="64">
<source media="(prefers-color-scheme: light)" srcset="./.r/light.png" width="64" height="64">
<img alt="Perseides logo" src="./.r/light.png" width="64" height="64">
</picture>
</a>
</p>
Expand Down Expand Up @@ -53,41 +53,66 @@ npm install @perseidesjs/medusa-plugin-rate-limit
Usage
</h2>
<p>
This plugin uses Redis under the hood, this plugin will also work in a development environment thanks to the fake Redis instance created by Medusa, remember to use Redis in production, by just passing the <code>redis_url</code> option to the <code>medusa-config.js > projectConfig</code> object.
This plugin uses the <a href="https://docs.medusajs.com/v2/resources/architectural-modules/cache#main">CacheModule</a> available (<i>InMemory, Redis, etc.</i>) under the hood and exposes a simple middleware to limit the number of requests per IP address.
</p>

<h3>
Plugin configuration
</h3>
<h2>
How to use
</h2>

<p>
You need to add the plugin to your Medusa configuration before you can use the rate limitting service. To do this, import the plugin as follows:
If you want to start restricting certain routes, you can import the <code>defaultRateLimit</code> middleware from the plugin and then use it as follows:
</p>

```ts
const plugins = [
`medusa-fulfillment-manual`,
`medusa-payment-manual`,
`@perseidesjs/medusa-plugin-rate-limit`,
]
// src/api/middlewares.ts
import { defineMiddlewares } from "@medusajs/medusa"
import { defaultRateLimit } from '@perseidesjs/medusa-plugin-rate-limit'

export default defineMiddlewares({
routes: [
{
matcher: "/store/custom*",
middlewares: [defaultRateLimit()],
},
],
})
```

<p>You can also override the default configuration by passing an object to the plugin as follows: </p>
<p>
You can also pass some custom options to have a complete control over the rate limiting mechanism as follows:
</p>

```ts
const plugins = [
`medusa-fulfillment-manual`,
`medusa-payment-manual`,
{
resolve: `@perseidesjs/medusa-plugin-rate-limit`,
/** @type {import('@perseidesjs/medusa-plugin-rate-limit').PluginOptions} */
options: {
limit: 5,
window: 60,
},
},
]
// src/api/middlewares.ts
import { defineMiddlewares } from "@medusajs/medusa"
import { defaultRateLimit } from '@perseidesjs/medusa-plugin-rate-limit'

export default defineMiddlewares({
routes: [
{
matcher: "/store/custom*",
middlewares: [defaultRateLimit({
limit: 10,
window: 60,
})],
},
],
})
```
<blockquote>
In this example, the rate limiting mechanism will allow 10 requests per minute per IP address.
</blockquote>

<h3>Granular control over rate limiting</h3>

<p>
The choice of having options directly inside the middleware instead of globally inside the plugin options was made to provide greater flexibility. This approach allows users to be more or less restrictive on certain specific routes. By specifying options directly within the middleware, you can tailor the rate limiting mechanism to suit the needs of individual routes, rather than applying a one-size-fits-all configuration globally. This ensures that you can have fine-grained control over the rate limiting behavior, making it possible to adjust the limits based on the specific requirements of each route.
</p>

<p>
Additionally, you can still use the plugin options to update the default global values, such as the limit and window. This allows you to set your own default values that will be applied across many routes, while still having the flexibility to specify more granular settings for specific routes. By configuring the plugin options, you can establish a baseline rate limiting policy that suits the majority of your application, and then override these defaults as needed for particular routes.
</p>

<h3> Default configuration </h3>

Expand Down Expand Up @@ -116,104 +141,37 @@ const plugins = [
</tbody>
</table>

<h2>
How to use
</h2>

<p>
If you want to start restricting certain routes, you can resolve the <code>RateLimitService</code> from the Medusa container, and then create middleware as shown below :
</p>

```ts
// src/middlewares/rate-limit.ts

import { type MedusaRequest, type MedusaResponse } from '@medusajs/medusa'
import type { NextFunction } from 'express'
import type { RateLimitService } from '@perseidesjs/medusa-plugin-rate-limit'

/**
* A simple rate limiter middleware based on the RateLimitService
* @param limit {number} - Number of requests allowed per window
* @param window {number} - Number of seconds to wait before allowing requests again
* @returns
*/
export default async function rateLimit(
req: MedusaRequest,
res: MedusaResponse,
next: NextFunction,
) {
try {
// 1️⃣ We resolve the RateLimitService from the container
const rateLimitService = req.scope.resolve<RateLimitService>('rateLimitService')


// 2️⃣ We create a key for the current request based on the IP address for example
const key = req.ip
const rateLimitKey = `rate_limit:${key}`
const allowed = await rateLimitService.limit(rateLimitKey)

// 3️⃣ If the request is not allowed, we return a 429 status code and a JSON response with an error message
if (!allowed) {
const retryAfter = await rateLimitService.ttl(rateLimitKey)
res.set('Retry-After', String(retryAfter))
res
.status(429)
.json({ error: 'Too many requests, please try again later.' })
return
}

// 4️⃣ Otherwise, we can continue, below I'm getting the remaining attempts for the current key for example
const remaining = await rateLimitService.getRemainingAttempts(rateLimitKey)

res.set('X-RateLimit-Limit', String(rateLimitService.getOptions().limit))
res.set('X-RateLimit-Remaining', String(remaining))

next()
} catch (error) {
next(error)
}
}
```

<p>And then use it in your <code>src/api/middlewares.ts</code> file as follows:</p>

```ts
import { MiddlewaresConfig } from '@medusajs/medusa'
import rateLimit from './middlewares/rate-limit'

export const config: MiddlewaresConfig = {
routes: [
{
// This will limit the number of requests to 5 per 60 seconds on the auth route
matcher: '/store/auth',
middlewares: [rateLimit],
},
],
}
```

<h3> Default Middleware </h3>

<p>We also provide a out of the box middleware that you can use immediately without needing to create your own. This middleware is exposed and can be used as follows:</p>
<h3> Plugin options </h3>

```ts
import { MiddlewaresConfig } from '@medusajs/medusa'
import { rateLimitRoutes } from '@perseidesjs/medusa-plugin-rate-limit'

export const config: MiddlewaresConfig = {
routes: [
{
// This will limit the number of requests to 5 per 60 seconds on the auth route using the default middleware
matcher: '/store/auth',
middlewares: [rateLimitRoutes],
},
],
}
// medusa-config.js
const { loadEnv, defineConfig } = require('@medusajs/framework/utils')

loadEnv(process.env.NODE_ENV, process.cwd())

module.exports = defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL,
http: {
storeCors: process.env.STORE_CORS,
adminCors: process.env.ADMIN_CORS,
authCors: process.env.AUTH_CORS,
jwtSecret: process.env.JWT_SECRET || "supersecret",
cookieSecret: process.env.COOKIE_SECRET || "supersecret",
},
},
plugins: [
{
resolve: "@perseidesjs/medusa-plugin-rate-limit",
options: {
limit: 50,
window: 60,
},
},
],
})
```


<h2> More information </h2>
<p> You can find the <code>RateLimitService</code> class in the <a href="https://github.com/perseidesjs/medusa-plugin-rate-limit/blob/main/src/services/rate-limit.ts">src/services/rate-limit.ts</a> file.</p>

<h2>License</h2>
<p> This project is licensed under the MIT License - see the <a href="./LICENSE.md">LICENSE</a> file for details</p>
94 changes: 94 additions & 0 deletions __tests__/default-rate-limit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
type MedusaRequest,
type MedusaResponse,
type MedusaNextFunction,
} from '@medusajs/framework'
import {
type ICacheService,
type MedusaContainer,
} from '@medusajs/framework/types'
import { Modules } from '@medusajs/framework/utils'
import express from 'express'
import request from 'supertest'
import { defaultRateLimit } from '../src/api/middlewares/default-rate-limit'

// Mock the cache service
const mockCacheService: jest.Mocked<ICacheService> = {
get: jest.fn(),
set: jest.fn(),
invalidate: jest.fn(),
}

const mockScope = {
resolve: jest.fn().mockImplementation((module) => {
if (module === Modules.CACHE) {
return mockCacheService
}
return {}
}),
}

const app = express()
app.use((req: MedusaRequest, res: MedusaResponse, next: MedusaNextFunction) => {
req.scope = mockScope as unknown as MedusaContainer
next()
})

app.use(defaultRateLimit({ limit: 2, window: 60 }))

app.get('/', (req, res) => {
res.send('Hello World')
})

describe('defaultRateLimit Middleware', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('should allow requests under the limit', async () => {
mockCacheService.get.mockResolvedValueOnce(0)

const response = await request(app).get('/')
expect(response.status).toBe(200)
expect(response.text).toBe('Hello World')
})

it('should block requests over the limit', async () => {
mockCacheService.get.mockResolvedValueOnce(2)

const response = await request(app).get('/')
expect(response.status).toBe(429)
expect(response.text).toBe('Too many requests, please try again later.')
})

it('should include rate limit headers when includeHeaders is true', async () => {
app.use(defaultRateLimit({ limit: 2, window: 60, includeHeaders: true }))
mockCacheService.get.mockResolvedValueOnce(0)

const response = await request(app).get('/')
expect(response.headers['x-ratelimit-limit']).toBe('2')
expect(response.headers['x-ratelimit-remaining']).toBe('1')
})

it('should not include rate limit headers when includeHeaders is false', async () => {
const testApp = express()
testApp.use(
(req: MedusaRequest, res: MedusaResponse, next: MedusaNextFunction) => {
req.scope = mockScope as unknown as MedusaContainer
next()
},
)
testApp.use(
defaultRateLimit({ limit: 2, window: 60, includeHeaders: false }),
)
testApp.get('/', (req, res) => {
res.send('Hello World')
})

mockCacheService.get.mockResolvedValueOnce(0)

const response = await request(testApp).get('/')
expect(response.headers['x-ratelimit-limit']).toBeUndefined()
expect(response.headers['x-ratelimit-remaining']).toBeUndefined()
})
})
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { config as defaultConfig } from '@epic-web/config/eslint'

/** @type {import("eslint").Linter.Config} */
export default [...defaultConfig]
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
transform: { "^.+\\.[jt]s?$": "@swc/jest" },
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
testMatch: ["**/__tests__/**/*.spec.[jt]s"],
}
Loading

0 comments on commit 4cecaa2

Please sign in to comment.