diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java new file mode 100644 index 00000000..3ce2e43f --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java @@ -0,0 +1,25 @@ +package com.cameleer3.server.app; + +import com.cameleer3.server.app.config.IngestionConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Main entry point for the Cameleer3 Server application. + *
+ * Scans {@code com.cameleer3.server.app} and {@code com.cameleer3.server.core} packages. + */ +@SpringBootApplication(scanBasePackages = { + "com.cameleer3.server.app", + "com.cameleer3.server.core" +}) +@EnableScheduling +@EnableConfigurationProperties(IngestionConfig.class) +public class Cameleer3ServerApplication { + + public static void main(String[] args) { + SpringApplication.run(Cameleer3ServerApplication.class, args); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java new file mode 100644 index 00000000..3d252c2c --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java @@ -0,0 +1,34 @@ +package com.cameleer3.server.app.config; + +import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC configuration. + *
+ * Registers the {@link ProtocolVersionInterceptor} on data and agent endpoint paths, + * excluding health, API docs, and Swagger UI paths that do not require protocol versioning. + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final ProtocolVersionInterceptor protocolVersionInterceptor; + + public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor) { + this.protocolVersionInterceptor = protocolVersionInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(protocolVersionInterceptor) + .addPathPatterns("/api/v1/data/**", "/api/v1/agents/**") + .excludePathPatterns( + "/api/v1/health", + "/api/v1/api-docs/**", + "/api/v1/swagger-ui/**", + "/api/v1/swagger-ui.html" + ); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/interceptor/ProtocolVersionInterceptor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/interceptor/ProtocolVersionInterceptor.java new file mode 100644 index 00000000..9eb307fa --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/interceptor/ProtocolVersionInterceptor.java @@ -0,0 +1,46 @@ +package com.cameleer3.server.app.interceptor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Map; + +/** + * Validates that all requests to data and agent endpoints include the + * {@code X-Cameleer-Protocol-Version} header with value {@code "1"}. + *
+ * Requests missing the header or using an unsupported version receive a 400 response + * with a JSON error body. + */ +@Component +public class ProtocolVersionInterceptor implements HandlerInterceptor { + + private static final String HEADER_NAME = "X-Cameleer-Protocol-Version"; + private static final String SUPPORTED_VERSION = "1"; + + private final ObjectMapper objectMapper; + + public ProtocolVersionInterceptor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + String version = request.getHeader(HEADER_NAME); + + if (version == null || !SUPPORTED_VERSION.equals(version)) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), + Map.of("error", "Missing or unsupported X-Cameleer-Protocol-Version header")); + return false; + } + + return true; + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java new file mode 100644 index 00000000..a8f92b66 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java @@ -0,0 +1,68 @@ +package com.cameleer3.server.app; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.clickhouse.ClickHouseContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.junit.jupiter.api.BeforeAll; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; + +/** + * Base class for integration tests requiring a ClickHouse instance. + *
+ * Uses Testcontainers to spin up a ClickHouse server and initializes the schema + * from {@code clickhouse/init/01-schema.sql} before the first test runs. + * Subclasses get a {@link JdbcTemplate} for direct database assertions. + */ +@Testcontainers +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public abstract class AbstractClickHouseIT { + + @Container + protected static final ClickHouseContainer CLICKHOUSE = + new ClickHouseContainer("clickhouse/clickhouse-server:25.3"); + + @Autowired + protected JdbcTemplate jdbcTemplate; + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", CLICKHOUSE::getJdbcUrl); + registry.add("spring.datasource.username", CLICKHOUSE::getUsername); + registry.add("spring.datasource.password", CLICKHOUSE::getPassword); + } + + @BeforeAll + static void initSchema() throws Exception { + String sql = Files.readString( + Path.of("clickhouse/init/01-schema.sql"), StandardCharsets.UTF_8); + + try (Connection conn = DriverManager.getConnection( + CLICKHOUSE.getJdbcUrl(), + CLICKHOUSE.getUsername(), + CLICKHOUSE.getPassword()); + Statement stmt = conn.createStatement()) { + // Execute each statement separately (separated by semicolons) + for (String statement : sql.split(";")) { + String trimmed = statement.trim(); + if (!trimmed.isEmpty()) { + stmt.execute(trimmed); + } + } + } + } +} diff --git a/cameleer3-server-app/src/test/resources/application-test.yml b/cameleer3-server-app/src/test/resources/application-test.yml new file mode 100644 index 00000000..cb294b0f --- /dev/null +++ b/cameleer3-server-app/src/test/resources/application-test.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: jdbc:ch://placeholder:8123/cameleer3 + username: default + password: "" + driver-class-name: com.clickhouse.jdbc.ClickHouseDriver + +ingestion: + buffer-capacity: 100 + batch-size: 10 + flush-interval-ms: 100