feat: add gateway-service (Spring Cloud Gateway + Consul)

This commit is contained in:
2026-02-17 19:34:49 +01:00
parent 5a05bb2147
commit 008aceb64b
11 changed files with 464 additions and 0 deletions

8
gateway-service/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
target/
*.class
*.jar
*.log
.idea/
*.iml
.DS_Store
.env

View File

@@ -0,0 +1,25 @@
# --- Build stage ---
FROM maven:3.9.9-eclipse-temurin-24-alpine AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B
# --- Runtime stage ---
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build /app/target/*.jar app.jar
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]

96
gateway-service/pom.xml Normal file
View File

@@ -0,0 +1,96 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.posthub</groupId>
<artifactId>gateway-service</artifactId>
<version>1.0.0</version>
<name>Gateway Service</name>
<description>API Gateway for Post Hub Platform</description>
<properties>
<java.version>25</java.version>
<spring-boot.version>3.5.7</spring-boot.version>
<spring-cloud.version>2025.0.0</spring-cloud.version>
<lombok.version>1.18.40</lombok.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
<relativePath/>
</parent>
<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>
<dependencies>
<!-- Spring Cloud Gateway (reactive / WebFlux) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Consul service discovery -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
<!-- Health checks -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<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>

View File

@@ -0,0 +1,12 @@
package com.posthub.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

View File

@@ -0,0 +1,38 @@
package com.posthub.gateway.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Value("${gateway.cors.allowed-origins:*}")
private String allowedOrigins;
@Bean
public CorsWebFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
if ("*".equals(allowedOrigins)) {
config.addAllowedOriginPattern("*");
} else {
config.setAllowedOrigins(List.of(allowedOrigins.split(",")));
}
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}

View File

@@ -0,0 +1,65 @@
package com.posthub.gateway.config;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.net.ConnectException;
import java.util.Map;
@Slf4j
@Order(-1)
@Component
public class GlobalErrorHandler implements ErrorWebExceptionHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
HttpStatus status;
String message;
if (ex instanceof ResponseStatusException rse) {
status = HttpStatus.valueOf(rse.getStatusCode().value());
message = rse.getReason() != null ? rse.getReason() : status.getReasonPhrase();
} else if (ex instanceof ConnectException) {
status = HttpStatus.SERVICE_UNAVAILABLE;
message = "Downstream service is unavailable";
log.error("Service unavailable: {}", ex.getMessage());
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = "Internal gateway error";
log.error("Unhandled gateway error", ex);
}
response.setStatusCode(status);
Map<String, Object> errorBody = Map.of(
"status", status.value(),
"error", status.getReasonPhrase(),
"message", message,
"path", exchange.getRequest().getURI().getPath()
);
try {
byte[] bytes = objectMapper.writeValueAsBytes(errorBody);
DataBuffer buffer = response.bufferFactory().wrap(bytes);
return response.writeWith(Mono.just(buffer));
} catch (JsonProcessingException e) {
return response.setComplete();
}
}
}

View File

@@ -0,0 +1,63 @@
package com.posthub.gateway.filter;
import lombok.extern.slf4j.Slf4j;
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.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.List;
/**
* Per-route filter: validates that Authorization header contains a Bearer token.
* Does NOT verify JWT signature — downstream services handle that.
*
* Usage in application.yml:
* filters:
* - AuthFilter
*/
@Slf4j
@Component
public class AuthFilter extends AbstractGatewayFilterFactory<AuthFilter.Config> {
public AuthFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
List<String> authHeaders = request.getHeaders().getOrEmpty(HttpHeaders.AUTHORIZATION);
if (authHeaders.isEmpty()) {
log.warn("Missing Authorization header for {}", path);
return unauthorized(exchange);
}
String token = authHeaders.getFirst();
if (token == null || !token.startsWith("Bearer ") || !token.contains(".")) {
log.warn("Invalid Authorization header format for {}", path);
return unauthorized(exchange);
}
log.debug("Token present for {}", path);
return chain.filter(exchange);
};
}
private Mono<Void> unauthorized(org.springframework.web.server.ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
public static class Config {
}
}

View File

@@ -0,0 +1,42 @@
package com.posthub.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class RequestLoggingFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String method = request.getMethod().name();
String path = request.getURI().getPath();
String clientIp = request.getRemoteAddress() != null
? request.getRemoteAddress().getAddress().getHostAddress()
: "unknown";
log.info(">>> {} {} from {}", method, path, clientIp);
long startTime = System.currentTimeMillis();
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
long duration = System.currentTimeMillis() - startTime;
int status = exchange.getResponse().getStatusCode() != null
? exchange.getResponse().getStatusCode().value()
: 0;
log.info("<<< {} {} -> {} ({}ms)", method, path, status, duration);
}));
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}

View File

@@ -0,0 +1,15 @@
spring:
cloud:
consul:
host: ${CONSUL_HOST:consul}
port: ${CONSUL_PORT:8500}
gateway:
cors:
allowed-origins: ${CORS_ORIGINS:https://balexvic.com}
logging:
level:
root: WARN
com.posthub.gateway: INFO
org.springframework.cloud.gateway: WARN

View File

@@ -0,0 +1,86 @@
server:
port: 8080
forward-headers-strategy: framework
spring:
application:
name: gateway-service
cloud:
consul:
host: ${CONSUL_HOST:localhost}
port: ${CONSUL_PORT:8500}
discovery:
register: true
enabled: true
health-check-path: /actuator/health
health-check-interval: 15s
prefer-ip-address: true
instance-id: ${spring.application.name}:${random.value}
# Spring Cloud Gateway 2025.0 — new prefix: spring.cloud.gateway.server.webflux
gateway:
server:
webflux:
# Trust Nginx reverse proxy for forwarded headers
trusted-proxies: 127\.0\.0\.1|10\.0\.0\..*|172\.1[6-9]\..*|172\.2[0-9]\..*|172\.3[0-1]\..*|192\.168\..*
discovery:
locator:
enabled: true
lower-case-service-id: true
httpclient:
connect-timeout: 5000
response-timeout: 60s
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST
routes:
# RAG Service - actuator (health, info)
- id: rag-service-actuator
uri: lb://rag-service
predicates:
- Path=/api/rag/actuator/**
filters:
- RewritePath=/api/rag/actuator(?<segment>/?.*), /actuator${segment}
# RAG Service - API endpoints
- id: rag-service-api
uri: lb://rag-service
predicates:
- Path=/api/rag/**
- Method=GET,POST,PUT,DELETE
filters:
- RewritePath=/api/rag(?<segment>/?.*), ${segment}
# Analytics Service (will be added later)
# - id: analytics-service-api
# uri: lb://analytics-service
# predicates:
# - Path=/api/analytics/**
# - Method=GET,POST
# filters:
# - RewritePath=/api/analytics(?<segment>/?.*), ${segment}
gateway:
cors:
allowed-origins: ${CORS_ORIGINS:*}
management:
endpoints:
web:
exposure:
include: health,info,gateway
endpoint:
health:
show-details: always
gateway:
enabled: true
logging:
level:
root: INFO
com.posthub.gateway: DEBUG
org.springframework.cloud.gateway: INFO

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>