API Gateway with Spring Cloud Gateway in Java

Spring Cloud Gateway provides a library for building API Gateways on top of Spring WebFlux. It offers a simple, yet effective way to route to APIs and provide cross-cutting concerns like security, monitoring, and resiliency.

Key Features

  • Built on Spring Framework 6, Spring Boot 3, and Project Reactor
  • Predicate and Filter-based routing
  • Circuit Breaker integration
  • Spring Cloud DiscoveryClient integration
  • Easy to write predicates and filters
  • Request rate limiting
  • Path rewriting

Dependencies Setup

Maven Dependencies

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>api-gateway</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Service Discovery -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- Circuit Breaker -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Config -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<!-- Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-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>
</project>

Basic Configuration

Application Configuration

# application.yml
server:
port: 8080
spring:
application:
name: api-gateway
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: userService
fallbackUri: forward:/fallback/user-service
- id: product-service
uri: lb://PRODUCT-SERVICE
predicates:
- Path=/api/products/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
redis-rate-limiter.requestedTokens: 1
- id: order-service
uri: lb://ORDER-SERVICE
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- name: JwtAuthentication
- id: auth-service
uri: lb://AUTH-SERVICE
predicates:
- Path=/api/auth/**
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka
instance:
prefer-ip-address: true
management:
endpoints:
web:
exposure:
include: health,info,metrics,gateway
endpoint:
gateway:
enabled: true
resilience4j:
circuitbreaker:
instances:
userService:
register-health-indicator: true
failure-rate-threshold: 50
slow-call-rate-threshold: 50
slow-call-duration-threshold: 2s
permitted-number-of-calls-in-half-open-state: 3
sliding-window-size: 10
minimum-number-of-calls: 5
wait-duration-in-open-state: 5s

Main Application Class

package com.example.apigateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}

Custom Filters and Predicates

Example 1: JWT Authentication Filter

package com.example.apigateway.filters;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAuthenticationFilter.Config> {
@Value("${jwt.secret}")
private String jwtSecret;
public JwtAuthenticationFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
if (!exchange.getRequest().getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
}
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return onError(exchange, "Invalid authorization header", HttpStatus.UNAUTHORIZED);
}
String token = authHeader.substring(7);
try {
if (isTokenValid(token)) {
String username = getUsernameFromToken(token);
List<String> roles = getRolesFromToken(token);
// Add headers for downstream services
ServerWebExchange mutatedExchange = exchange.mutate()
.request(builder -> builder
.header("X-User-Id", username)
.header("X-User-Roles", String.join(",", roles))
)
.build();
return chain.filter(mutatedExchange);
} else {
return onError(exchange, "Invalid token", HttpStatus.UNAUTHORIZED);
}
} catch (Exception e) {
return onError(exchange, "Token validation failed", HttpStatus.UNAUTHORIZED);
}
};
}
private boolean isTokenValid(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
private String getUsernameFromToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
private List<String> getRolesFromToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("roles", List.class);
}
private Mono<Void> onError(ServerWebExchange exchange, String error, HttpStatus status) {
exchange.getResponse().setStatusCode(status);
return exchange.getResponse().setComplete();
}
public static class Config {
// Configuration properties if needed
}
}

Example 2: Logging Filter

package com.example.apigateway.filters;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.Instant;
@Component
public class LoggingFilter implements GlobalFilter, Ordered {
private static final Logger logger = LoggerFactory.getLogger(LoggingFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
Instant startTime = Instant.now();
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
Instant endTime = Instant.now();
Duration duration = Duration.between(startTime, endTime);
String path = exchange.getRequest().getPath().value();
String method = exchange.getRequest().getMethod().name();
int status = exchange.getResponse().getStatusCode() != null ? 
exchange.getResponse().getStatusCode().value() : 500;
logger.info("{} {} - Status: {} - Duration: {}ms", 
method, path, status, duration.toMillis());
}));
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}

Example 3: Rate Limiting Filter

package com.example.apigateway.filters;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
@Component
public class CustomRateLimiterFilter extends AbstractGatewayFilterFactory<CustomRateLimiterFilter.Config> {
@Autowired
private ReactiveRedisTemplate<String, String> redisTemplate;
public CustomRateLimiterFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String clientIp = getClientIp(exchange);
String key = "rate_limit:" + clientIp + ":" + Instant.now().getEpochSecond();
return redisTemplate.opsForValue().increment(key)
.flatMap(count -> {
if (count == 1) {
// Set expiration on first request
return redisTemplate.expire(key, config.getWindowInSeconds(), TimeUnit.SECONDS)
.then(Mono.just(count));
}
return Mono.just(count);
})
.flatMap(count -> {
if (count > config.getMaxRequests()) {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
});
};
}
private String getClientIp(ServerWebExchange exchange) {
String xForwardedFor = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return exchange.getRequest().getRemoteAddress() != null ? 
exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() : "unknown";
}
public static class Config {
private int maxRequests = 10;
private int windowInSeconds = 60;
public int getMaxRequests() { return maxRequests; }
public void setMaxRequests(int maxRequests) { this.maxRequests = maxRequests; }
public int getWindowInSeconds() { return windowInSeconds; }
public void setWindowInSeconds(int windowInSeconds) { this.windowInSeconds = windowInSeconds; }
}
}

Fallback Handlers

Example 4: Circuit Breaker Fallback Controller

package com.example.apigateway.fallback;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/fallback")
public class FallbackController {
@GetMapping("/user-service")
public Mono<ResponseEntity<Map<String, Object>>> userServiceFallback() {
return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(createFallbackResponse("User service is temporarily unavailable")));
}
@GetMapping("/product-service")
public Mono<ResponseEntity<Map<String, Object>>> productServiceFallback() {
return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(createFallbackResponse("Product service is temporarily unavailable")));
}
@GetMapping("/order-service")
public Mono<ResponseEntity<Map<String, Object>>> orderServiceFallback() {
return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(createFallbackResponse("Order service is temporarily unavailable")));
}
private Map<String, Object> createFallbackResponse(String message) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", message);
response.put("timestamp", System.currentTimeMillis());
return response;
}
}

Security Configuration

Example 5: Gateway Security Configuration

package com.example.apigateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/auth/**").permitAll()
.pathMatchers("/actuator/**").permitAll()
.pathMatchers("/fallback/**").permitAll()
.pathMatchers("/api/users/register").permitAll()
.pathMatchers("/api/users/login").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(new JwtAuthenticationConverter()))
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

Dynamic Route Configuration

Example 6: Dynamic Route Repository

package com.example.apigateway.config;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionRepository;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Map;
@Component
public class RedisRouteDefinitionRepository implements RouteDefinitionRepository {
private final ReactiveRedisTemplate<String, RouteDefinition> redisTemplate;
private static final String ROUTE_KEY = "gateway_routes";
public RedisRouteDefinitionRepository(ReactiveRedisTemplate<String, RouteDefinition> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Flux<RouteDefinition> getRouteDefinitions() {
return redisTemplate.opsForHash().values(ROUTE_KEY)
.cast(RouteDefinition.class);
}
@Override
public Mono<Void> save(Mono<RouteDefinition> route) {
return route.flatMap(routeDefinition -> 
redisTemplate.opsForHash().put(ROUTE_KEY, routeDefinition.getId(), routeDefinition)
).then();
}
@Override
public Mono<Void> delete(Mono<String> routeId) {
return routeId.flatMap(id -> 
redisTemplate.opsForHash().remove(ROUTE_KEY, id)
).then();
}
}

Example 7: Route Refresh Controller

package com.example.apigateway.controller;
import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/gateway")
public class RouteRefreshController implements ApplicationEventPublisherAware {
private ApplicationEventPublisher publisher;
@PostMapping("/refresh")
public Mono<String> refreshRoutes() {
publisher.publishEvent(new RefreshRoutesEvent(this));
return Mono.just("Routes refreshed successfully");
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.publisher = applicationEventPublisher;
}
}

Monitoring and Metrics

Example 8: Custom Gateway Metrics

package com.example.apigateway.metrics;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.Instant;
@Component
public class MetricsFilter implements GlobalFilter, Ordered {
private final MeterRegistry meterRegistry;
public MetricsFilter(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
Instant startTime = Instant.now();
String path = exchange.getRequest().getPath().value();
String method = exchange.getRequest().getMethod().name();
return chain.filter(exchange).doOnSuccessOrError((result, throwable) -> {
Instant endTime = Instant.now();
Duration duration = Duration.between(startTime, endTime);
// Record request duration
meterRegistry.timer("gateway.requests.duration", 
"path", path,
"method", method,
"status", String.valueOf(exchange.getResponse().getStatusCode().value()))
.record(duration);
// Count requests
meterRegistry.counter("gateway.requests.total",
"path", path,
"method", method,
"status", String.valueOf(exchange.getResponse().getStatusCode().value()))
.increment();
});
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}

Testing

Example 9: Gateway Test Configuration

package com.example.apigateway.test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
class ApiGatewayTest {
@Autowired
private WebTestClient webClient;
@BeforeEach
void setUp() {
// Stub for user service
stubFor(get(urlEqualTo("/api/users/1"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"name\":\"John Doe\"}")));
}
@Test
void testUserServiceRoute() {
webClient.get().uri("/api/users/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.name").isEqualTo("John Doe");
}
@Test
void testAuthenticationFilter() {
webClient.get().uri("/api/orders/1")
.exchange()
.expectStatus().isUnauthorized();
}
@Test
void testRateLimiting() {
for (int i = 0; i < 15; i++) {
webClient.get().uri("/api/products/1")
.exchange();
}
webClient.get().uri("/api/products/1")
.exchange()
.expectStatus().isEqualTo(429); // Too Many Requests
}
}

Advanced Configuration

Example 10: Advanced Gateway Configuration

# application-advanced.yml
spring:
cloud:
gateway:
httpclient:
connect-timeout: 1000
response-timeout: 5s
pool:
type: elastic
max-connections: 1000
acquire-timeout: 45000
metrics:
enabled: true
default-filters:
- name: RequestHeader
args:
name: X-Gateway-Request
value: true
- name: AddResponseHeader
args:
name: X-Gateway-Response
value: processed
management:
metrics:
enable:
gateway: true
endpoint:
gateway:
enabled: true
metrics:
enabled: true
logging:
level:
org.springframework.cloud.gateway: DEBUG
reactor.netty: DEBUG
resilience4j:
timelimiter:
instances:
userService:
timeout-duration: 3s
retry:
instances:
userService:
max-attempts: 3
wait-duration: 500ms

Best Practices

  1. Use Service Discovery: Integrate with Eureka or Consul for dynamic routing
  2. Implement Circuit Breakers: Use Resilience4j for fault tolerance
  3. Rate Limiting: Protect backend services from excessive traffic
  4. Centralized Security: Handle authentication and authorization at gateway level
  5. Request/Response Logging: Log important request/response data
  6. CORS Configuration: Handle cross-origin requests at gateway level
  7. Monitoring: Use Spring Boot Actuator and Micrometer for monitoring
  8. Configuration Management: Use Spring Cloud Config for centralized configuration

Conclusion

Spring Cloud Gateway provides a powerful, flexible way to build API Gateways in Java. Key benefits include:

  • Performance: Built on WebFlux for non-blocking I/O
  • Flexibility: Custom filters and predicates
  • Integration: Seamless Spring ecosystem integration
  • Resilience: Built-in circuit breaker and retry patterns
  • Security: Comprehensive security features
  • Monitoring: Excellent observability support

This setup provides a robust foundation for building microservices architectures with proper API gateway patterns for routing, security, and resilience.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper