feat: add gateway-service (Spring Cloud Gateway + Consul)
This commit is contained in:
8
gateway-service/.gitignore
vendored
Normal file
8
gateway-service/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.log
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
.env
|
||||
25
gateway-service/docker/Dockerfile
Normal file
25
gateway-service/docker/Dockerfile
Normal 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
96
gateway-service/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
15
gateway-service/src/main/resources/application-prod.yml
Normal file
15
gateway-service/src/main/resources/application-prod.yml
Normal 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
|
||||
86
gateway-service/src/main/resources/application.yml
Normal file
86
gateway-service/src/main/resources/application.yml
Normal 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
|
||||
14
gateway-service/src/main/resources/logback.xml
Normal file
14
gateway-service/src/main/resources/logback.xml
Normal 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>
|
||||
Reference in New Issue
Block a user