Skip to content

Commit

Permalink
Improve Consul Health checks (#16)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mcmikius authored Feb 23, 2024
1 parent f02aa42 commit b294423
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -58,6 +58,7 @@ public struct ApplicationHealthChecks: ApplicationHealthChecksProtocol {
break
}
}
result[""] = nil
return result
}
}
11 changes: 7 additions & 4 deletions Sources/HealthChecks/ChecksProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
44 changes: 32 additions & 12 deletions Sources/HealthChecks/ConsulHealthChecks/ConsulConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
43 changes: 22 additions & 21 deletions Sources/HealthChecks/ConsulHealthChecks/ConsulHealthChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
)
Expand All @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
18 changes: 2 additions & 16 deletions Tests/HealthChecksTests/ApplicationHealthChecksTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
138 changes: 135 additions & 3 deletions Tests/HealthChecksTests/ConsulHealthChecksTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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
}
}
23 changes: 23 additions & 0 deletions Tests/HealthChecksTests/Mocks/MockClient.swift
Original file line number Diff line number Diff line change
@@ -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<ClientResponse> {
self.eventLoop.makeSucceededFuture(self.clientResponse)
}

func delegating(to eventLoop: EventLoop) -> Client {
self
}
}

extension ClientResponse: @unchecked Sendable {}

0 comments on commit b294423

Please sign in to comment.