From 07f09d9f3fb840cb98ab7e387d72524570e8484c Mon Sep 17 00:00:00 2001 From: Suraj Date: Sat, 17 Jan 2026 18:35:45 +0530 Subject: [PATCH] feat(flw-api): implement /health and /version monitoring endpoints - Add HealthController and HealthService with MySQL and Redis health checks - Add VersionController and VersionService for Git commit and build info - Update JwtUserIdValidationFilter to bypass authentication for monitoring endpoints - Add git-commit-id-plugin and build-info goal to Maven configuration Closes #102 --- pom.xml | 30 ++ .../controller/health/HealthController.java | 60 ++++ .../controller/version/VersionController.java | 53 ++++ .../flw/service/health/HealthService.java | 282 ++++++++++++++++++ .../flw/service/version/VersionService.java | 170 +++++++++++ .../flw/utils/JwtUserIdValidationFilter.java | 15 +- 6 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/iemr/flw/controller/health/HealthController.java create mode 100644 src/main/java/com/iemr/flw/controller/version/VersionController.java create mode 100644 src/main/java/com/iemr/flw/service/health/HealthService.java create mode 100644 src/main/java/com/iemr/flw/service/version/VersionService.java diff --git a/pom.xml b/pom.xml index f3433e09..51321f86 100644 --- a/pom.xml +++ b/pom.xml @@ -293,6 +293,30 @@ + + pl.project13.maven + git-commit-id-plugin + 4.9.10 + + + get-the-git-infos + + revision + + initialize + + + + true + ${project.build.outputDirectory}/git.properties + + git.commit.id + git.build.time + + full + properties + + org.apache.maven.plugins maven-compiler-plugin @@ -410,6 +434,12 @@ repackage + + build-info + + build-info + + diff --git a/src/main/java/com/iemr/flw/controller/health/HealthController.java b/src/main/java/com/iemr/flw/controller/health/HealthController.java new file mode 100644 index 00000000..875d098f --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/health/HealthController.java @@ -0,0 +1,60 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.flw.controller.health; + +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.flw.service.health.HealthService; + +import io.swagger.v3.oas.annotations.Operation; + +@RestController +public class HealthController { + + private static final Logger logger = LoggerFactory.getLogger(HealthController.class); + + @Autowired + private HealthService healthService; + + @Operation(summary = "Health check endpoint") + @GetMapping("/health") + public ResponseEntity> health() { + logger.info("Health check endpoint called"); + + Map healthStatus = healthService.checkHealth(); + + // Return 503 if any service is down, 200 if all are up + String status = (String) healthStatus.get("status"); + HttpStatus httpStatus = "UP".equals(status) ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE; + + logger.info("Health check completed with status: {}", status); + return ResponseEntity.status(httpStatus).body(healthStatus); + } +} diff --git a/src/main/java/com/iemr/flw/controller/version/VersionController.java b/src/main/java/com/iemr/flw/controller/version/VersionController.java new file mode 100644 index 00000000..14829d03 --- /dev/null +++ b/src/main/java/com/iemr/flw/controller/version/VersionController.java @@ -0,0 +1,53 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.flw.controller.version; + +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.flw.service.version.VersionService; + +import io.swagger.v3.oas.annotations.Operation; + +@RestController +public class VersionController { + + private static final Logger logger = LoggerFactory.getLogger(VersionController.class); + + @Autowired + private VersionService versionService; + + @Operation(summary = "Version information") + @GetMapping(value = "/version", produces = MediaType.APPLICATION_JSON_VALUE) + public Map versionInformation() { + logger.info("version Controller Start"); + Map versionInfo = versionService.getVersionInfo(); + logger.info("version Controller End"); + return versionInfo; + } +} diff --git a/src/main/java/com/iemr/flw/service/health/HealthService.java b/src/main/java/com/iemr/flw/service/health/HealthService.java new file mode 100644 index 00000000..80399bd7 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/health/HealthService.java @@ -0,0 +1,282 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.flw.service.health; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.function.Supplier; + +import javax.sql.DataSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +public class HealthService { + + private static final Logger logger = LoggerFactory.getLogger(HealthService.class); + private static final String DB_HEALTH_CHECK_QUERY = "SELECT 1 as health_check"; + private static final String DB_VERSION_QUERY = "SELECT VERSION()"; + + @Autowired + private DataSource dataSource; + + @Autowired(required = false) + private RedisTemplate redisTemplate; + + @Value("${spring.datasource.url:unknown}") + private String dbUrl; + + @Value("${spring.redis.host:localhost}") + private String redisHost; + + @Value("${spring.redis.port:6379}") + private int redisPort; + + public Map checkHealth() { + Map healthStatus = new LinkedHashMap<>(); + Map components = new LinkedHashMap<>(); + boolean overallHealth = true; + + // Check MySQL connectivity + Map mysqlStatus = checkMySQLHealth(); + components.put("mysql", mysqlStatus); + if (!isHealthy(mysqlStatus)) { + overallHealth = false; + } + + // Check Redis connectivity if configured + if (redisTemplate != null) { + Map redisStatus = checkRedisHealth(); + components.put("redis", redisStatus); + if (!isHealthy(redisStatus)) { + overallHealth = false; + } + } + + healthStatus.put("status", overallHealth ? "UP" : "DOWN"); + healthStatus.put("timestamp", Instant.now().toString()); + healthStatus.put("components", components); + + logger.info("Health check completed - Overall status: {}", overallHealth ? "UP" : "DOWN"); + return healthStatus; + } + + private Map checkMySQLHealth() { + Map details = new LinkedHashMap<>(); + details.put("type", "MySQL"); + details.put("host", extractHost(dbUrl)); + details.put("port", extractPort(dbUrl)); + details.put("database", extractDatabaseName(dbUrl)); + + return performHealthCheck("MySQL", details, () -> { + try { + try (Connection connection = dataSource.getConnection()) { + if (connection.isValid(2)) { + try (PreparedStatement stmt = connection.prepareStatement(DB_HEALTH_CHECK_QUERY)) { + stmt.setQueryTimeout(3); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next() && rs.getInt(1) == 1) { + String version = getMySQLVersion(connection); + return new HealthCheckResult(true, version, null); + } + } + } + } + return new HealthCheckResult(false, null, "Connection validation failed"); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + private Map checkRedisHealth() { + Map details = new LinkedHashMap<>(); + details.put("type", "Redis"); + details.put("host", redisHost); + details.put("port", redisPort); + + return performHealthCheck("Redis", details, () -> { + String pong = redisTemplate.execute((RedisCallback) connection -> + connection.ping() + ); + if ("PONG".equals(pong)) { + String version = getRedisVersion(); + return new HealthCheckResult(true, version, null); + } + return new HealthCheckResult(false, null, "Ping returned unexpected response"); + }); + } + + + + /** + * Common health check execution pattern to reduce code duplication. + */ + private Map performHealthCheck(String componentName, + Map details, + Supplier checker) { + Map status = new LinkedHashMap<>(); + long startTime = System.currentTimeMillis(); + + try { + HealthCheckResult result = checker.get(); + long responseTime = System.currentTimeMillis() - startTime; + + if (result.isHealthy) { + logger.debug("{} health check: UP ({}ms)", componentName, responseTime); + status.put("status", "UP"); + details.put("responseTimeMs", responseTime); + if (result.version != null) { + details.put("version", result.version); + } + } else { + logger.warn("{} health check: {}", componentName, result.error); + status.put("status", "DOWN"); + details.put("error", result.error); + } + status.put("details", details); + return status; + } catch (Exception e) { + logger.error("{} health check failed: {}", componentName, e.getMessage()); + status.put("status", "DOWN"); + details.put("error", e.getMessage()); + details.put("errorType", e.getClass().getSimpleName()); + status.put("details", details); + return status; + } + } + + private boolean isHealthy(Map componentStatus) { + return "UP".equals(componentStatus.get("status")); + } + + private String getMySQLVersion(Connection connection) { + try (PreparedStatement stmt = connection.prepareStatement(DB_VERSION_QUERY); + ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return rs.getString(1); + } + } catch (Exception e) { + logger.debug("Could not retrieve MySQL version: {}", e.getMessage()); + } + return null; + } + + private String getRedisVersion() { + try { + Properties info = redisTemplate.execute((RedisCallback) connection -> + connection.serverCommands().info("server") + ); + if (info != null && info.containsKey("redis_version")) { + return info.getProperty("redis_version"); + } + } catch (Exception e) { + logger.debug("Could not retrieve Redis version: {}", e.getMessage()); + } + return null; + } + + + + private String extractHost(String jdbcUrl) { + if (jdbcUrl == null || "unknown".equals(jdbcUrl)) { + return "unknown"; + } + try { + String withoutPrefix = jdbcUrl.replaceFirst("jdbc:mysql://", ""); + int slashIndex = withoutPrefix.indexOf('/'); + String hostPort = slashIndex > 0 + ? withoutPrefix.substring(0, slashIndex) + : withoutPrefix; + int colonIndex = hostPort.indexOf(':'); + return colonIndex > 0 ? hostPort.substring(0, colonIndex) : hostPort; + } catch (Exception e) { + logger.debug("Could not extract host from URL: {}", e.getMessage()); + } + return "unknown"; + } + + private String extractPort(String jdbcUrl) { + if (jdbcUrl == null || "unknown".equals(jdbcUrl)) { + return "unknown"; + } + try { + String withoutPrefix = jdbcUrl.replaceFirst("jdbc:mysql://", ""); + int slashIndex = withoutPrefix.indexOf('/'); + String hostPort = slashIndex > 0 + ? withoutPrefix.substring(0, slashIndex) + : withoutPrefix; + int colonIndex = hostPort.indexOf(':'); + return colonIndex > 0 ? hostPort.substring(colonIndex + 1) : "3306"; + } catch (Exception e) { + logger.debug("Could not extract port from URL: {}", e.getMessage()); + } + return "3306"; + } + + private String extractDatabaseName(String jdbcUrl) { + if (jdbcUrl == null || "unknown".equals(jdbcUrl)) { + return "unknown"; + } + try { + int lastSlash = jdbcUrl.lastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < jdbcUrl.length() - 1) { + String afterSlash = jdbcUrl.substring(lastSlash + 1); + int queryStart = afterSlash.indexOf('?'); + if (queryStart > 0) { + return afterSlash.substring(0, queryStart); + } + return afterSlash; + } + } catch (Exception e) { + logger.debug("Could not extract database name: {}", e.getMessage()); + } + return "unknown"; + } + + /** + * Internal class to hold health check results. + */ + private static class HealthCheckResult { + final boolean isHealthy; + final String version; + final String error; + + HealthCheckResult(boolean isHealthy, String version, String error) { + this.isHealthy = isHealthy; + this.version = version; + this.error = error; + } + } +} diff --git a/src/main/java/com/iemr/flw/service/version/VersionService.java b/src/main/java/com/iemr/flw/service/version/VersionService.java new file mode 100644 index 00000000..a20ecbac --- /dev/null +++ b/src/main/java/com/iemr/flw/service/version/VersionService.java @@ -0,0 +1,170 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.flw.service.version; + +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.TimeZone; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Service +public class VersionService { + + private static final Logger logger = LoggerFactory.getLogger(VersionService.class); + + @Value("${app.version:unknown}") + private String appVersion; + + @Value("${maven.properties.path:META-INF/maven/com.iemr.common.flw/flw-api/pom.properties}") + private String mavenPropertiesPath; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Get version information as a Map for direct JSON serialization + * @return Map containing version information + */ + public Map getVersionInfo() { + return buildVersionInfo(); + } + + /** + * Get version information as JSON string (deprecated - use getVersionInfo() instead) + * @return JSON string containing version information + */ + @Deprecated + public String getVersionInformation() { + try { + Map versionInfo = buildVersionInfo(); + return objectMapper.writeValueAsString(versionInfo); + } catch (Exception e) { + logger.error("Error building version information", e); + return createErrorResponse(); + } + } + + private Map buildVersionInfo() { + Map versionInfo = new LinkedHashMap<>(); + + // Add Git information + addGitInformation(versionInfo); + + // Add build information + addBuildInformation(versionInfo); + + // Add current time + versionInfo.put("current.time", getCurrentIstTimeFormatted()); + + return versionInfo; + } + + private void addGitInformation(Map versionInfo) { + Properties gitProps = loadPropertiesFile("git.properties"); + if (gitProps != null) { + String commitId = gitProps.getProperty("git.commit.id", + gitProps.getProperty("git.commit.id.abbrev", "unknown")); + versionInfo.put("git.commit.id", commitId); + + String buildTime = gitProps.getProperty("git.build.time", + gitProps.getProperty("git.commit.time", + gitProps.getProperty("git.commit.timestamp", "unknown"))); + versionInfo.put("git.build.time", buildTime); + } else { + logger.warn("git.properties file not found. Git information will be unavailable."); + versionInfo.put("git.commit.id", "information unavailable"); + versionInfo.put("git.build.time", "information unavailable"); + } + } + + private void addBuildInformation(Map versionInfo) { + Properties buildProps = loadPropertiesFile("META-INF/build-info.properties"); + if (buildProps != null) { + String version = buildProps.getProperty("build.version", + buildProps.getProperty("build.version.number", + buildProps.getProperty("version", appVersion))); + versionInfo.put("build.version", version); + + String time = buildProps.getProperty("build.time", + buildProps.getProperty("build.timestamp", + buildProps.getProperty("timestamp", getCurrentIstTimeFormatted()))); + versionInfo.put("build.time", time); + } else { + logger.info("build-info.properties not found, trying Maven properties"); + Properties mavenProps = loadPropertiesFile(mavenPropertiesPath); + if (mavenProps != null) { + String version = mavenProps.getProperty("version", appVersion); + versionInfo.put("build.version", version); + versionInfo.put("build.time", getCurrentIstTimeFormatted()); + } else { + logger.warn("Neither build-info.properties nor Maven properties found."); + versionInfo.put("build.version", appVersion); + versionInfo.put("build.time", getCurrentIstTimeFormatted()); + } + } + } + + private String getCurrentIstTimeFormatted() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + sdf.setTimeZone(TimeZone.getTimeZone("Asia/Kolkata")); + return sdf.format(new Date()); + } + + private Properties loadPropertiesFile(String resourceName) { + ClassLoader classLoader = getClass().getClassLoader(); + try (InputStream inputStream = classLoader.getResourceAsStream(resourceName)) { + if (inputStream != null) { + Properties props = new Properties(); + props.load(inputStream); + return props; + } + } catch (IOException e) { + logger.warn("Could not load properties file: " + resourceName, e); + } + return null; + } + + private String createErrorResponse() { + try { + Map errorInfo = new LinkedHashMap<>(); + errorInfo.put("git.commit.id", "error retrieving information"); + errorInfo.put("git.build.time", "error retrieving information"); + errorInfo.put("build.version", appVersion); + errorInfo.put("build.time", getCurrentIstTimeFormatted()); + errorInfo.put("current.time", getCurrentIstTimeFormatted()); + return objectMapper.writeValueAsString(errorInfo); + } catch (Exception e) { + logger.error("Error creating error response", e); + return "{\"error\": \"Unable to retrieve version information\"}"; + } + } +} diff --git a/src/main/java/com/iemr/flw/utils/JwtUserIdValidationFilter.java b/src/main/java/com/iemr/flw/utils/JwtUserIdValidationFilter.java index 7cc17200..5aa80a71 100644 --- a/src/main/java/com/iemr/flw/utils/JwtUserIdValidationFilter.java +++ b/src/main/java/com/iemr/flw/utils/JwtUserIdValidationFilter.java @@ -36,8 +36,19 @@ public JwtUserIdValidationFilter(JwtAuthenticationUtil jwtAuthenticationUtil, public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; - HttpServletResponse response = (HttpServletResponse) servletResponse; + + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + + // FIRST: Check for health and version endpoints - skip ALL processing + if (path.equals("/health") || path.equals("/version") || + path.equals(contextPath + "/health") || path.equals(contextPath + "/version")) { + logger.info("Skipping JWT validation for monitoring endpoint: {}", path); + filterChain.doFilter(servletRequest, servletResponse); + return; + } + String origin = request.getHeader("Origin"); logger.debug("Incoming Origin: {}", origin); @@ -58,8 +69,6 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo return; } - String path = request.getRequestURI(); - String contextPath = request.getContextPath(); logger.info("JwtUserIdValidationFilter invoked for path: " + path); // Log cookies for debugging