diff --git a/gateway-service/.gitignore b/gateway-service/.gitignore
new file mode 100644
index 0000000..ee3df5e
--- /dev/null
+++ b/gateway-service/.gitignore
@@ -0,0 +1,8 @@
+target/
+*.class
+*.jar
+*.log
+.idea/
+*.iml
+.DS_Store
+.env
diff --git a/gateway-service/docker/Dockerfile b/gateway-service/docker/Dockerfile
new file mode 100644
index 0000000..1db3d06
--- /dev/null
+++ b/gateway-service/docker/Dockerfile
@@ -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"]
diff --git a/gateway-service/pom.xml b/gateway-service/pom.xml
new file mode 100644
index 0000000..9487884
--- /dev/null
+++ b/gateway-service/pom.xml
@@ -0,0 +1,96 @@
+
+
+ 4.0.0
+
+ com.posthub
+ gateway-service
+ 1.0.0
+ Gateway Service
+ API Gateway for Post Hub Platform
+
+
+ 25
+ 3.5.7
+ 2025.0.0
+ 1.18.40
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.7
+
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-consul-discovery
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ true
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/GatewayApplication.java b/gateway-service/src/main/java/com/posthub/gateway/GatewayApplication.java
new file mode 100644
index 0000000..de02c83
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/GatewayApplication.java
@@ -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);
+ }
+}
diff --git a/gateway-service/src/main/java/com/posthub/gateway/config/CorsConfig.java b/gateway-service/src/main/java/com/posthub/gateway/config/CorsConfig.java
new file mode 100644
index 0000000..457dd2e
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/config/CorsConfig.java
@@ -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);
+ }
+}
diff --git a/gateway-service/src/main/java/com/posthub/gateway/config/GlobalErrorHandler.java b/gateway-service/src/main/java/com/posthub/gateway/config/GlobalErrorHandler.java
new file mode 100644
index 0000000..867815f
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/config/GlobalErrorHandler.java
@@ -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 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 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();
+ }
+ }
+}
diff --git a/gateway-service/src/main/java/com/posthub/gateway/filter/AuthFilter.java b/gateway-service/src/main/java/com/posthub/gateway/filter/AuthFilter.java
new file mode 100644
index 0000000..c315ebc
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/filter/AuthFilter.java
@@ -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 {
+
+ public AuthFilter() {
+ super(Config.class);
+ }
+
+ @Override
+ public GatewayFilter apply(Config config) {
+ return (exchange, chain) -> {
+ ServerHttpRequest request = exchange.getRequest();
+ String path = request.getURI().getPath();
+
+ List 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 unauthorized(org.springframework.web.server.ServerWebExchange exchange) {
+ ServerHttpResponse response = exchange.getResponse();
+ response.setStatusCode(HttpStatus.UNAUTHORIZED);
+ return response.setComplete();
+ }
+
+ public static class Config {
+ }
+}
diff --git a/gateway-service/src/main/java/com/posthub/gateway/filter/RequestLoggingFilter.java b/gateway-service/src/main/java/com/posthub/gateway/filter/RequestLoggingFilter.java
new file mode 100644
index 0000000..9c17ddf
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/filter/RequestLoggingFilter.java
@@ -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 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;
+ }
+}
diff --git a/gateway-service/src/main/resources/application-prod.yml b/gateway-service/src/main/resources/application-prod.yml
new file mode 100644
index 0000000..bc6b8b1
--- /dev/null
+++ b/gateway-service/src/main/resources/application-prod.yml
@@ -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
diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml
new file mode 100644
index 0000000..eed24b4
--- /dev/null
+++ b/gateway-service/src/main/resources/application.yml
@@ -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(?/?.*), /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}
+
+ # 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}
+
+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
diff --git a/gateway-service/src/main/resources/logback.xml b/gateway-service/src/main/resources/logback.xml
new file mode 100644
index 0000000..ac67ff4
--- /dev/null
+++ b/gateway-service/src/main/resources/logback.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ ${LOG_PATTERN}
+
+
+
+
+
+
+