Spring Cloud Gateway Custom Api limiter

Faza Zulfika Permana Putra
Blibli.com Tech Blog
13 min readSep 3, 2021
image source : https://wstutorial.com/img/topics/apig/spring-cloud-gateway.png

In my previous article, I talk about how to make spring cloud gateway routes configurable from database. So in current article, we will talk about other spring cloud gateway feature.

Spring cloud gateway comes with many features, one of them is API Rate Limiter. As default it will use redis to store data, and all logic will handled by RedisRateLimiter class (RedisRateLimiter use Token Bucket Algorithm). To differentiate user of each request, spring will look to Principal class which will set by spring security. So if we didn’t use spring security, we need to update KeyResolver class to differentiate each request that we will limit. Here’s some example to set new KeyResolver that doesn’t differentiate any request, and how to set Api Rate Limiter through application.properties (application.yaml).

To set a new KeyResolver, we need to create a bean of KeyResolver class. Here’s the example.

ApiLimiterConfiguration.javapackage com.faza.example.springcloudgatewaycustomapilimiter.configuration;import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;
@Configuration
public class ApiLimiterConfiguration {
@Bean
public KeyResolver keyResolver() {
return exchange -> Mono.just("1");
}
}

After we create a bean of KeyResolver, then we can set Rate Limiter to the route that we want. Here’s the example of application.properties to set Rate Limiter.

application.propertiesspring.cloud.gateway.routes[0].id=get_route
spring.cloud.gateway.routes[0].predicates[0].name=Path
spring.cloud.gateway.routes[0].predicates[0].args[patterns]=/get
spring.cloud.gateway.routes[0].predicates[1].name=Method
spring.cloud.gateway.routes[0].predicates[1].args[methods]=GET
spring.cloud.gateway.routes[0].filters[0].name=RequestRateLimiter
spring.cloud.gateway.routes[0].filters[0].args[redis-rate-limiter].replenish-rate=10
spring.cloud.gateway.routes[0].filters[0].args[redis-rate-limiter].burst-capacity=15
spring.cloud.gateway.routes[0].uri.=http://httpbin.org/

From configuration above, user only allowed to hit ‘/get’ path 10 times per second, and allowed to burst request up to 15 request at a time. Since Rate Limiter cache it’s quite fast, you need to use JMeter or similar tools to burst request.

If your request exceed maximum allowed request, you will get 429 Too Many Request Http Status.

But current article will not stop here, since default Rate Limiter cannot set cache expiration time we’ll try to make it configurable. And we’ll try to move Rate Limiter configuration from properties into database, so we can maintain it dynamically.

Database Design

For current example, we will use postgresql as database. If you new to postgresql, and wonder how to install it. You can refer to my other article to install postgresql using docker.

Here’s the table design that we will use, currently we only use one table with name api_limiter.

Here’s the SQL script to create table that we will use.

-- Table: public.api_limiter-- DROP TABLE public.api_limiter;CREATE TABLE public.api_limiter
(
id bigint NOT NULL DEFAULT nextval('api_limiter_id_seq'::regclass),
path character varying COLLATE pg_catalog."default" NOT NULL,
method character varying(10) COLLATE pg_catalog."default" NOT NULL,
threshold integer NOT NULL,
ttl bigint NOT NULL,
active boolean NOT NULL,
CONSTRAINT api_limiter_pkey PRIMARY KEY (id)
)
TABLESPACE pg_default;ALTER TABLE public.api_limiter
OWNER to postgres;

Application Dependencies

For current example, we will use several spring dependencies to support our example.

  1. org.springframework.cloud:spring-cloud-starter-gateway → dependency for spring cloud gateway
  2. org.springframework.boot:spring-boot-starter-data-r2dbc → dependency for read-write data from database in reactive way
  3. org.springframework.boot:spring-boot-starter-data-redis-reactive → dependency for read-write data from redis in reactive way
  4. io.r2dbc:r2dbc-postgresql → dependency for reactive postgresql database driver
  5. org.projectlombok:lombok → dependency to reduce method that we will write in our example

You can generate base project for our example, using start.spring.io.

Or you can put dependencies manually to your existing pom.xml file (please update several information that match your current information).

pom.xml<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.faza.example</groupId>
<artifactId>spring-cloud-gateway-custom-api-limiter</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-cloud-gateway-custom-api-limiter</name>
<description>Demo project for Spring Boot Cloud Gateway Custom Api Limiter</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>2020.0.3</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

Database Integration

First code that we’ll write in our code is code to integrate with our database, especially to our api_limiter table.

To bind our table with our class, we need to create a model class. Here’s the code.

ApiLimiter.javapackage com.faza.example.springcloudgatewaycustomapilimiter.model.database;
import com.faza.example.springcloudgatewaycustomapilimiter.model.constant.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.io.Serializable;@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(TableName.API_LIMITER) // To bind our model class with a database table with defined name
public class ApiLimiter implements Serializable {
private static final long serialVersionUID = -5132504076641395736L; @Id // Indicating that this field is primary key in our database table
private Long id;

private String path;
private String method;

private int threshold;
private int ttl;

private boolean active;
}

Here code for TableName class, a constant class that saved all of our table name.

TableName.javapackage com.faza.example.springcloudgatewaycustomapilimiter.model.constant;public class TableName {

public static final String API_LIMITER = "api_limiter";
}

Next step, we need to create a repository class to maintain our api_limiter data.

We can use ReactiveCrudRepository class from spring data r2dbc that contains common method (basic CRUD-CREATE-READ-UPDATE-DELETE) to maintain our api_limiter data.

Spring boot will automatically create this repository implementation when application start.

ApiLimiterRepository.javapackage com.faza.example.springcloudgatewaycustomapilimiter.repository;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
public interface ApiLimiterRepository extends ReactiveCrudRepository<ApiLimiter, Long> {}

After all of codes above complete, you need to add several properties for r2dbc to make it works.

application.propertiesspring.r2dbc.url=r2dbc:postgresql://localhost:5432/postgres
spring.r2dbc.username=postgres
spring.r2dbc.password=postgres

Finding a Suitable Api Limiter for Requested Path

In current example, we will find suitable api limiter data for request path through path and method field in api limiter table.

Path field in api limiter table can save regex, so we need to create a custom query to matching requested path and path regex in api limiter table.

To make this possible, we need to create our own custom repository for api limiter table.

ApiLimiterCustomRepository.javapackage com.faza.example.springcloudgatewaycustomapilimiter.repository.custom;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import reactor.core.publisher.Mono;
public interface ApiLimiterCustomRepository { Mono<ApiLimiter> findMatchesApiLimiter(String path, String method);
}

We need to make our ApiLimiterRepository extend our custom repository.

ApiLimiterRepository.javapackage com.faza.example.springcloudgatewaycustomapilimiter.repository;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.repository.custom.ApiLimiterCustomRepository;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
public interface ApiLimiterRepository extends ReactiveCrudRepository<ApiLimiter, Long>,
ApiLimiterCustomRepository {
}

After api limiter repository is done, we need to make implementation for our custom repository.

In our implementation, we will create a native postgresql query to get api limiter that match with our requested path and method.

And we will use DatabaseClient bean to execute our native query.

ApiLimiterRepositoryImpl.javapackage com.faza.example.springcloudgatewaycustomapilimiter.repository.impl;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.repository.custom.ApiLimiterCustomRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
@Repository
public class ApiLimiterRepositoryImpl implements ApiLimiterCustomRepository {
private static final String API_LIMITER_MATCH_REGEX_QUERY =
"SELECT * " +
"FROM api_limiter apiLimiter " +
"WHERE apiLimiter.active = true AND :path SIMILAR TO apiLimiter.path " +
"AND apiLimiter.method = :method";

@Autowired
private DatabaseClient databaseClient;
@Override
public Mono<ApiLimiter> findMatchesApiLimiter(String path, String method) {
return databaseClient.sql(API_LIMITER_MATCH_REGEX_QUERY)
.bind("path", path)
.bind("method", method)
.map(row -> ApiLimiter.builder()
.id(row.get("id", Long.class))
.path(row.get("path", String.class))
.method(row.get("method", String.class))
.threshold(row.get("threshold", Integer.class))
.ttl(row.get("ttl", Integer.class))
.active(row.get("active", Boolean.class))
.build())
.first();
}
}

Then we already able to find suitable api limiter for requested path.

Custom Key Resolver

From this point we already able to find suitable api limiter for requested path, so for the next step we will apply the api limiter.

So we need a service class to pass suitable api limiter from repository.

ApiLimiterService.javapackage com.faza.example.springcloudgatewaycustomapilimiter.service;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import reactor.core.publisher.Mono;
public interface ApiLimiterService {

Mono<ApiLimiter> findMatchesApiLimiter(String path, String method);
}

Then we will create an implementation for api limiter service class.

ApiLimiterServiceImpl.javapackage com.faza.example.springcloudgatewaycustomapilimiter.service.impl;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.repository.ApiLimiterRepository;
import com.faza.example.springcloudgatewaycustomapilimiter.service.ApiLimiterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class ApiLimiterServiceImpl implements ApiLimiterService {

@Autowired
private ApiLimiterRepository apiLimiterRepository;
@Override
public Mono<ApiLimiter> findMatchesApiLimiter(String path, String method) {
return apiLimiterRepository.findMatchesApiLimiter(path, method);
}
}

As we can see before, KeyResolver class return a string to differentiate each request, so we need to convert our api limiter to string that can be returned by api limiter.

We will create a helper class that will help us to convert an object into a string that will returned by api limiter.

ObjectHelper.javapackage com.faza.example.springcloudgatewaycustomapilimiter.helper;import lombok.SneakyThrows;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Base64;
@Service
public class ObjectHelper {
@SneakyThrows
public String toStringBase64(Serializable serializable) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(serializable);
objectOutputStream.close();
return Base64.getEncoder()
.encodeToString(byteArrayOutputStream.toByteArray());
}
@SneakyThrows
public Object fromStringBase64(String base64String) {
byte[] bytes = Base64.getDecoder()
.decode(base64String);
ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(bytes));
Object object = objectInputStream.readObject();
objectInputStream.close();
return object;
}
}

From ObjectHelper class above, we have to method that will help us to convert object into string and vice versa.

Then we able to return our suitable api limiter in KeyResolver, so we need to create our custom Key Resolver.

ApiRateLimiterKeyResolver.javapackage com.faza.example.springcloudgatewaycustomapilimiter.service.impl;import com.faza.example.springcloudgatewaycustomapilimiter.helper.ObjectHelper;
import com.faza.example.springcloudgatewaycustomapilimiter.service.ApiLimiterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Service
public class ApiRateLimiterKeyResolver implements KeyResolver {

@Autowired
private ApiLimiterService apiLimiterService;

@Autowired
private ObjectHelper objectHelper;
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
return apiLimiterService.findMatchesApiLimiter(
exchange.getRequest()
.getPath()
.value(),
exchange.getRequest()
.getMethodValue())
.doOnNext(apiLimiter -> apiLimiter.setPath(
exchange.getRequest()
.getPath()
.value()))
.map(objectHelper::toStringBase64);
}
}

We need to update our api limiter path with requested path to differentiate for each path, since our api limiter path data can contains regex path.

Custom Rate Limiter

After we can return our suitable api limiter from our custom Key Resolver, we should be able to configure our api limiter with our suitable api limiter.

To configure this api limiter, we need to create a custom api limiter class.

ApiRateLimiter.javapackage com.faza.example.springcloudgatewaycustomapilimiter.service.impl;import com.faza.example.springcloudgatewaycustomapilimiter.helper.ObjectHelper;
import com.faza.example.springcloudgatewaycustomapilimiter.model.constant.CacheKey;
import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.model.dto.ApiRateLimiterCacheDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter;
import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.publisher.SynchronousSink;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Service
public class ApiRateLimiter implements RateLimiter<Object> {
private static final String REMAINING_TIME_IN_SECONDS_HEADER =
"X-RateLimit-Remaining-Time-In-Seconds";
private static final String TIME_IN_SECONDS_HEADER =
"X-RateLimit-Time-In-Seconds";
@Autowired
private ObjectHelper objectHelper;
@Autowired
private ReactiveStringRedisTemplate reactiveStringRedisTemplate;
@Override
public Mono<Response> isAllowed(String routeId, String id) {
ApiLimiter apiLimiter = (ApiLimiter) objectHelper.fromStringBase64(id);
ApiRateLimiterCacheDto apiRateLimiterCacheDto = ApiRateLimiterCacheDto.builder()
.cacheKey(buildApiRateLimiterCacheKey(routeId, apiLimiter))
.threshold(apiLimiter.getThreshold())
.ttl(apiLimiter.getTtl())
.build();
return reactiveStringRedisTemplate.opsForValue()
.increment(apiRateLimiterCacheDto.getCacheKey())
.doOnNext(apiRateLimiterCacheDto::setRate)
.flatMap(rate -> setCacheExpiration(apiRateLimiterCacheDto))
.flatMap(this::setRemainingTtl)
.handle(this::handleApiRateLimiterCacheValue);
}
@Override
public Map<String, Object> getConfig() {
return null;
}
@Override
public Class<Object> getConfigClass() {
return null;
}
@Override
public Object newConfig() {
return null;
}
private String buildApiRateLimiterCacheKey(String routeId, ApiLimiter apiLimiter) {
return String.join(CacheKey.CACHE_KEY_SEPARATOR, CacheKey.API_RATE_LIMITER_CACHE_KEY_PREFIX,
routeId, apiLimiter.getPath(), apiLimiter.getMethod());
}
private Mono<ApiRateLimiterCacheDto> setCacheExpiration(ApiRateLimiterCacheDto apiRateLimiterCacheDto) {
return Mono.just(apiRateLimiterCacheDto.getRate())
.filter(rate -> rate == 1)
.flatMap(rate -> reactiveStringRedisTemplate.expire(
apiRateLimiterCacheDto.getCacheKey(),
Duration.ofSeconds(apiRateLimiterCacheDto.getTtl())))
.thenReturn(apiRateLimiterCacheDto);
}
private Mono<ApiRateLimiterCacheDto> setRemainingTtl(ApiRateLimiterCacheDto apiRateLimiterCacheDto) {
return reactiveStringRedisTemplate.getExpire(apiRateLimiterCacheDto.getCacheKey())
.doOnNext(duration -> apiRateLimiterCacheDto.setRemainingTtl(duration.getSeconds()))
.thenReturn(apiRateLimiterCacheDto);
}
private void handleApiRateLimiterCacheValue(ApiRateLimiterCacheDto apiRateLimiterCacheDto,
SynchronousSink<Response> synchronousSink) {
long remaining = apiRateLimiterCacheDto.getThreshold() - apiRateLimiterCacheDto.getRate();
synchronousSink.next(
new Response(isAllowed(remaining), getHeaders(remaining, apiRateLimiterCacheDto)));
}
private boolean isAllowed(long remaining) {
return remaining >= 0;
}
public Map<String, String> getHeaders(long remaining,
ApiRateLimiterCacheDto apiRateLimiterCacheDto) {
Map<String, String> headers = new HashMap<>();
headers.put(RedisRateLimiter.REMAINING_HEADER,
String.valueOf(getRemainingHeaderValue(remaining)));
headers.put(RedisRateLimiter.REPLENISH_RATE_HEADER,
String.valueOf(apiRateLimiterCacheDto.getThreshold()));
headers.put(REMAINING_TIME_IN_SECONDS_HEADER,
String.valueOf(apiRateLimiterCacheDto.getRemainingTtl()));
headers.put(TIME_IN_SECONDS_HEADER, String.valueOf(apiRateLimiterCacheDto.getTtl()));
return headers;
}
private long getRemainingHeaderValue(long remaining) {
if (remaining < 0) {
return 0;
}
return remaining;
}
}

We also need constant class for cache key.

CacheKey.javapackage com.faza.example.springcloudgatewaycustomapilimiter.model.constant;public class CacheKey {

public static final String API_RATE_LIMITER_CACHE_KEY_PREFIX = "api-rate-limiter";
public static final String CACHE_KEY_SEPARATOR = ":";
}

And we also need a dto class for api limiter cache data that we will process.

ApiRateLimiterCacheDto.javapackage com.faza.example.springcloudgatewaycustomapilimiter.model.dto;import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiRateLimiterCacheDto {

private String cacheKey;
private long rate;
private long remainingTtl;
private int threshold;
private int ttl;
}

Our custom api limiter will counting every request and saved it to redis.

The counter in redis will be expired during ttl that we get from our api limiter data. After our counter is expired, it will reset.

If counter reach threshold that we get from our api limiter data, we will reject the request.

We will return several additional headers for user to let him know his current limit.

These headers include :

  • X-RateLimit-Remaining : to inform user the remain request
  • X-RateLimit-Replenish-Rate : to inform user the maximum request
  • X-RateLimit-Remaining-Time-In-Seconds : to inform user the remaining time for current limiter
  • X-RateLimit-Time-In-Seconds : to inform user expiration time for current limiter

Apply Custom Key Resolver and Rate Limiter to Route Configuration

At this point all of our configuration class for api limiter is completed, so the next step is to applied it in our route configuration.

application.properties# Redis Properties
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.client-type=lettuce
# Since the default spring boot will auto configure redis rate limiter
# We need to exclude this auto configuration to prevent conflict for Rate Limiter bean class
spring.autoconfigure.exclude=org.springframework.cloud.gateway.config.GatewayRedisAutoConfiguration
# Since the default of rate limiter will reject request if Key Resolver return nothing, so we need to turn off this behavior
# In this example, if Key Resolver return nothing, it means we didn't apply api limiter for that requested path
spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key=false
spring.cloud.gateway.routes[0].id=get_route
spring.cloud.gateway.routes[0].predicates[0].name=Path
spring.cloud.gateway.routes[0].predicates[0].args[patterns]=/get
spring.cloud.gateway.routes[0].predicates[1].name=Method
spring.cloud.gateway.routes[0].predicates[1].args[methods]=GET
# Properties to apply api limiter in this route
spring.cloud.gateway.routes[0].filters[0].name=RequestRateLimiter
# Apply our custom api limiter to this api limiter configuration
spring.cloud.gateway.routes[0].filters[0].args[rate-limiter]=#{@apiRateLimiter}
# Apply our custom key resolver to this api limiter configuration
spring.cloud.gateway.routes[0].filters[0].args[key-resolver]=#{@apiRateLimiterKeyResolver}
spring.cloud.gateway.routes[0].uri.=http://httpbin.org/
spring.cloud.gateway.routes[1].id=post_route
spring.cloud.gateway.routes[1].predicates[0].name=Path
spring.cloud.gateway.routes[1].predicates[0].args[patterns]=/post
spring.cloud.gateway.routes[1].predicates[1].name=Method
spring.cloud.gateway.routes[1].predicates[1].args[methods]=POST
spring.cloud.gateway.routes[1].uri.=http://httpbin.org/

So all of our code is completed now, we can try to test it.

You can try to insert some api limiter data in database, here’s an example.

INSERT INTO public.api_limiter(
path, method, threshold, ttl, active)
VALUES ('/get', 'GET', 10, 300, true);

From the example we will set api limiter for path /get with method GET.

We only allow 10 request in 5 minutes.

If you try to hit /get path, you will get several additional headers like this.

And if you try to hit /get path until maximum number of request, you will get 429 Too Many Requests response with several additional headers.

From this experiment, we can say that our api limiter successfully applied.

Internal API to Maintain Our Api Limiter Data

To make this example more perfect, we can add several endpoints to maintain our api limiter data.

First we need to update our api limiter service class to add several CRUD / Create-Read-Update-Delete methods.

ApiLimiterService.javapackage com.faza.example.springcloudgatewaycustomapilimiter.service;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.model.web.CreateOrUpdateApiLimiter;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface ApiLimiterService { Flux<ApiLimiter> findApiLimiters(); Mono<ApiLimiter> findApiLimiter(Long id); Mono<Void> createApiLimiter(CreateOrUpdateApiLimiter createOrUpdateApiLimiter); Mono<Void> updateApiLimiter(Long id, CreateOrUpdateApiLimiter createOrUpdateApiLimiter); Mono<Void> deleteApiLimiter(Long id);

Mono<Void> updateActivationStatus(Long id, boolean activate);

Mono<ApiLimiter> findMatchesApiLimiter(String path, String method);
}

Then we need to create an implementation for each method that we add.

ApiLimiterServiceImpl.javapackage com.faza.example.springcloudgatewaycustomapilimiter.service.impl;import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.model.web.CreateOrUpdateApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.repository.ApiLimiterRepository;
import com.faza.example.springcloudgatewaycustomapilimiter.service.ApiLimiterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class ApiLimiterServiceImpl implements ApiLimiterService {

@Autowired
private ApiLimiterRepository apiLimiterRepository;
@Override
public Flux<ApiLimiter> findApiLimiters() {
return apiLimiterRepository.findAll();
}
@Override
public Mono<ApiLimiter> findApiLimiter(Long id) {
return findAndValidateApiLimiter(id);
}
@Override
public Mono<Void> createApiLimiter(CreateOrUpdateApiLimiter createOrUpdateApiLimiter) {
ApiLimiter apiLimiter = setNewApiLimiter(new ApiLimiter(), createOrUpdateApiLimiter);
return apiLimiterRepository.save(apiLimiter)
.then();
}
@Override
public Mono<Void> updateApiLimiter(Long id, CreateOrUpdateApiLimiter createOrUpdateApiLimiter) {
return findAndValidateApiLimiter(id)
.map(apiLimiter -> setNewApiLimiter(apiLimiter, createOrUpdateApiLimiter))
.flatMap(apiLimiterRepository::save)
.then();
}
@Override
public Mono<Void> deleteApiLimiter(Long id) {
return findAndValidateApiLimiter(id)
.flatMap(apiLimiter -> apiLimiterRepository.deleteById(apiLimiter.getId()));
}
@Override
public Mono<Void> updateActivationStatus(Long id, boolean activate) {
return findAndValidateApiLimiter(id)
.doOnNext(apiLimiter -> apiLimiter.setActive(activate))
.flatMap(apiLimiterRepository::save)
.then();
}
@Override
public Mono<ApiLimiter> findMatchesApiLimiter(String path, String method) {
return apiLimiterRepository.findMatchesApiLimiter(path, method);
}
private Mono<ApiLimiter> findAndValidateApiLimiter(Long id) {
return apiLimiterRepository.findById(id)
.switchIfEmpty(Mono.error(
new RuntimeException(String.format("Api limiter with id %d not found", id))));
}
private ApiLimiter setNewApiLimiter(ApiLimiter apiLimiter,
CreateOrUpdateApiLimiter createOrUpdateApiLimiter) {
apiLimiter.setPath(createOrUpdateApiLimiter.getPath());
apiLimiter.setMethod(createOrUpdateApiLimiter.getMethod());
apiLimiter.setThreshold(createOrUpdateApiLimiter.getThreshold());
apiLimiter.setTtl(createOrUpdateApiLimiter.getTtl());
apiLimiter.setActive(createOrUpdateApiLimiter.isActive());
return apiLimiter;
}
}

After service methods is ready, we will create a controller class to receive request from user.

ApiLimiterController.javapackage com.faza.example.springcloudgatewaycustomapilimiter.controller;import com.faza.example.springcloudgatewaycustomapilimiter.model.constant.ApiPath;
import com.faza.example.springcloudgatewaycustomapilimiter.model.database.ApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.model.web.ApiLimiterResponse;
import com.faza.example.springcloudgatewaycustomapilimiter.model.web.CreateOrUpdateApiLimiter;
import com.faza.example.springcloudgatewaycustomapilimiter.service.ApiLimiterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;@RestController
@RequestMapping(path = ApiPath.INTERNAL_API_LIMITERS)
public class ApiLimiterController {

@Autowired
private ApiLimiterService apiLimiterService;
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<List<ApiLimiterResponse>> findApiLimiters() {
return apiLimiterService.findApiLimiters()
.map(this::convertApiLimiterIntoApiLimiterResponse)
.collectList()
.subscribeOn(Schedulers.boundedElastic());
}
@GetMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<ApiLimiterResponse> findApiLimiter(@PathVariable Long id) {
return apiLimiterService.findApiLimiter(id)
.map(this::convertApiLimiterIntoApiLimiterResponse)
.subscribeOn(Schedulers.boundedElastic());
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public Mono<?> createApiLimiter(
@RequestBody @Validated CreateOrUpdateApiLimiter createOrUpdateApiLimiter) {
return apiLimiterService.createApiLimiter(createOrUpdateApiLimiter)
.subscribeOn(Schedulers.boundedElastic());
}
@PutMapping(path = "/{id}",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<?> updateApiLimiter(@PathVariable Long id,
@RequestBody @Validated CreateOrUpdateApiLimiter createOrUpdateApiLimiter) {
return apiLimiterService.updateApiLimiter(id, createOrUpdateApiLimiter)
.subscribeOn(Schedulers.boundedElastic());
}
@DeleteMapping(path = "/{id}",
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<?> deleteApiLimiter(@PathVariable Long id) {
return apiLimiterService.deleteApiLimiter(id)
.subscribeOn(Schedulers.boundedElastic());
}
@PutMapping(path = "/{id}/activate",
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<?> activateApiLimiter(@PathVariable Long id) {
return apiLimiterService.updateActivationStatus(id, true)
.subscribeOn(Schedulers.boundedElastic());
}
@PutMapping(path = "/{id}/deactivate",
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<?> deactivateApiLimiter(@PathVariable Long id) {
return apiLimiterService.updateActivationStatus(id, false)
.subscribeOn(Schedulers.boundedElastic());
}
private ApiLimiterResponse convertApiLimiterIntoApiLimiterResponse(ApiLimiter apiLimiter) {
return ApiLimiterResponse.builder()
.id(apiLimiter.getId())
.path(apiLimiter.getPath())
.method(apiLimiter.getMethod())
.threshold(apiLimiter.getThreshold())
.ttl(apiLimiter.getTtl())
.active(apiLimiter.isActive())
.build();
}
}

From service and controller class, we need to create additional class, that is new models class and constant class.

ApiLimiterResponse.javapackage com.faza.example.springcloudgatewaycustomapilimiter.model.web;import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class ApiLimiterResponse {
private Long id; private String path;
private String method;
private int threshold;
private int ttl;
private boolean active;
}
--------------------------------------------------------------------CreateOrUpdateApiLimiter.javapackage com.faza.example.springcloudgatewaycustomapilimiter.model.web;import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class CreateOrUpdateApiLimiter {
@NotBlank
private String path;
@NotBlank
private String method;
private int threshold;
private int ttl;
private boolean active;
}
--------------------------------------------------------------------ApiPath.javapackage com.faza.example.springcloudgatewaycustomapilimiter.model.constant;public class ApiPath {

public static final String INTERNAL = "/internal";
public static final String INTERNAL_API_LIMITERS = INTERNAL + "/api-limiters";
}

Conclusion

From what we try in this article, we got that if we want to create a custom api limiter we need to create a custom KeyResolver and custom RateLimiter class.

And after those both class already created, we need to add custom properties to our rate limiter configuration properties.

You can find full codes of this example in my github repository.

--

--