diff --git a/Package.resolved b/Package.resolved index 0289bc7..e705437 100644 --- a/Package.resolved +++ b/Package.resolved @@ -81,6 +81,24 @@ "version" : "1.20.0" } }, + { + "identity" : "redis", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/redis.git", + "state" : { + "revision" : "2a8d3e4639b90b39b74309b54216bdfd9cb52b41", + "version" : "5.0.0-alpha.2.2" + } + }, + { + "identity" : "redistack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/RediStack.git", + "state" : { + "revision" : "622ce440f90d79b58e45f3a3efdd64c51d1dfd17", + "version" : "1.6.2" + } + }, { "identity" : "routing-kit", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 060fad5..3433675 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,9 @@ let package = Package( // 🖋 Swift ORM (queries, models, and relations) for NoSQL and SQL databases. .package(url: "https://github.com/vapor/fluent.git", from: "4.1.0"), // 🐘 Swift ORM (queries, models, relations, etc) built on PostgreSQL. - .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.1.1") + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.1.1"), + // Vapor provider for RedisKit + RedisNIO + .package(url: "https://github.com/vapor/redis.git", from: "5.0.0-alpha.2.2"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -30,7 +32,8 @@ let package = Package( dependencies: [ .product(name: "Vapor", package: "vapor"), .product(name: "Fluent", package: "fluent"), - .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver") + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), + .product(name: "Redis", package: "redis"), ] ), .testTarget( diff --git a/Sources/HealthChecks/Extensions/Application+Extensions.swift b/Sources/HealthChecks/Extensions/Application+Extensions.swift index 02a3379..78ba26f 100644 --- a/Sources/HealthChecks/Extensions/Application+Extensions.swift +++ b/Sources/HealthChecks/Extensions/Application+Extensions.swift @@ -73,6 +73,18 @@ extension Application { get { storage[LaunchTimeKey.self] ?? Date().timeIntervalSinceReferenceDate } set { storage[LaunchTimeKey.self] = newValue } } + + /// A `redisIdKey` conform to StorageKey protocol + private struct RedisIdKey: StorageKey { + /// Less verbose typealias for `String`. + typealias Value = String + } + + /// Setup `redisIdKey` in application storage + public var redisId: String? { + get { storage[RedisIdKey.self] } + set { storage[RedisIdKey.self] = newValue } + } } extension Application { @@ -125,6 +137,18 @@ extension Application { get { storage[ApplicationHealthChecksKey.self] } set { storage[ApplicationHealthChecksKey.self] = newValue } } + + /// A `RedisHealthChecksKey` conform to StorageKey protocol + public struct RedisHealthChecksKey: StorageKey { + /// Less verbose typealias for `RedisHealthChecksProtocol`. + public typealias Value = RedisHealthChecksProtocol + } + + /// Setup `redisHealthChecks` in application storage + public var redisHealthChecks: RedisHealthChecksProtocol? { + get { storage[RedisHealthChecksKey.self] } + set { storage[RedisHealthChecksKey.self] = newValue } + } } extension Application { diff --git a/Sources/HealthChecks/RedisHealthChecks/RedisChecksProtocol.swift b/Sources/HealthChecks/RedisHealthChecks/RedisChecksProtocol.swift new file mode 100644 index 0000000..6fd24d0 --- /dev/null +++ b/Sources/HealthChecks/RedisHealthChecks/RedisChecksProtocol.swift @@ -0,0 +1,40 @@ +// FS App Health Checks +// Copyright (C) 2024 FREEDOM SPACE, LLC + +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// +// RedisChecksProtocol.swift +// +// +// Created by Mykola Buhaiov on 21.02.2024. +// + +import Vapor + +/// Groups func for get redis health check +public protocol RedisChecksProtocol { + /// Get redis connection + /// - Returns: `HealthCheckItem` + func connection() async -> HealthCheckItem + + /// Get response time from redis + /// - Returns: `HealthCheckItem` + func getResponseTime() async -> HealthCheckItem + + /// Get pong from redis + /// - Returns: `String` + func pong() async -> String +} diff --git a/Sources/HealthChecks/RedisHealthChecks/RedisHealthChecks.swift b/Sources/HealthChecks/RedisHealthChecks/RedisHealthChecks.swift new file mode 100644 index 0000000..9d67f2c --- /dev/null +++ b/Sources/HealthChecks/RedisHealthChecks/RedisHealthChecks.swift @@ -0,0 +1,101 @@ +// FS App Health Checks +// Copyright (C) 2024 FREEDOM SPACE, LLC + +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// +// RedisHealthChecks.swift +// +// +// Created by Mykola Buhaiov on 21.02.2024. +// + +import Vapor +import Fluent +import Redis + +/// Service that provides redis health check functionality +public struct RedisHealthChecks: RedisHealthChecksProtocol { + /// Instance of app as `Application` + public let app: Application + + /// Get redis connection + /// - Returns: `HealthCheckItem` + public func connection() async -> HealthCheckItem { + let dateNow = Date().timeIntervalSinceReferenceDate + let response = await pong() + let result = HealthCheckItem( + componentId: app.redisId, + componentType: .datastore, + observedValue: Date().timeIntervalSinceReferenceDate - dateNow, + observedUnit: "s", + status: response.lowercased().contains("pong") ? .pass : .fail, + time: app.dateTimeISOFormat.string(from: Date()), + output: !response.lowercased().contains("pong") ? response : nil, + links: nil, + node: nil + ) + return result + } + + /// Get response time from redis + /// - Returns: `HealthCheckItem` + public func getResponseTime() async -> HealthCheckItem { + let dateNow = Date().timeIntervalSinceReferenceDate + let response = await pong() + let result = HealthCheckItem( + componentId: app.redisId, + componentType: .datastore, + observedValue: Date().timeIntervalSinceReferenceDate - dateNow, + observedUnit: "s", + status: response.lowercased().contains("pong") ? .pass : .fail, + time: app.dateTimeISOFormat.string(from: Date()), + output: !response.lowercased().contains("pong") ? response : nil, + links: nil, + node: nil + ) + return result + } + + /// Get pong from redis + /// - Returns: `String` + public func pong() async -> String { + let result = try? await app.redis.ping().get() + var connectionDescription = "ERROR: No connect to Redis database" + if let result, result.lowercased().contains("pong") { + connectionDescription = result + } + return connectionDescription + } + + /// Check with setup options + /// - Parameter options: array of `MeasurementType` + /// - Returns: dictionary `[String: HealthCheckItem]` + public func checkHealth(for options: [MeasurementType]) async -> [String: HealthCheckItem] { + var result = ["": HealthCheckItem()] + let measurementTypes = Array(Set(options)) + for type in measurementTypes { + switch type { + case .responseTime: + result["\(ComponentName.redis):\(MeasurementType.responseTime)"] = await getResponseTime() + case .connections: + result["\(ComponentName.redis):\(MeasurementType.connections)"] = await connection() + default: + break + } + } + return result + } +} diff --git a/Sources/HealthChecks/RedisHealthChecks/RedisHealthChecksProtocol.swift b/Sources/HealthChecks/RedisHealthChecks/RedisHealthChecksProtocol.swift new file mode 100644 index 0000000..3e7d0e1 --- /dev/null +++ b/Sources/HealthChecks/RedisHealthChecks/RedisHealthChecksProtocol.swift @@ -0,0 +1,28 @@ +// FS App Health Checks +// Copyright (C) 2024 FREEDOM SPACE, LLC + +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// +// RedisHealthChecksProtocol.swift +// +// +// Created by Mykola Buhaiov on 21.02.2024. +// + +import Vapor + +/// Groups func for get redis health check +public protocol RedisHealthChecksProtocol: RedisChecksProtocol, ChecksProtocol {} diff --git a/Tests/HealthChecksTests/Mocks/RedisHealthChecksMock.swift b/Tests/HealthChecksTests/Mocks/RedisHealthChecksMock.swift new file mode 100644 index 0000000..0c79e84 --- /dev/null +++ b/Tests/HealthChecksTests/Mocks/RedisHealthChecksMock.swift @@ -0,0 +1,62 @@ +// FS App Health Checks +// Copyright (C) 2024 FREEDOM SPACE, LLC + +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// +// RedisHealthChecksMock.swift +// +// +// Created by Mykola Buhaiov on 21.02.2024. +// + +import Vapor +@testable import HealthChecks + +public struct RedisHealthChecksMock: RedisHealthChecksProtocol { + static let redisId = "adca7c3d-55f4-4ab3-a842-18b35f50cb0f" + static let healthCheckItem = HealthCheckItem( + componentId: redisId, + componentType: .datastore, + observedValue: 1, + observedUnit: "s", + status: .pass, + affectedEndpoints: nil, + time: "2024-02-01T11:11:59.364", + output: "Ok", + links: nil, + node: nil + ) + + public func connection() async -> HealthCheckItem { + RedisHealthChecksMock.healthCheckItem + } + + public func getResponseTime() async -> HealthCheckItem { + RedisHealthChecksMock.healthCheckItem + } + + public func pong() async -> String { + "PONG" + } + + public func checkHealth(for options: [MeasurementType]) async -> [String: HealthCheckItem] { + let result = [ + "\(ComponentName.redis):\(MeasurementType.responseTime)": RedisHealthChecksMock.healthCheckItem, + "\(ComponentName.redis):\(MeasurementType.connections)": RedisHealthChecksMock.healthCheckItem + ] + return result + } +} diff --git a/Tests/HealthChecksTests/RedisHealthChecksTests.swift b/Tests/HealthChecksTests/RedisHealthChecksTests.swift new file mode 100644 index 0000000..1c50b90 --- /dev/null +++ b/Tests/HealthChecksTests/RedisHealthChecksTests.swift @@ -0,0 +1,68 @@ +// FS App Health Checks +// Copyright (C) 2024 FREEDOM SPACE, LLC + +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// +// RedisHealthChecksTests.swift +// +// +// Created by Mykola Buhaiov on 21.02.2024. +// + +import Vapor +import XCTest +@testable import HealthChecks + +final class RedisHealthChecksTests: XCTestCase { + func testConnection() async throws { + let app = Application(.testing) + defer { app.shutdown() } + app.redisId = UUID().uuidString + app.redisHealthChecks = RedisHealthChecksMock() + let result = await app.redisHealthChecks?.connection() + XCTAssertEqual(result, RedisHealthChecksMock.healthCheckItem) + } + + func testGetResponseTime() async throws { + let app = Application(.testing) + defer { app.shutdown() } + let redisId = UUID().uuidString + app.redisId = redisId + app.redisHealthChecks = RedisHealthChecksMock() + let result = await app.redisHealthChecks?.getResponseTime() + XCTAssertEqual(result, RedisHealthChecksMock.healthCheckItem) + XCTAssertEqual(app.redisId, redisId) + } + + func testCheckHealth() async throws { + let app = Application(.testing) + defer { app.shutdown() } + app.redisHealthChecks = RedisHealthChecksMock() + let result = await app.redisHealthChecks?.checkHealth(for: [MeasurementType.responseTime, MeasurementType.connections]) + let redisConnections = result?["\(ComponentName.redis):\(MeasurementType.connections)"] + XCTAssertEqual(redisConnections, RedisHealthChecksMock.healthCheckItem) + let redisResponseTimes = result?["\(ComponentName.redis):\(MeasurementType.responseTime)"] + XCTAssertEqual(redisResponseTimes, RedisHealthChecksMock.healthCheckItem) + } + + func testGetPong() async throws { + let app = Application(.testing) + defer { app.shutdown() } + app.redisHealthChecks = RedisHealthChecksMock() + let result = await app.redisHealthChecks?.pong() + XCTAssertEqual(result, "PONG") + } +}