Mordil/RediStack

View on GitHub
Sources/RediStack/RedisConnection+Configuration.swift

Summary

Maintainability
A
40 mins
Test Coverage
//===----------------------------------------------------------------------===//
//
// This source file is part of the RediStack open source project
//
// Copyright (c) 2020-2022 RediStack project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of RediStack project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Foundation
import Logging
import NIOCore

extension RedisConnection.Configuration {
    public struct ValidationError: LocalizedError, Equatable {
        public static let invalidURLString = ValidationError(.invalidURLString)
        public static let missingURLScheme = ValidationError(.missingURLScheme)
        public static let invalidURLScheme = ValidationError(.invalidURLScheme)
        public static let missingURLHost = ValidationError(.missingURLHost)
        public static let outOfBoundsDatabaseID = ValidationError(.outOfBoundsDatabaseID)

        var localizedDescription: String { self.kind.localizedDescription }

        private let kind: Kind
        
        private init(_ kind: Kind) { self.kind = kind }
        
        public static func ==(lhs: ValidationError, rhs: ValidationError) -> Bool {
            return lhs.kind == rhs.kind
        }
        
        private enum Kind: LocalizedError {
            case invalidURLString
            case missingURLScheme
            case invalidURLScheme
            case missingURLHost
            case outOfBoundsDatabaseID
            
            var localizedDescription: String {
                let message: String = {
                    switch self {
                    case .invalidURLString: return "invalid URL string"
                    case .missingURLScheme: return "URL scheme is missing"
                    case .invalidURLScheme: return "invalid URL scheme, expected 'redis'"
                    case .missingURLHost: return "missing remote hostname"
                    case .outOfBoundsDatabaseID: return "database index out of bounds"
                    }
                }()
                return "(RediStack) \(RedisConnection.Configuration.self) validation failed: \(message)"
            }
        }
    }
}

// MARK: - RedisConnection Config

extension RedisConnection {
    /// A configuration object for creating a single connection to Redis.
    public struct Configuration {
        /// The default port that Redis uses.
        ///
        /// See [https://redis.io/topics/quickstart](https://redis.io/topics/quickstart)
        public static var defaultPort: Int { 6379 }
        
        internal static let defaultLogger = Logger.redisBaseConnectionLogger

        /// The hostname of the connection address. If the address is a Unix socket, then it will be `nil`.
        public var hostname: String? {
            switch self.address {
            case let .v4(addr): return addr.host
            case let .v6(addr): return addr.host
            case .unixDomainSocket: return nil
            }
        }
        /// The port of the connection address. If the address is a Unix socket, then it will be `nil`.
        public var port: Int? { self.address.port }
        /// The password used to authenticate the connection.
        public let password: String?
        /// The initial database index that the connection should use.
        public let initialDatabase: Int?
        /// The logger prototype that will be used by the connection by default when generating logs.
        public let defaultLogger: Logger
        
        internal let address: SocketAddress
        
        /// Creates a new connection configuration with the provided details.
        /// - Parameters:
        ///     - address: The socket address information to use for creating the Redis connection.
        ///     - password: The optional password to authenticate the connection with. The default is `nil`.
        ///     - initialDatabase: The optional database index to initially connect to. The default is `nil`.
        ///     Redis by default opens connections against index `0`, so only set this value if the desired default is not `0`.
        ///     - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
        ///     If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
        /// - Throws: `RedisConnection.Configuration.ValidationError` if invalid arguments are provided.
        public init(
            address: SocketAddress,
            password: String? = nil,
            initialDatabase: Int? = nil,
            defaultLogger: Logger? = nil
        ) throws {
            if initialDatabase != nil && initialDatabase! < 0 {
                throw ValidationError.outOfBoundsDatabaseID
            }
            
            self.address = address
            self.password = password
            self.initialDatabase = initialDatabase
            self.defaultLogger = defaultLogger ?? Configuration.defaultLogger
        }

        /// Creates a new connection configuration with exact details.
        /// - Parameters:
        ///     - hostname: The remote hostname to connect to.
        ///     - port: The port that the Redis instance connects with. The default is `RedisConnection.Configuration.defaultPort`.
        ///     - password: The optional password to authenticate the connection with. The default is `nil`.
        ///     - initialDatabase: The optional database index to initially connect to. The default is `nil`.
        ///     Redis by default opens connections against index `0`, so only set this value if the desired default is not `0`.
        ///     - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
        ///     If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
        /// - Throws:
        ///     - `NIO.SocketAddressError` if hostname resolution fails.
        ///     - `RedisConnection.Configuration.ValidationError` if invalid arguments are provided.
        public init(
            hostname: String,
            port: Int = Self.defaultPort,
            password: String? = nil,
            initialDatabase: Int? = nil,
            defaultLogger: Logger? = nil
        ) throws {
            try self.init(
                address: try .makeAddressResolvingHost(hostname, port: port),
                password: password,
                initialDatabase: initialDatabase,
                defaultLogger: defaultLogger
            )
        }

        /// Creates a new connection configuration from the provided RFC 1808 URL formatted string.
        ///
        /// This is a convenience initializer over creating a `Foundation.URL` directly and passing it to the overloaded initializer.
        ///
        /// The string is expected to match the [RFC 1808](https://tools.ietf.org/html/rfc1808) format.
        ///
        /// An example string:
        ///
        ///     redis://:password@localhost:6379/3
        ///
        /// For more details, see the `Configuration.init(url:)` overload that accepts a `Foundation.URL`.
        /// - Parameters:
        ///     - string: The URL formatted string.
        ///     - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
        ///     If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
        /// - Throws:
        ///     - `RedisConnection.Configuration.ValidationError` if required URL components are invalid or missing.
        ///     - `NIO.SocketAddressError` if hostname resolution fails.
        public init(url string: String, defaultLogger: Logger? = nil) throws {
            guard let url = URL(string: string) else { throw ValidationError.invalidURLString }
            try self.init(url: url, defaultLogger: defaultLogger)
        }

        /// Creates a new connection configuration from the provided URL object.
        ///
        /// The `scheme` and `host` are the only properties that need to be established.
        /// - Invariant: The `port` property is optional, with the `RedisConnection.Configuration.defaultPort` being used by default.
        /// - Invariant: `password` is only required if the Redis instance specifies a password is required. This will not be detected until trying to establish a connection
        /// with this configuration.
        /// - Invariant: To set the default selected database index, provide the index as the `lastPathComponent` of the `Foundation.URL`.
        /// - Requires: The URL **must** use the `redis://` scheme.
        /// - Parameters:
        ///     - url: The URL to use to resolve and authenticate the remote connection.
        ///     - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
        ///     If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
        /// - Throws:
        ///     - `RedisConnection.Configuration.ValidationError` if required URL components are invalid or missing.
        ///     - `NIO.SocketAddressError` if hostname resolution fails.
        public init(url: URL, defaultLogger: Logger? = nil) throws {
            try Self.validateRedisURL(url)

            guard let host = url.host, !host.isEmpty else { throw ValidationError.missingURLHost }
            
            let databaseID = Int(url.lastPathComponent)
            
            try self.init(
                address: try .makeAddressResolvingHost(host, port: url.port ?? Self.defaultPort),
                password: url.password,
                initialDatabase: databaseID,
                defaultLogger: defaultLogger
            )
        }

        private static func validateRedisURL(_ url: URL) throws {
            guard
                let scheme = url.scheme,
                !scheme.isEmpty
            else { throw ValidationError.missingURLScheme }

            guard scheme == "redis" else { throw ValidationError.invalidURLScheme }
        }
    }
}