Skip to content

Commit

Permalink
feat(middleware): feat(middleware): add ratelimit middleware (#72)
Browse files Browse the repository at this point in the history
add ratelimit middleware.

GH-71
  • Loading branch information
Surbhi-sharma1 committed Mar 14, 2023
1 parent 0eb2382 commit 8dc4889
Show file tree
Hide file tree
Showing 21 changed files with 276 additions and 12 deletions.
1 change: 1 addition & 0 deletions .cz-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
{name: 'providers'},
{name: 'repositories'},
{name: 'typings'},
{name: 'middleware'},
],

appendBranchNameToCommitMessage: false,
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,23 @@ async userDetails(
}
```

## Middleware Sequence Support

As action based sequence will be deprecated soon, we have provided support for middleware based sequences. If you are using middleware sequence you can add ratelimit to your application by enabling ratelimit action middleware. This can be done by binding the RateLimitSecurityBindings.CONFIG in application.ts :

```ts
this.bind(RateLimitSecurityBindings.RATELIMITCONFIG).to({
RatelimitActionMiddleware: true,
});
```

this.component(RateLimiterComponent);

```
This binding needs to be done before adding the RateLimiter component to your application.
Apart from this all other steps will remain the same.
## Feedback
If you've noticed a bug or have a question or have a feature request, [search the issue tracker](https://github.com/sourcefuse/loopback4-ratelimiter/issues) to see if someone else in the community has already created a ticket.
Expand All @@ -222,3 +239,4 @@ Code of conduct guidelines [here](https://github.com/sourcefuse/loopback4-rateli
## License
[MIT](https://github.com/sourcefuse/loopback4-ratelimiter/blob/master/LICENSE)
```
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import {RestApplication} from '@loopback/rest';
import {ServiceMixin} from '@loopback/service-proxy';
import path from 'path';
import {MySequence} from './sequence';
import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../';
import {TestController} from './test.controller';
import {StoreProvider} from './store.provider';
import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../..';

import {StoreProvider} from '../../store.provider';
import {TestController} from '../../test.controller';
export {ApplicationConfig};
export class TestApplication extends BootMixin(
ServiceMixin(RepositoryMixin(RestApplication)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
SequenceActions,
SequenceHandler,
} from '@loopback/rest';
import {RateLimitAction, RateLimitSecurityBindings} from '../../../';
import {RateLimitAction, RateLimitSecurityBindings} from '../../../..';

export class MySequence implements SequenceHandler {
constructor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
givenHttpServerConfig,
} from '@loopback/testlab';
import {TestApplication} from './fixtures/application';

export async function setUpApplication(): Promise<AppWithClient> {
const app = new TestApplication({
rest: givenHttpServerConfig(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {Client} from '@loopback/testlab';
import {memoryStore} from '../store.provider';
import {TestApplication} from './fixtures/application';
import {memoryStore} from './fixtures/store.provider';
import {setUpApplication} from './helper';

describe('Acceptance Test Cases', () => {
let app: TestApplication;
let client: Client;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Acceptance tests
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {BootMixin} from '@loopback/boot';
import {ApplicationConfig} from '@loopback/core';
import {RepositoryMixin} from '@loopback/repository';
import {RestApplication} from '@loopback/rest';
import {ServiceMixin} from '@loopback/service-proxy';
import path from 'path';
import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../..';
import {TestController} from '../../test.controller';

import {MySequence} from './middleware.sequence';
import {StoreProvider} from '../../store.provider';
export {ApplicationConfig};
export class TestApplication extends BootMixin(
ServiceMixin(RepositoryMixin(RestApplication)),
) {
constructor(options: ApplicationConfig = {}) {
super(options);

this.sequence(MySequence);

this.static('/', path.join(__dirname, '../public'));
this.bind(RateLimitSecurityBindings.RATELIMITCONFIG).to({
RatelimitActionMiddleware: true,
});
this.component(RateLimiterComponent);

this.projectRoot = __dirname;
this.controller(TestController);
this.bind(RateLimitSecurityBindings.DATASOURCEPROVIDER).toProvider(
StoreProvider,
);

this.bind(RateLimitSecurityBindings.CONFIG).to({
name: 'inMemory',
max: 5,
windowMs: 2000,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {MiddlewareSequence} from '@loopback/rest';

export class MySequence extends MiddlewareSequence {}
23 changes: 23 additions & 0 deletions src/__tests__/acceptance/ratelimit-middleware-acceptance/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
Client,
createRestAppClient,
givenHttpServerConfig,
} from '@loopback/testlab';
import {TestApplication} from './fixtures/application';
export async function setUpApplication(): Promise<AppWithClient> {
const app = new TestApplication({
rest: givenHttpServerConfig(),
});

await app.boot();
await app.start();

const client = createRestAppClient(app);

return {app, client};
}

export interface AppWithClient {
app: TestApplication;
client: Client;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {Client} from '@loopback/testlab';
import {memoryStore} from '../store.provider';
import {TestApplication} from './fixtures/application';
import {setUpApplication} from './helper';
describe('Acceptance Test Cases', () => {
let app: TestApplication;
let client: Client;

before('setupApplication', async () => {
({app, client} = await setUpApplication());
});
afterEach(async () => {
await clearStore();
});

after(async () => app.stop());

it('should hit end point when number of requests is less than max requests allowed', async () => {
//Max request is set to 5 while binding
for (let i = 0; i < 4; i++) {
await client.get('/test').expect(200);
}
});

it('should hit end point when number of requests is equal to max requests allowed', async () => {
//Max request is set to 5 while binding
for (let i = 0; i < 5; i++) {
await client.get('/test').expect(200);
}
});

it('should give error when number of requests is greater than max requests allowed', async () => {
//Max request is set to 5 while binding
for (let i = 0; i < 5; i++) {
await client.get('/test').expect(200);
}
await client.get('/test').expect(429);
});

it('should overwrite the default behaviour when rate limit decorator is applied', async () => {
//Max request is set to 1 in decorator
await client.get('/testDecorator').expect(200);
await client.get('/testDecorator').expect(429);
});

it('should throw no error if requests more than max are sent after window resets', async () => {
//Max request is set to 5 while binding
for (let i = 0; i < 5; i++) {
await client.get('/test').expect(200);
}
setTimeout(() => {
client
.get('/test')
.expect(200)
.then(
() => {},
() => {},
);
}, 2000);
});

async function clearStore() {
memoryStore.resetAll();
}
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {inject, Provider, ValueOrPromise} from '@loopback/core';
import {Store} from 'express-rate-limit';
import {RateLimitOptions, RateLimitSecurityBindings} from '../../..';
import {RateLimitSecurityBindings} from '../../keys';
import {RateLimitOptions} from '../../types';
import {InMemoryStore} from './in-memory-store';
export const memoryStore = new InMemoryStore();
export class StoreProvider implements Provider<Store> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {get} from '@loopback/rest';
import {ratelimit} from '../../..';
import {ratelimit} from '../..';

export class TestController {
constructor() {}
Expand Down
13 changes: 11 additions & 2 deletions src/component.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import {Binding, Component, ProviderMap} from '@loopback/core';
import {Binding, Component, inject, ProviderMap} from '@loopback/core';
import {createMiddlewareBinding} from '@loopback/rest';
import {RateLimitSecurityBindings} from './keys';
import {RatelimitMiddlewareProvider} from './middleware';
import {
RatelimitActionProvider,
RateLimitMetadataProvider,
RatelimitDatasourceProvider,
} from './providers';
import {RateLimitMiddlewareConfig} from './types';

export class RateLimiterComponent implements Component {
constructor() {
constructor(
@inject(RateLimitSecurityBindings.RATELIMITCONFIG, {optional: true})
private readonly ratelimitConfig?: RateLimitMiddlewareConfig,
) {
this.providers = {
[RateLimitSecurityBindings.RATELIMIT_SECURITY_ACTION.key]:
RatelimitActionProvider,
Expand All @@ -18,6 +24,9 @@ export class RateLimiterComponent implements Component {
this.bindings.push(
Binding.bind(RateLimitSecurityBindings.CONFIG.key).to(null),
);
if (this.ratelimitConfig?.RatelimitActionMiddleware) {
this.bindings.push(createMiddlewareBinding(RatelimitMiddlewareProvider));
}
}

providers?: ProviderMap;
Expand Down
11 changes: 10 additions & 1 deletion src/keys.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {BindingKey, MetadataAccessor} from '@loopback/core';
import {Store} from 'express-rate-limit';
import {RateLimitAction, RateLimitOptions, RateLimitMetadata} from './types';
import {
RateLimitAction,
RateLimitOptions,
RateLimitMetadata,
RateLimitMiddlewareConfig,
} from './types';

export namespace RateLimitSecurityBindings {
export const RATELIMIT_SECURITY_ACTION = BindingKey.create<RateLimitAction>(
Expand All @@ -18,6 +23,10 @@ export namespace RateLimitSecurityBindings {
export const DATASOURCEPROVIDER = BindingKey.create<Store | null>(
'sf.security.ratelimit.datasourceProvider',
);
export const RATELIMITCONFIG =
BindingKey.create<RateLimitMiddlewareConfig | null>(
'sf.security.rateLimitMiddleware.config',
);
}

export const RATELIMIT_METADATA_ACCESSOR = MetadataAccessor.create<
Expand Down
2 changes: 2 additions & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ratelimit.middleware';
export * from './middleware.enum';
3 changes: 3 additions & 0 deletions src/middleware/middleware.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum RatelimitActionMiddlewareGroup {
RATELIMIT = 'ratelimitAction',
}
88 changes: 88 additions & 0 deletions src/middleware/ratelimit.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @SONAR_STOP@
import {CoreBindings, inject, injectable, Next, Provider} from '@loopback/core';
import {Getter} from '@loopback/repository';
import {
Request,
Response,
RestApplication,
HttpErrors,
Middleware,
MiddlewareContext,
asMiddleware,
RestMiddlewareGroups,
} from '@loopback/rest';
import * as RateLimit from 'express-rate-limit';
import {RateLimitSecurityBindings} from '../keys';
import {RateLimitMetadata, RateLimitOptions} from '../types';
import {RatelimitActionMiddlewareGroup} from './middleware.enum';
@injectable(
asMiddleware({
group: RatelimitActionMiddlewareGroup.RATELIMIT,
upstreamGroups: RestMiddlewareGroups.PARSE_PARAMS,
downstreamGroups: [RestMiddlewareGroups.INVOKE_METHOD],
}),
)
export class RatelimitMiddlewareProvider implements Provider<Middleware> {
constructor(
@inject.getter(RateLimitSecurityBindings.DATASOURCEPROVIDER)
private readonly getDatastore: Getter<RateLimit.Store>,
@inject.getter(RateLimitSecurityBindings.METADATA)
private readonly getMetadata: Getter<RateLimitMetadata>,
@inject(CoreBindings.APPLICATION_INSTANCE)
private readonly application: RestApplication,
@inject(RateLimitSecurityBindings.CONFIG, {
optional: true,
})
private readonly config?: RateLimitOptions,
) {}

value() {
const middleware = async (ctx: MiddlewareContext, next: Next) => {
await this.action(ctx.request, ctx.response);
return next();
};
return middleware;
}

async action(request: Request, response: Response): Promise<void> {
const enabledByDefault = this.config?.enabledByDefault ?? true;
const metadata: RateLimitMetadata = await this.getMetadata();
const dataStore = await this.getDatastore();
if (metadata && !metadata.enabled) {
return Promise.resolve();
}

// Perform rate limiting now
const promise = new Promise<void>((resolve, reject) => {
// First check if rate limit options available at method level
const operationMetadata = metadata ? metadata.options : {};

// Create options based on global config and method level config
const opts = Object.assign({}, this.config, operationMetadata);

if (dataStore) {
opts.store = dataStore;
}

opts.message = new HttpErrors.TooManyRequests(
opts.message?.toString() ?? 'Method rate limit reached !',
);

const limiter = RateLimit.default(opts);
limiter(request, response, (err: unknown) => {
if (err) {
reject(err);
}
resolve();
});
});
if (enabledByDefault === true) {
await promise;
} else if (enabledByDefault === false && metadata && metadata.enabled) {
await promise;
} else {
return Promise.resolve();
}
}
}
// @SONAR_START@
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ export interface RateLimitMetadata {

export type Store = MemcachedStore | MongoStore | RedisStore;
export type Writable<T> = {-readonly [P in keyof T]: T[P]};
export interface RateLimitMiddlewareConfig {
RatelimitActionMiddleware?: boolean;
}

0 comments on commit 8dc4889

Please sign in to comment.