From b29442330a507dfb86d8a6bbe022d234d06b478b Mon Sep 17 00:00:00 2001 From: mcmikius Date: Fri, 23 Feb 2024 17:26:48 +0200 Subject: [PATCH] Improve Consul Health checks (#16) * refactor: add tests to consul health check * test: add test for consul health checks * docs: improve documentation * style: resolve lint warnings * test: improve test when fail --- .../ApplicationHealthChecks.swift | 3 +- Sources/HealthChecks/ChecksProtocol.swift | 11 +- .../ConsulHealthChecks/ConsulConfig.swift | 44 ++++-- .../ConsulHealthChecks.swift | 43 +++--- .../ConsulHealthChecksProtocol.swift | 2 +- .../ApplicationHealthChecksTests.swift | 18 +-- .../ConsulHealthChecksTests.swift | 138 +++++++++++++++++- .../HealthChecksTests/Mocks/MockClient.swift | 23 +++ 8 files changed, 224 insertions(+), 58 deletions(-) create mode 100644 Tests/HealthChecksTests/Mocks/MockClient.swift diff --git a/Sources/HealthChecks/ApplicationHealthChecks/ApplicationHealthChecks.swift b/Sources/HealthChecks/ApplicationHealthChecks/ApplicationHealthChecks.swift index 1f0683f..4c56b53 100644 --- a/Sources/HealthChecks/ApplicationHealthChecks/ApplicationHealthChecks.swift +++ b/Sources/HealthChecks/ApplicationHealthChecks/ApplicationHealthChecks.swift @@ -34,7 +34,7 @@ public struct ApplicationHealthChecks: ApplicationHealthChecksProtocol { /// Get uptime of the system. /// - Returns: A `HealthCheckItem` representing the application's uptime. public func uptime() -> HealthCheckItem { - let uptime = Date().timeIntervalSinceReferenceDate - app.launchTime + let uptime = Date().timeIntervalSinceReferenceDate - app.launchTime return HealthCheckItem( componentType: .system, observedValue: uptime, @@ -58,6 +58,7 @@ public struct ApplicationHealthChecks: ApplicationHealthChecksProtocol { break } } + result[""] = nil return result } } diff --git a/Sources/HealthChecks/ChecksProtocol.swift b/Sources/HealthChecks/ChecksProtocol.swift index e943f0e..c2fda5c 100644 --- a/Sources/HealthChecks/ChecksProtocol.swift +++ b/Sources/HealthChecks/ChecksProtocol.swift @@ -24,10 +24,13 @@ import Vapor -/// Groups func for get health check +/// The protocol defines a basic interface for performing health checks against different systems or services. public protocol ChecksProtocol { - /// Check with setup options - /// - Parameter options: array of `MeasurementType` - /// - Returns: dictionary `[String: HealthCheckItem]` + /// Performs health checks against Consul, returning a dictionary + /// of `HealthCheckItem`s for the specified components. + /// + /// - Parameters: + /// - options: An array of `MeasurementType`s indicating which checks to perform. + /// - Returns: A dictionary of `HealthCheckItem`s, keyed by component ID and measurement type. func check(for options: [MeasurementType]) async -> [String: HealthCheckItem] } diff --git a/Sources/HealthChecks/ConsulHealthChecks/ConsulConfig.swift b/Sources/HealthChecks/ConsulHealthChecks/ConsulConfig.swift index e637b4d..aa7f975 100644 --- a/Sources/HealthChecks/ConsulHealthChecks/ConsulConfig.swift +++ b/Sources/HealthChecks/ConsulHealthChecks/ConsulConfig.swift @@ -24,21 +24,41 @@ import Vapor -/// A generic `ConsulConfig` data that can be save in storage. +/// Represents configuration details for connecting to a Consul server. public struct ConsulConfig { - /// Is a unique identifier of the consul, in the application scope - /// Example: `43119325-63f5-4e14-9175-84e0e296c527` + /// A unique identifier for this Consul configuration within your application. + /// This ID is not related to Consul itself and can be used for internal reference. + /// Example: "43119325-63f5-4e14-9175-84e0e296c527" public let id: String - - /// Consul url - /// Example: `http://127.0.0.1:8500`, `https://xmpl-consul.example.com` + + /// The URL of the Consul server to connect to. + /// Example: "http://127.0.0.1:8500", "https://xmpl-consul.example.com" public let url: String - - /// Consul username - /// Example: `username` + + /// The username for authenticating with Consul (optional). + /// Example: "username" public let username: String? - - /// Consul password - /// Example: `password` + + /// The password for authenticating with Consul (optional). + /// Example: "password" public let password: String? + + /// Initializes a `ConsulConfig` with the specified details. + /// + /// - Parameters: + /// - id: The unique identifier for this configuration. + /// - url: The URL of the Consul server. + /// - username: The username for authentication (optional). + /// - password: The password for authentication (optional). + public init( + id: String, + url: String, + username: String? = nil, + password: String? = nil + ) { + self.id = id + self.url = url + self.username = username + self.password = password + } } diff --git a/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecks.swift b/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecks.swift index d025ec9..3614909 100644 --- a/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecks.swift +++ b/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecks.swift @@ -24,43 +24,48 @@ import Vapor -/// Service that provides consul health check functionality +/// Service that provides Consul health check functionality public struct ConsulHealthChecks: ConsulHealthChecksProtocol { - /// Instance of app as `Application` + /// Instance of the application as `Application` public let app: Application - + /// Check with setup options /// - Parameters: /// - options: array of `MeasurementType` /// - Returns: dictionary `[String: HealthCheckItem]` public func check(for options: [MeasurementType]) async -> [String: HealthCheckItem] { - var dict = ["": HealthCheckItem()] + var result = ["": HealthCheckItem()] let measurementTypes = Array(Set(options)) let dateNow = Date().timeIntervalSinceReferenceDate let response = await getStatus() for type in measurementTypes { switch type { case .responseTime: - let result = responseTime(from: response, dateNow) - dict["\(ComponentName.consul):\(MeasurementType.responseTime)"] = result + result["\(ComponentName.consul):\(MeasurementType.responseTime)"] = responseTime(from: response, dateNow) case .connections: - let result = status(response) - dict["\(ComponentName.consul):\(MeasurementType.connections)"] = result + result["\(ComponentName.consul):\(MeasurementType.connections)"] = status(response) default: break } } - return dict + result[""] = nil + return result } - + /// Get response for consul /// - Returns: `ClientResponse` func getStatus() async -> ClientResponse { - let url = app.consulConfig?.url ?? Constants.consulUrl + guard let url = app.consulConfig?.url else { + app.logger.error("ERROR: Consul URL is not configured.") + return ClientResponse() + } let path = Constants.consulStatusPath let uri = URI(string: url + path) var headers = HTTPHeaders() - if let username = app.consulConfig?.username, !username.isEmpty, let password = app.consulConfig?.password, !password.isEmpty { + if let username = app.consulConfig?.username, + !username.isEmpty, + let password = app.consulConfig?.password, + !password.isEmpty { headers.basicAuthorization = BasicAuthorization(username: username, password: password) } do { @@ -75,14 +80,12 @@ public struct ConsulHealthChecks: ConsulHealthChecksProtocol { /// - Parameter response: `ClientResponse` /// - Returns: `HealthCheckItem` func status(_ response: ClientResponse) -> HealthCheckItem { - let url = app.consulConfig?.url ?? Constants.consulUrl - let path = Constants.consulStatusPath return HealthCheckItem( componentId: app.consulConfig?.id, componentType: .component, status: response.status == .ok ? .pass : .fail, - time: app.dateTimeISOFormat.string(from: Date()), - output: response.status != .ok ? "Error response from uri - \(url + path), with http status - \(response.status)" : nil, + time: response.status == .ok ? app.dateTimeISOFormat.string(from: Date()) : nil, + output: response.status != .ok ? "Error response from consul, with http status - \(response.status)" : nil, links: nil, node: nil ) @@ -94,16 +97,14 @@ public struct ConsulHealthChecks: ConsulHealthChecksProtocol { /// - start: `TimeInterval` /// - Returns: `HealthCheckItem` func responseTime(from response: ClientResponse, _ start: TimeInterval) -> HealthCheckItem { - let url = app.consulConfig?.url ?? Constants.consulUrl - let path = Constants.consulStatusPath return HealthCheckItem( componentId: app.consulConfig?.id, componentType: .component, - observedValue: Date().timeIntervalSinceReferenceDate - start, + observedValue: response.status == .ok ? Date().timeIntervalSinceReferenceDate - start : 0, observedUnit: "s", status: response.status == .ok ? .pass : .fail, - time: app.dateTimeISOFormat.string(from: Date()), - output: response.status != .ok ? "Error response from uri - \(url + path), with http status - \(response.status)" : nil, + time: response.status == .ok ? app.dateTimeISOFormat.string(from: Date()) : nil, + output: response.status != .ok ? "Error response from consul, with http status - \(response.status)" : nil, links: nil, node: nil ) diff --git a/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecksProtocol.swift b/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecksProtocol.swift index 2c1441e..43af7ce 100644 --- a/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecksProtocol.swift +++ b/Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecksProtocol.swift @@ -24,5 +24,5 @@ import Vapor -/// Groups func for get consul health check +/// Protocol defining an interface for performing Consul health checks. public protocol ConsulHealthChecksProtocol: ChecksProtocol {} diff --git a/Tests/HealthChecksTests/ApplicationHealthChecksTests.swift b/Tests/HealthChecksTests/ApplicationHealthChecksTests.swift index 02346df..651c64b 100644 --- a/Tests/HealthChecksTests/ApplicationHealthChecksTests.swift +++ b/Tests/HealthChecksTests/ApplicationHealthChecksTests.swift @@ -71,7 +71,7 @@ final class ApplicationHealthChecksTests: XCTestCase { let healthChecks = ApplicationHealthChecks(app: app) let checks = await healthChecks.check(for: [.uptime]) - + XCTAssertEqual(checks.count, 1) XCTAssertNotNil(checks) XCTAssertTrue(checks.keys.contains("uptime")) guard let uptimeItem = checks["uptime"] else { @@ -93,20 +93,6 @@ final class ApplicationHealthChecksTests: XCTestCase { let healthChecks = ApplicationHealthChecks(app: app) let checks = await healthChecks.check(for: [.connections]) - let expectedResult = [ - "": HealthCheckItem( - componentId: nil, - componentType: nil, - observedValue: nil, - observedUnit: nil, - status: nil, - affectedEndpoints: nil, - time: nil, - output: nil, - links: nil, - node: nil - ) - ] - XCTAssertEqual(checks, expectedResult) // Expect empty result, as .memory is not supported + XCTAssertEqual(checks.count, 0) // Expect empty result, as .memory is not supported } } diff --git a/Tests/HealthChecksTests/ConsulHealthChecksTests.swift b/Tests/HealthChecksTests/ConsulHealthChecksTests.swift index 8eed2a5..114bf06 100644 --- a/Tests/HealthChecksTests/ConsulHealthChecksTests.swift +++ b/Tests/HealthChecksTests/ConsulHealthChecksTests.swift @@ -33,9 +33,7 @@ final class ConsulHealthChecksTests: XCTestCase { app.consulHealthChecks = ConsulHealthChecksMock() let consulConfig = ConsulConfig( id: UUID().uuidString, - url: Constants.consulUrl, - username: "username", - password: "password" + url: Constants.consulUrl ) app.consulConfig = consulConfig let result = await app.consulHealthChecks?.check(for: [MeasurementType.responseTime, MeasurementType.connections]) @@ -46,4 +44,138 @@ final class ConsulHealthChecksTests: XCTestCase { XCTAssertEqual(app.consulConfig?.username, consulConfig.username) XCTAssertEqual(app.consulConfig?.password, consulConfig.password) } + + func testCheckForBothSuccess() async { + let app = Application(.testing) + defer { app.shutdown() } + app.consulConfig = ConsulConfig( + id: String(UUID()), + url: "consul-url" + ) + let clientResponse = ClientResponse(status: .ok) + app.clients.use { app in + MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse) + } + let healthChecks = ConsulHealthChecks(app: app) + let check = await healthChecks.check(for: [.responseTime, .connections]) + XCTAssertEqual(check.count, 2) + + // Response time check + guard let responseTimeCheck = check["\(ComponentName.consul):\(MeasurementType.responseTime)"] else { + return XCTFail("no have key for response time") + } + XCTAssertEqual(responseTimeCheck.status, .pass) + guard let observedValue = responseTimeCheck.observedValue else { + return XCTFail("no have observed value") + } + XCTAssertGreaterThan(observedValue, 0) + XCTAssertNil(responseTimeCheck.output) + + // Connections check + guard let connectionsCheck = check["\(ComponentName.consul):\(MeasurementType.connections)"] else { + return XCTFail("no have key for connections") + } + XCTAssertEqual(connectionsCheck.status, .pass) + XCTAssertNil(connectionsCheck.observedValue) + XCTAssertNil(connectionsCheck.output) + } + + func testCheckStatusSuccess() async { + let app = Application(.testing) + defer { app.shutdown() } + app.consulConfig = ConsulConfig( + id: String(UUID()), + url: "consul-url" + ) + let clientResponse = ClientResponse(status: .ok) + app.clients.use { app in + MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse) + } + let healthChecks = ConsulHealthChecks(app: app) + let response = await healthChecks.getStatus() + let result = healthChecks.status(response) + + XCTAssertEqual(result.status, .pass) + XCTAssertNil(result.observedValue) + XCTAssertNil(result.output) + } + + func testCheckStatusFail() async { + let app = Application(.testing) + defer { app.shutdown() } + let clientResponse = ClientResponse(status: .badRequest) + let healthChecks = ConsulHealthChecks(app: app) + let response = await healthChecks.getStatus() + let result = healthChecks.status(clientResponse) + + XCTAssertEqual(result.status, .fail) + XCTAssertNil(result.observedValue) + XCTAssertNotNil(result.output) + } + + func testCheckResponseTimeSuccess() async { + let app = Application(.testing) + defer { app.shutdown() } + app.consulConfig = ConsulConfig( + id: String(UUID()), + url: "consul-url" + ) + let clientResponse = ClientResponse(status: .ok) + app.clients.use { app in + MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse) + } + let healthChecks = ConsulHealthChecks(app: app) + let response = await healthChecks.getStatus() + + let result = healthChecks.responseTime(from: response, Date().timeIntervalSinceReferenceDate) + XCTAssertEqual(result.status, .pass) + guard let observedValue = result.observedValue else { + return XCTFail("no have observed value") + } + XCTAssertGreaterThan(observedValue, 0) + XCTAssertNil(result.output) + } + + func testCheckResponseTimeFail() async { + let app = Application(.testing) + defer { app.shutdown() } + let clientResponse = ClientResponse(status: .badRequest) + let healthChecks = ConsulHealthChecks(app: app) + + let result = healthChecks.responseTime(from: clientResponse, Date().timeIntervalSinceReferenceDate) + XCTAssertEqual(result.status, .fail) + guard let observedValue = result.observedValue else { + return XCTFail("no have observed value") + } + XCTAssertEqual(observedValue, 0) + XCTAssertNotNil(result.output) + } + + func testGetStatusSuccessWithAuth() async { + let app = Application(.testing) + defer { app.shutdown() } + app.consulConfig = ConsulConfig( + id: String(UUID()), + url: "https://example.com/status", + username: "user", + password: "password" + ) + let clientResponse = ClientResponse(status: .ok) + app.clients.use { app in + MockClient(eventLoop: app.eventLoopGroup.next(), clientResponse: clientResponse) + } + let healthChecks = ConsulHealthChecks(app: app) + let response = await healthChecks.getStatus() + + XCTAssertEqual(response.status, .ok) + } + + func testCheckHandlesUnsupportedTypes() async { + let app = Application(.testing) + defer { app.shutdown() } + + let healthChecks = ConsulHealthChecks(app: app) + let checks = await healthChecks.check(for: [.uptime]) + XCTAssertEqual(checks.count, 0) // Expect empty result, as .memory is not supported + } } diff --git a/Tests/HealthChecksTests/Mocks/MockClient.swift b/Tests/HealthChecksTests/Mocks/MockClient.swift new file mode 100644 index 0000000..46400f1 --- /dev/null +++ b/Tests/HealthChecksTests/Mocks/MockClient.swift @@ -0,0 +1,23 @@ +// +// MockClient.swift +// +// +// Created by Mykhailo Bondarenko on 23.02.2024. +// + +import Vapor + +struct MockClient: Client { + var eventLoop: EventLoop + var clientResponse: ClientResponse + + func send(_ request: ClientRequest) -> EventLoopFuture { + self.eventLoop.makeSucceededFuture(self.clientResponse) + } + + func delegating(to eventLoop: EventLoop) -> Client { + self + } +} + +extension ClientResponse: @unchecked Sendable {}