From b2367ac33eb8632011c1ec7afff12307838a4dab Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sat, 7 Nov 2020 21:03:06 -0800 Subject: [PATCH] 81 -- Add Configuration types for initialization --- README.md | 6 +- Sources/RediStack/Configuration.swift | 315 ++++++++++++++++++ .../ConnectionPool.swift | 0 .../ConnectionPoolErrors.swift | 0 Sources/RediStack/RedisConnection.swift | 82 +++-- Sources/RediStack/RedisConnectionPool.swift | 184 +++++----- Sources/RediStack/_Deprecations.swift | 132 ++++++++ .../Extensions/RediStack.swift | 33 +- ...disConnectionPoolIntegrationTestCase.swift | 18 +- .../RedisIntegrationTestCase.swift | 22 +- .../RediStackTestUtils/_Deprecations.swift | 48 +++ .../RedisLoggingTests.swift | 10 +- .../RedisCommandHandlerTests.swift | 6 +- Tests/RediStackTests/ConfigurationTests.swift | 43 +++ 14 files changed, 699 insertions(+), 200 deletions(-) create mode 100644 Sources/RediStack/Configuration.swift rename Sources/RediStack/{Connection Pool => ConnectionPool}/ConnectionPool.swift (100%) rename Sources/RediStack/{Connection Pool => ConnectionPool}/ConnectionPoolErrors.swift (100%) create mode 100644 Sources/RediStack/_Deprecations.swift create mode 100644 Sources/RediStackTestUtils/_Deprecations.swift create mode 100644 Tests/RediStackTests/ConfigurationTests.swift diff --git a/README.md b/README.md index 13e3667..29dfd5d 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,9 @@ import NIO import RediStack let eventLoop: EventLoop = ... -let connection = RedisConnection.connect( - to: try .init(ipAddress: "127.0.0.1", port: RedisConnection.defaultPort), - on: eventLoop +let connection = RedisConnection.make( + configuration: try .init(hostname: "127.0.0.1"), + boundEventLoop: eventLoop ).wait() let result = try connection.set("my_key", to: "some value") diff --git a/Sources/RediStack/Configuration.swift b/Sources/RediStack/Configuration.swift new file mode 100644 index 0000000..573fff0 --- /dev/null +++ b/Sources/RediStack/Configuration.swift @@ -0,0 +1,315 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 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 NIO + +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 = 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 } + } + } +} + +// MARK: - RedisConnectionPool Config + +extension RedisConnectionPool { + /// A configuration object for creating Redis connections with a connection pool. + /// - Warning: This type has **reference** semantics due to the `NIO.ClientBootstrap` reference. + public struct ConnectionFactoryConfiguration { + // this needs to be var so it can be updated by the pool with the pool id + /// The logger prototype that will be used by connections by default when generating logs. + public internal(set) var connectionDefaultLogger: Logger + /// The password used to authenticate connections. + public let connectionPassword: String? + /// The initial database index that connections should use. + public let connectionInitialDatabase: Int? + /// The pre-configured TCP client for connections to use. + public let tcpClient: ClientBootstrap? + + /// Creates a new connection factory configuration with the provided options. + /// - Parameters: + /// - connectionInitialDatabase: 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`. + /// - connectionPassword: The optional password to authenticate connections with. The default is `nil`. + /// - connectionDefaultLogger: The optional prototype logger to use as the default logger instance when generating logs from connections. + /// If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`. + /// - tcpClient: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `.makeRedisTCPClient` factory instance. + public init( + connectionInitialDatabase: Int? = nil, + connectionPassword: String? = nil, + connectionDefaultLogger: Logger? = nil, + tcpClient: ClientBootstrap? = nil + ) { + self.connectionInitialDatabase = connectionInitialDatabase + self.connectionPassword = connectionPassword + self.connectionDefaultLogger = connectionDefaultLogger ?? RedisConnection.Configuration.defaultLogger + self.tcpClient = tcpClient + } + } + + /// A configuration object for connection pools. + /// - Warning: This type has **reference** semantics due to `ConnectionFactoryConfiguration`. + public struct Configuration { + /// The set of Redis servers to which this pool is initially willing to connect. + public let initialConnectionAddresses: [SocketAddress] + /// The minimum number of connections to preserve in the pool. + /// + /// If the pool is mostly idle and the Redis servers close these idle connections, + /// the `RedisConnectionPool` will initiate new outbound connections proactively to avoid the number of available connections dropping below this number. + public let minimumConnectionCount: Int + /// The maximum number of connections to for this pool, either to be preserved or as a hard limit. + public let maximumConnectionCount: RedisConnectionPoolSize + /// The configuration object that controls the connection retry behavior. + public let connectionRetryConfiguration: (backoff: (initialDelay: TimeAmount, factor: Float32), timeout: TimeAmount) + // these need to be var so they can be updated by the pool in some cases + public internal(set) var factoryConfiguration: ConnectionFactoryConfiguration + /// The logger prototype that will be used by the connection pool by default when generating logs. + public internal(set) var poolDefaultLogger: Logger + + /// Creates a new connection configuration with the provided options. + /// - Parameters: + /// - initialServerConnectionAddresses: The set of Redis servers to which this pool is initially willing to connect. + /// This set can be updated over time directly on the connection pool. + /// - maximumConnectionCount: The maximum number of connections to for this pool, either to be preserved or as a hard limit. + /// - connectionFactoryConfiguration: The configuration to use while creating connections to fill the pool. + /// - minimumConnectionCount: The minimum number of connections to preserve in the pool. If the pool is mostly idle + /// and the Redis servers close these idle connections, the `RedisConnectionPool` will initiate new outbound + /// connections proactively to avoid the number of available connections dropping below this number. Defaults to `1`. + /// - connectionBackoffFactor: Used when connection attempts fail to control the exponential backoff. This is a multiplicative + /// factor, each connection attempt will be delayed by this amount times the previous delay. + /// - initialConnectionBackoffDelay: If a TCP connection attempt fails, this is the first backoff value on the reconnection attempt. + /// Subsequent backoffs are computed by compounding this value by `connectionBackoffFactor`. + /// - connectionRetryTimeout: The max time to wait for a connection to be available before failing a particular command or connection operation. + /// The default is 60 seconds. + /// - poolDefaultLogger: The `Logger` used by the connection pool itself. + public init( + initialServerConnectionAddresses: [SocketAddress], + maximumConnectionCount: RedisConnectionPoolSize, + connectionFactoryConfiguration: ConnectionFactoryConfiguration, + minimumConnectionCount: Int = 1, + connectionBackoffFactor: Float32 = 2, + initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), + connectionRetryTimeout: TimeAmount? = .seconds(60), + poolDefaultLogger: Logger? = nil + ) { + self.initialConnectionAddresses = initialServerConnectionAddresses + self.maximumConnectionCount = maximumConnectionCount + self.factoryConfiguration = connectionFactoryConfiguration + self.minimumConnectionCount = minimumConnectionCount + self.connectionRetryConfiguration = ( + (initialConnectionBackoffDelay, connectionBackoffFactor), + connectionRetryTimeout ?? .milliseconds(10) // always default to a baseline 10ms + ) + self.poolDefaultLogger = poolDefaultLogger ?? .redisBaseConnectionPoolLogger + } + } +} + +/// `RedisConnectionPoolSize` controls how the maximum number of connections in a pool are interpreted. +public enum RedisConnectionPoolSize { + /// The pool will allow no more than this number of connections to be "active" (that is, connecting, in-use, + /// or pooled) at any one time. This will force possible future users of new connections to wait until a currently + /// active connection becomes available by being returned to the pool, but provides a hard upper limit on concurrency. + case maximumActiveConnections(Int) + + /// The pool will only store up to this number of connections that are not currently in-use. However, if the pool is + /// asked for more connections at one time than this number, it will create new connections to serve those waiting for + /// connections. These "extra" connections will not be preserved: while they will be used to satisfy those waiting for new + /// connections if needed, they will not be preserved in the pool if load drops low enough. This does not provide a hard + /// upper bound on concurrency, but does provide an upper bound on low-level load. + case maximumPreservedConnections(Int) +} diff --git a/Sources/RediStack/Connection Pool/ConnectionPool.swift b/Sources/RediStack/ConnectionPool/ConnectionPool.swift similarity index 100% rename from Sources/RediStack/Connection Pool/ConnectionPool.swift rename to Sources/RediStack/ConnectionPool/ConnectionPool.swift diff --git a/Sources/RediStack/Connection Pool/ConnectionPoolErrors.swift b/Sources/RediStack/ConnectionPool/ConnectionPoolErrors.swift similarity index 100% rename from Sources/RediStack/Connection Pool/ConnectionPoolErrors.swift rename to Sources/RediStack/ConnectionPool/ConnectionPoolErrors.swift diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index b3502d4..ad5afbd 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -20,25 +20,20 @@ import NIO import NIOConcurrencyHelpers extension RedisConnection { - /// The documented default port that Redis connects through. - /// - /// See [https://redis.io/topics/quickstart](https://redis.io/topics/quickstart) - public static let defaultPort = 6379 - /// Creates a new connection to a Redis instance. + /// Creates a new connection with provided configuration and sychronization objects. /// - /// If you would like to specialize the `NIO.ClientBootstrap` that the connection communicates on, override the default by passing it in as `tcpClient`. + /// If you would like to specialize the `NIO.ClientBootstrap` that the connection communicates on, override the default by passing it in as `configuredTCPClient`. /// /// let eventLoopGroup: EventLoopGroup = ... /// var customTCPClient = ClientBootstrap.makeRedisTCPClient(group: eventLoopGroup) /// customTCPClient.channelInitializer { channel in /// // channel customizations /// } - /// let connection = RedisConnection.connect( - /// to: ..., - /// on: eventLoopGroup.next(), - /// password: ..., - /// tcpClient: customTCPClient + /// let connection = RedisConnection.make( + /// configuration: ..., + /// boundEventLoop: eventLoopGroup.next(), + /// configuredTCPClient: customTCPClient /// ).wait() /// /// It is recommended that you be familiar with `ClientBootstrap.makeRedisTCPClient(group:)` and `NIO.ClientBootstrap` in general before doing so. @@ -46,45 +41,50 @@ extension RedisConnection { /// Note: Use of `wait()` in the example is for simplicity. Never call `wait()` on an event loop. /// /// - Important: Call `close()` on the connection before letting the instance deinit to properly cleanup resources. - /// - Note: If a `password` is provided, the connection will send an "AUTH" command to Redis as soon as it has been opened. - /// + /// - Invariant: If a `password` is provided in the configuration, the connection will send an "AUTH" command to Redis as soon as it has been opened. + /// - Invariant: If a `database` index is provided in the configuration, the connection will send a "SELECT" command to Redis after it has been authenticated. /// - Parameters: - /// - socket: The `NIO.SocketAddress` information of the Redis instance to connect to. - /// - eventLoop: The `NIO.EventLoop` that this connection will execute all tasks on. - /// - password: The optional password to use for authorizing the connection with Redis. - /// - logger: The `Logging.Logger` instance to use for all client logging purposes. If one is not provided, one will be created. - /// A `Foundation.UUID` will be attached to the metadata to uniquely identify this connection instance's logs. - /// - tcpClient: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `makeRedisTCPClient` instance. - /// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection after it has been opened, and if a `password` is provided, authenticated. - public static func connect( - to socket: SocketAddress, - on eventLoop: EventLoop, - password: String? = nil, - logger: Logger = .redisBaseConnectionLogger, - tcpClient: ClientBootstrap? = nil + /// - config: The configuration to use for creating the connection. + /// - eventLoop: The `NIO.EventLoop` that the connection will be bound to. + /// - client: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `.makeRedisTCPClient` factory instance. + /// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection after it has been opened, configured, and authenticated per the `configuration` object. + public static func make( + configuration config: Configuration, + boundEventLoop eventLoop: EventLoop, + configuredTCPClient client: ClientBootstrap? = nil ) -> EventLoopFuture { - let client = tcpClient ?? ClientBootstrap.makeRedisTCPClient(group: eventLoop) + let client = client ?? .makeRedisTCPClient(group: eventLoop) - return client.connect(to: socket) - .map { return RedisConnection(configuredRESPChannel: $0, context: logger) } - .flatMap { connection in - guard let pw = password else { - return connection.eventLoop.makeSucceededFuture(connection) - } - return connection.authorize(with: pw) - .map { return connection } + var future = client + .connect(to: config.address) + .map { return RedisConnection(configuredRESPChannel: $0, context: config.defaultLogger) } + + // if a password is specified, use it to authenticate before further operations happen + if let password = config.password { + future = future.flatMap { connection in + return connection.authorize(with: password).map { connection } } + } + + // if a database index is specified, use it to switch the selected database before further operations happen + if let database = config.initialDatabase { + future = future.flatMap { connection in + return connection.select(database: database).map { connection } + } + } + + return future } } -/// A concrete `RedisClient` implementation that represents an individual connection to a Redis database instance. +/// A concrete `RedisClient` implementation that represents an individual connection to a Redis instance. /// -/// For basic setups, you will just need a `NIO.SocketAddress` and a `NIO.EventLoop` and perhaps a `password`. +/// For basic setups, you will just need a `NIO.EventLoop` and perhaps a `password`. /// /// let eventLoop: EventLoop = ... -/// let connection = RedisConnection.connect( -/// to: try .makeAddressResolvingHost("my.redis.url", port: RedisConnection.defaultPort), -/// on: eventLoop +/// let connection = RedisConnection.make( +/// configuration: .init(hostname: "my.redis.url", password: "some_password"), +/// boundEventLoop: eventLoop /// ).wait() /// /// let result = try connection.set("my_key", to: "some value") @@ -94,8 +94,6 @@ extension RedisConnection { /// print(result) // Optional("some value") /// /// Note: `wait()` is used in the example for simplicity. Never call `wait()` on an event loop. -/// -/// See `NIO.SocketAddress`, `NIO.EventLoop`, and `RedisClient`. public final class RedisConnection: RedisClient, RedisClientWithUserContext { /// A unique identifer to represent this connection. public let id = UUID() diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 999b988..6f37d3b 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -34,83 +34,47 @@ public class RedisConnectionPool { /// The number of connections that have been handed out and are in active use. public var leasedConnectionCount: Int { self.pool?.leasedConnectionCount ?? 0 } + private let loop: EventLoop // This needs to be var because we hand it a closure that references us strongly. This also // establishes a reference cycle which we need to break. // Aside from on init, all other operations on this var must occur on the event loop. private var pool: ConnectionPool? - - /// This needs to be var because it is updatable and mutable. As a result, aside from init, - /// all use of this var must occur on the event loop. + // This needs to be var because of some logger metadata tagging. + // This should only be mutated on init, and safe to read anywhere else + private var configuration: Configuration + // This needs to be var because it is updatable and mutable. + // As a result, aside from init, all use of this var must occur on the event loop. private var serverConnectionAddresses: ConnectionAddresses - /// This needs to be a var because we reuse the same connection + // This needs to be a var because its value changes as the pool enters/leaves pubsub mode to reuse the same connection. private var pubsubConnection: RedisConnection? + + public init(configuration: Configuration, boundEventLoop: EventLoop) { + var config = configuration - private let connectionRetryTimeout: TimeAmount - private let connectionPassword: String? - private let connectionSystemContext: Logger - private let poolSystemContext: Context - private let loop: EventLoop - private let connectionTCPClient: ClientBootstrap? - - /// Create a new `RedisConnectionPool`. - /// - /// - parameters: - /// - serverConnectionAddresses: The set of Redis servers to which this pool is initially willing to connect. - /// This set can be updated over time. - /// - loop: The event loop to which this pooled client is tied. - /// - maximumConnectionCount: The maximum number of connections to for this pool, either to be preserved or as a hard limit. - /// - minimumConnectionCount: The minimum number of connections to preserve in the pool. If the pool is mostly idle - /// and the Redis servers close these idle connections, the `RedisConnectionPool` will initiate new outbound - /// connections proactively to avoid the number of available connections dropping below this number. Defaults to `1`. - /// - connectionPassword: The password to use to connect to the Redis servers in this pool. - /// - connectionLogger: The `Logger` to pass to each connection in the pool. - /// - connectionTCPClient: The base `ClientBootstrap` to use to create pool connections, if a custom one is in use. - /// - poolLogger: The `Logger` used by the connection pool itself. - /// - connectionBackoffFactor: Used when connection attempts fail to control the exponential backoff. This is a multiplicative - /// factor, each connection attempt will be delayed by this amount times the previous delay. - /// - initialConnectionBackoffDelay: If a TCP connection attempt fails, this is the first backoff value on the reconnection attempt. - /// Subsequent backoffs are computed by compounding this value by `connectionBackoffFactor`. - /// - connectionRetryTimeout: The max time to wait for a connection to be available before failing a particular command or connection operation. - /// The default is 60 seconds. - public init( - serverConnectionAddresses: [SocketAddress], - loop: EventLoop, - maximumConnectionCount: RedisConnectionPoolSize, - minimumConnectionCount: Int = 1, - connectionPassword: String? = nil, - connectionLogger: Logger = .redisBaseConnectionLogger, - connectionTCPClient: ClientBootstrap? = nil, - poolLogger: Logger = .redisBaseConnectionPoolLogger, - connectionBackoffFactor: Float32 = 2, - initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), - connectionRetryTimeout: TimeAmount? = .seconds(60) - ) { - self.loop = loop - self.serverConnectionAddresses = ConnectionAddresses(initialAddresses: serverConnectionAddresses) - self.connectionPassword = connectionPassword - self.connectionRetryTimeout = connectionRetryTimeout ?? .milliseconds(10) + self.loop = boundEventLoop + self.serverConnectionAddresses = ConnectionAddresses(initialAddresses: config.initialConnectionAddresses) // mix of terminology here with the loggers // as we're being "forward thinking" in terms of the 'baggage context' future type - - var connectionLogger = connectionLogger - connectionLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" - self.connectionSystemContext = connectionLogger - - var poolLogger = poolLogger - poolLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" - self.poolSystemContext = poolLogger - - self.connectionTCPClient = connectionTCPClient - + + var taggedConnectionLogger = config.factoryConfiguration.connectionDefaultLogger + taggedConnectionLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" + config.factoryConfiguration.connectionDefaultLogger = taggedConnectionLogger + + var taggedPoolLogger = config.poolDefaultLogger + taggedPoolLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" + config.poolDefaultLogger = taggedPoolLogger + + self.configuration = config + self.pool = ConnectionPool( - maximumConnectionCount: maximumConnectionCount.size, - minimumConnectionCount: minimumConnectionCount, - leaky: maximumConnectionCount.leaky, - loop: loop, - systemContext: poolLogger, - connectionBackoffFactor: connectionBackoffFactor, - initialConnectionBackoffDelay: initialConnectionBackoffDelay, + maximumConnectionCount: config.maximumConnectionCount.size, + minimumConnectionCount: config.minimumConnectionCount, + leaky: config.maximumConnectionCount.leaky, + loop: boundEventLoop, + systemContext: config.poolDefaultLogger, + connectionBackoffFactor: config.connectionRetryConfiguration.backoff.factor, + initialConnectionBackoffDelay: config.connectionRetryConfiguration.backoff.initialDelay, connectionFactory: self.connectionFactory(_:) ) } @@ -190,7 +154,7 @@ extension RedisConnectionPool { } /// Updates the list of valid connection addresses. - /// + /// - Warning: This will replace any previously set list of addresses. /// - Note: This does not invalidate existing connections: as long as those connections continue to stay up, they will be kept by /// this client. /// @@ -214,26 +178,42 @@ extension RedisConnectionPool { // Validate the loop invariants. self.loop.preconditionInEventLoop() targetLoop.preconditionInEventLoop() + + let factoryConfig = self.configuration.factoryConfiguration guard let nextTarget = self.serverConnectionAddresses.nextTarget() else { // No valid connection target, we'll fail. return targetLoop.makeFailedFuture(RedisConnectionPoolError.noAvailableConnectionTargets) } + + let connectionConfig: RedisConnection.Configuration + do { + connectionConfig = try .init( + address: nextTarget, + password: factoryConfig.connectionPassword, + initialDatabase: factoryConfig.connectionInitialDatabase, + defaultLogger: factoryConfig.connectionDefaultLogger + ) + } catch { + // config validation failed, return the error + return targetLoop.makeFailedFuture(error) + } - let connectFuture = RedisConnection.connect( - to: nextTarget, - on: targetLoop, - password: self.connectionPassword, - logger: self.connectionSystemContext, - tcpClient: self.connectionTCPClient - ) - // disallow subscriptions on all connections by default so that we can enforce our management of PubSub state - connectFuture.whenSuccess { $0.allowSubscriptions = false } - return connectFuture + return RedisConnection + .make( + configuration: connectionConfig, + boundEventLoop: targetLoop, + configuredTCPClient: factoryConfig.tcpClient + ) + .map { connection in + // disallow subscriptions on all connections by default to enforce our management of PubSub state + connection.allowSubscriptions = false + return connection + } } private func prepareLoggerForUse(_ logger: Logger?) -> Logger { - guard var logger = logger else { return self.poolSystemContext } + guard var logger = logger else { return self.configuration.poolDefaultLogger } logger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" return logger } @@ -441,7 +421,11 @@ extension RedisConnectionPool: RedisClientWithUserContext { let logger = self.prepareLoggerForUse(context) guard let connection = preferredConnection else { - return pool.leaseConnection(deadline: .now() + self.connectionRetryTimeout, logger: logger) + return pool + .leaseConnection( + deadline: .now() + self.configuration.connectionRetryConfiguration.timeout, + logger: logger + ) .flatMap { operation($0, pool.returnConnection(_:logger:), logger) } } @@ -456,57 +440,47 @@ extension RedisConnectionPool { private var addresses: [SocketAddress] private var index: Array.Index - - init(initialAddresses: [SocketAddress]) { + + internal init(initialAddresses: [SocketAddress]) { self.addresses = initialAddresses self.index = self.addresses.startIndex } - - mutating func nextTarget() -> SocketAddress? { - // Early exit on 0, makes life easier. - guard self.addresses.count > 0 else { + + internal mutating func nextTarget() -> SocketAddress? { + // early exit on 0, makes life easier + guard !self.addresses.isEmpty else { self.index = self.addresses.startIndex return nil } - - // It's an invariant of this function that the index is always valid for subscripting the collection. + let nextTarget = self.addresses[self.index] + + // it's an invariant of this function that the index is always valid for subscripting the collection self.addresses.formIndex(after: &self.index) if self.index == self.addresses.endIndex { self.index = self.addresses.startIndex } + return nextTarget } - - mutating func update(_ newAddresses: [SocketAddress]) { + + internal mutating func update(_ newAddresses: [SocketAddress]) { self.addresses = newAddresses self.index = self.addresses.startIndex } } } -/// `RedisConnectionPoolSize` controls how the maximum number of connections in a pool are interpreted. -public enum RedisConnectionPoolSize { - /// The pool will allow no more than this number of connections to be "active" (that is, connecting, in-use, - /// or pooled) at any one time. This will force possible future users of new connections to wait until a currently - /// active connection becomes available by being returned to the pool, but provides a hard upper limit on concurrency. - case maximumActiveConnections(Int) - - /// The pool will only store up to this number of connections that are not currently in-use. However, if the pool is - /// asked for more connections at one time than this number, it will create new connections to serve those waiting for - /// connections. These "extra" connections will not be preserved: while they will be used to satisfy those waiting for new - /// connections if needed, they will not be preserved in the pool if load drops low enough. This does not provide a hard - /// upper bound on concurrency, but does provide an upper bound on low-level load. - case maximumPreservedConnections(Int) - - internal var size: Int { +// MARK: RedisConnectionPoolSize helpers +extension RedisConnectionPoolSize { + fileprivate var size: Int { switch self { case .maximumActiveConnections(let size), .maximumPreservedConnections(let size): return size } } - internal var leaky: Bool { + fileprivate var leaky: Bool { switch self { case .maximumActiveConnections: return false diff --git a/Sources/RediStack/_Deprecations.swift b/Sources/RediStack/_Deprecations.swift new file mode 100644 index 0000000..f872067 --- /dev/null +++ b/Sources/RediStack/_Deprecations.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 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 Logging +import NIO + +extension RedisConnection { + /// The documented default port that Redis connects through. + /// + /// See [https://redis.io/topics/quickstart](https://redis.io/topics/quickstart) + @available(*, deprecated, message: "Use RedisConnection.Configuration.defaultPort") + public static var defaultPort: Int { Configuration.defaultPort } + + /// Creates a new connection to a Redis instance. + /// + /// If you would like to specialize the `NIO.ClientBootstrap` that the connection communicates on, override the default by passing it in as `tcpClient`. + /// + /// let eventLoopGroup: EventLoopGroup = ... + /// var customTCPClient = ClientBootstrap.makeRedisTCPClient(group: eventLoopGroup) + /// customTCPClient.channelInitializer { channel in + /// // channel customizations + /// } + /// let connection = RedisConnection.connect( + /// to: ..., + /// on: eventLoopGroup.next(), + /// password: ..., + /// tcpClient: customTCPClient + /// ).wait() + /// + /// It is recommended that you be familiar with `ClientBootstrap.makeRedisTCPClient(group:)` and `NIO.ClientBootstrap` in general before doing so. + /// + /// Note: Use of `wait()` in the example is for simplicity. Never call `wait()` on an event loop. + /// + /// - Important: Call `close()` on the connection before letting the instance deinit to properly cleanup resources. + /// - Note: If a `password` is provided, the connection will send an "AUTH" command to Redis as soon as it has been opened. + /// + /// - Parameters: + /// - socket: The `NIO.SocketAddress` information of the Redis instance to connect to. + /// - eventLoop: The `NIO.EventLoop` that this connection will execute all tasks on. + /// - password: The optional password to use for authorizing the connection with Redis. + /// - logger: The `Logging.Logger` instance to use for all client logging purposes. If one is not provided, one will be created. + /// A `Foundation.UUID` will be attached to the metadata to uniquely identify this connection instance's logs. + /// - tcpClient: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `makeRedisTCPClient` instance. + /// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection after it has been opened, and if a `password` is provided, authenticated. + @available(*, deprecated, message: "Use make(configuration:boundEventLoop:configuredTCPClient:) instead") + public static func connect( + to socket: SocketAddress, + on eventLoop: EventLoop, + password: String? = nil, + logger: Logger = .redisBaseConnectionLogger, + tcpClient: ClientBootstrap? = nil + ) -> EventLoopFuture { + let config: Configuration + do { + config = try .init( + address: socket, + password: password, + defaultLogger: logger + ) + } catch { + return eventLoop.makeFailedFuture(error) + } + + return self.make(configuration: config, boundEventLoop: eventLoop, configuredTCPClient: tcpClient) + } +} + +extension RedisConnectionPool { + /// Create a new `RedisConnectionPool`. + /// + /// - parameters: + /// - serverConnectionAddresses: The set of Redis servers to which this pool is initially willing to connect. + /// This set can be updated over time. + /// - loop: The event loop to which this pooled client is tied. + /// - maximumConnectionCount: The maximum number of connections to for this pool, either to be preserved or as a hard limit. + /// - minimumConnectionCount: The minimum number of connections to preserve in the pool. If the pool is mostly idle + /// and the Redis servers close these idle connections, the `RedisConnectionPool` will initiate new outbound + /// connections proactively to avoid the number of available connections dropping below this number. Defaults to `1`. + /// - connectionPassword: The password to use to connect to the Redis servers in this pool. + /// - connectionLogger: The `Logger` to pass to each connection in the pool. + /// - connectionTCPClient: The base `ClientBootstrap` to use to create pool connections, if a custom one is in use. + /// - poolLogger: The `Logger` used by the connection pool itself. + /// - connectionBackoffFactor: Used when connection attempts fail to control the exponential backoff. This is a multiplicative + /// factor, each connection attempt will be delayed by this amount times the previous delay. + /// - initialConnectionBackoffDelay: If a TCP connection attempt fails, this is the first backoff value on the reconnection attempt. + /// Subsequent backoffs are computed by compounding this value by `connectionBackoffFactor`. + /// - connectionRetryTimeout: The max time to wait for a connection to be available before failing a particular command or connection operation. + /// The default is 60 seconds. + @available(*, deprecated, message: "Use .init(configuration:boundEventLoop:) instead.") + public convenience init( + serverConnectionAddresses: [SocketAddress], + loop: EventLoop, + maximumConnectionCount: RedisConnectionPoolSize, + minimumConnectionCount: Int = 1, + connectionPassword: String? = nil, // config + connectionLogger: Logger = .redisBaseConnectionLogger, // config + connectionTCPClient: ClientBootstrap? = nil, + poolLogger: Logger = .redisBaseConnectionPoolLogger, + connectionBackoffFactor: Float32 = 2, + initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), + connectionRetryTimeout: TimeAmount? = .seconds(60) + ) { + self.init( + configuration: Configuration( + initialServerConnectionAddresses: serverConnectionAddresses, + maximumConnectionCount: maximumConnectionCount, + connectionFactoryConfiguration: ConnectionFactoryConfiguration( + connectionPassword: connectionPassword, + connectionDefaultLogger: connectionLogger, + tcpClient: connectionTCPClient + ), + minimumConnectionCount: minimumConnectionCount, + connectionBackoffFactor: connectionBackoffFactor, + initialConnectionBackoffDelay: initialConnectionBackoffDelay, + connectionRetryTimeout: connectionRetryTimeout, + poolDefaultLogger: poolLogger + ), + boundEventLoop: loop + ) + } +} diff --git a/Sources/RediStackTestUtils/Extensions/RediStack.swift b/Sources/RediStackTestUtils/Extensions/RediStack.swift index 8e48e30..a9ed062 100644 --- a/Sources/RediStackTestUtils/Extensions/RediStack.swift +++ b/Sources/RediStackTestUtils/Extensions/RediStack.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -16,32 +16,15 @@ import Foundation import NIO import RediStack -extension RedisConnection { +extension RedisConnection.Configuration { /// A default hostname of `localhost` to try and connect to Redis at. public static let defaultHostname = "localhost" - - /// Creates a connection intended for tests using `REDIS_URL` and `REDIS_PW` environment variables if available. - /// - /// The default URL is `127.0.0.1` while the default port is `RedisConnection.defaultPort`. - /// - /// If `REDIS_PW` is not defined, no authentication will happen on the connection. - /// - Parameters: - /// - eventLoop: The event loop that the connection should execute on. - /// - port: The port to connect on. - /// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection. - public static func connect( - on eventLoop: EventLoop, - host: String = RedisConnection.defaultHostname, - port: Int = RedisConnection.defaultPort, + + public init( + host: String = RedisConnection.Configuration.defaultHostname, + port: Int = RedisConnection.Configuration.defaultPort, password: String? = nil - ) -> EventLoopFuture { - let address: SocketAddress - do { - address = try SocketAddress.makeAddressResolvingHost(host, port: port) - } catch { - return eventLoop.makeFailedFuture(error) - } - - return RedisConnection.connect(to: address, on: eventLoop, password: password) + ) throws { + try self.init(hostname: host, port: port, password: password) } } diff --git a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift index 8834c1d..00f7d5c 100644 --- a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift @@ -25,10 +25,10 @@ open class RedisConnectionPoolIntegrationTestCase: XCTestCase { /// The default value is `RedisConnection.defaultHostname` /// /// This is especially useful to override if you build on Linux & macOS where Redis might be installed locally vs. through Docker. - open var redisHostname: String { return RedisConnection.defaultHostname } + open var redisHostname: String { RedisConnection.Configuration.defaultHostname } /// The port to connect over to Redis, defaulting to `RedisConnection.defaultPort`. - open var redisPort: Int { return RedisConnection.defaultPort } + open var redisPort: Int { RedisConnection.Configuration.defaultPort } /// The password to use to connect to Redis. Default is `nil` - no password authentication. open var redisPassword: String? { return nil } @@ -80,12 +80,14 @@ open class RedisConnectionPoolIntegrationTestCase: XCTestCase { ) throws -> RedisConnectionPool { let address = try SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort) let pool = RedisConnectionPool( - serverConnectionAddresses: [address], - loop: self.eventLoopGroup.next(), - maximumConnectionCount: .maximumActiveConnections(4), - minimumConnectionCount: minimumConnectionCount, - connectionPassword: self.redisPassword, - connectionRetryTimeout: connectionRetryTimeout + configuration: .init( + initialServerConnectionAddresses: [address], + maximumConnectionCount: .maximumActiveConnections(4), + connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword), + minimumConnectionCount: minimumConnectionCount, + connectionRetryTimeout: connectionRetryTimeout + ), + boundEventLoop: self.eventLoopGroup.next() ) pool.activate() diff --git a/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift index f41cbd6..d329753 100644 --- a/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -18,17 +18,17 @@ import XCTest /// A helper `XCTestCase` subclass that does the standard work of creating a connection to use in test cases. /// -/// See `RedisConnection.connect(to:port:)` to understand how connections are made. +/// See `RedisConnection.make(configuration:boundEventLoop:)` to understand how connections are made. open class RedisIntegrationTestCase: XCTestCase { /// An overridable value of the Redis instance's hostname to connect to for the test suite(s). /// /// The default value is `RedisConnection.defaultHostname` /// /// This is especially useful to override if you build on Linux & macOS where Redis might be installed locally vs. through Docker. - open var redisHostname: String { return RedisConnection.defaultHostname } + open var redisHostname: String { RedisConnection.Configuration.defaultHostname } /// The port to connect over to Redis, defaulting to `RedisConnection.defaultPort`. - open var redisPort: Int { return RedisConnection.defaultPort } + open var redisPort: Int { RedisConnection.Configuration.defaultPort } /// The password to use to connect to Redis. Default is `nil` - no password authentication. open var redisPassword: String? { return nil } @@ -77,14 +77,16 @@ open class RedisIntegrationTestCase: XCTestCase { /// Creates a new connection for use in tests. /// - /// See `RedisConnection.connect(to:port:)` + /// See `RedisConnection.make(configuration:boundEventLoop:)` /// - Returns: The new `RediStack.RedisConnection`. public func makeNewConnection() throws -> RedisConnection { - return try RedisConnection.connect( - on: eventLoopGroup.next(), - host: self.redisHostname, - port: self.redisPort, - password: self.redisPassword + return try RedisConnection.make( + configuration: .init( + host: self.redisHostname, + port: self.redisPort, + password: self.redisPassword + ), + boundEventLoop: eventLoopGroup.next() ).wait() } } diff --git a/Sources/RediStackTestUtils/_Deprecations.swift b/Sources/RediStackTestUtils/_Deprecations.swift new file mode 100644 index 0000000..c77f24a --- /dev/null +++ b/Sources/RediStackTestUtils/_Deprecations.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 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 NIO +import RediStack + +extension RedisConnection { + /// A default hostname of `localhost` to try and connect to Redis at. + @available(*, deprecated, message: "Use RedisConnection.Configuration.defaultHostname") + public static let defaultHostname = "localhost" + + /// Creates a connection intended for tests using `REDIS_URL` and `REDIS_PW` environment variables if available. + /// + /// The default URL is `127.0.0.1` while the default port is `RedisConnection.defaultPort`. + /// + /// If `REDIS_PW` is not defined, no authentication will happen on the connection. + /// - Parameters: + /// - eventLoop: The event loop that the connection should execute on. + /// - port: The port to connect on. + /// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection. + @available(*, deprecated, message: "Use RedisConnection.make(configuration:boundEventLoop:) method") + public static func connect( + on eventLoop: EventLoop, + host: String = RedisConnection.defaultHostname, + port: Int = RedisConnection.Configuration.defaultPort, + password: String? = nil + ) -> EventLoopFuture { + let address: SocketAddress + do { + address = try SocketAddress.makeAddressResolvingHost(host, port: port) + } catch { + return eventLoop.makeFailedFuture(error) + } + + return RedisConnection.connect(to: address, on: eventLoop, password: password) + } +} diff --git a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift index d73ff91..2645220 100644 --- a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift +++ b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift @@ -47,10 +47,12 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { let logger = Logger(label: #function, factory: { _ in return handler }) let pool = RedisConnectionPool( - serverConnectionAddresses: [try .makeAddressResolvingHost(self.redisHostname, port: self.redisPort)], - loop: self.connection.eventLoop, - maximumConnectionCount: .maximumActiveConnections(1), - connectionPassword: self.redisPassword + configuration: .init( + initialServerConnectionAddresses: [try .makeAddressResolvingHost(self.redisHostname, port: self.redisPort)], + maximumConnectionCount: .maximumActiveConnections(1), + connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword) + ), + boundEventLoop: self.connection.eventLoop ) defer { pool.close() } pool.activate() diff --git a/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift b/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift index 1b871a5..b92e4b4 100644 --- a/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift +++ b/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift @@ -29,9 +29,9 @@ final class RedisCommandHandlerTests: XCTestCase { .wait() defer { try? server.close().wait() } - let connection = try RedisConnection.connect( - to: socketAddress, - on: group.next() + let connection = try RedisConnection.make( + configuration: .init(hostname: "localhost", port: 8080), + boundEventLoop: group.next() ).wait() defer { try? connection.close().wait() } diff --git a/Tests/RediStackTests/ConfigurationTests.swift b/Tests/RediStackTests/ConfigurationTests.swift new file mode 100644 index 0000000..3deab95 --- /dev/null +++ b/Tests/RediStackTests/ConfigurationTests.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 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 NIO +import RediStack +import XCTest + +final class ConfigurationTests: XCTestCase { + func test_connectionConfiguration_stringURLValidation() throws { + XCTAssertNoThrow(try RedisConnection.Configuration(url: "redis://localhost:6379")) + XCTAssertNoThrow(try RedisConnection.Configuration(url: "redis://localhost:6379/0")) + XCTAssertNoThrow(try RedisConnection.Configuration(url: "redis://:password@localhost")) + XCTAssertThrowsError(try RedisConnection.Configuration(url: "localhost:6379")) + XCTAssertThrowsError(try RedisConnection.Configuration(url: "redis://:6379")) + XCTAssertThrowsError(try RedisConnection.Configuration(url: "redis://localhost/-1")) + } + + func test_connectionConfiguration_urlComponents() throws { + let configuration = try RedisConnection.Configuration(url: "redis://:password@localhost:6666/1") + XCTAssertEqual(configuration.hostname, "localhost") + XCTAssertEqual(configuration.password, "password") + XCTAssertEqual(configuration.port, 6666) + XCTAssertEqual(configuration.initialDatabase, 1) + } + + func test_connectionConfiguration_databaseIndex() throws { + let address = try SocketAddress.makeAddressResolvingHost("localhost", port: RedisConnection.Configuration.defaultPort) + XCTAssertThrowsError(try RedisConnection.Configuration(address: address, initialDatabase: -1)) { + XCTAssertEqual($0 as? RedisConnection.Configuration.ValidationError, .outOfBoundsDatabaseID) + } + } +}