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} + + + + + + +