Skip to content

Commit bbea910

Browse files
SWIFT-1405 Implement description property for MongoConnectionString (#714)
1 parent 51ae967 commit bbea910

File tree

2 files changed

+180
-47
lines changed

2 files changed

+180
-47
lines changed

Sources/MongoSwift/MongoConnectionString.swift

Lines changed: 139 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@ import SwiftBSON
44
/// Represents a MongoDB connection string.
55
/// - SeeAlso: https://docs.mongodb.com/manual/reference/connection-string/
66
public struct MongoConnectionString: Codable, LosslessStringConvertible {
7+
/// Characters that must not be present in a database name.
78
private static let forbiddenDBCharacters = ["/", "\\", " ", "\"", "$"]
8-
/// Forbidden characters per RFC 3986.
9+
10+
/// General delimiters as defined by RFC 3986. These characters must be percent-encoded when present in the hosts,
11+
/// default authentication database, and user info.
912
/// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
10-
fileprivate static let forbiddenUserInfoCharacters = [":", "/", "?", "#", "[", "]", "@"]
13+
fileprivate static let genDelims = ":/?#[]@"
14+
15+
/// Characters that do not need to be percent-encoded when reconstructing the hosts, default authentication
16+
/// database, and user info.
17+
fileprivate static let allowedForNonOptionEncoding = CharacterSet(charactersIn: genDelims).inverted
18+
19+
/// Characters that do not need to be percent-encoded when reconstructing URI options.
20+
fileprivate static let allowedForOptionEncoding = CharacterSet(charactersIn: "=&,:").inverted
1121

1222
fileprivate enum OptionName: String {
1323
case appName = "appname"
@@ -161,17 +171,21 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
161171
}
162172

163173
public var description: String {
164-
var ret = ""
174+
var hostDescription = ""
165175
switch self.type {
166176
case .ipLiteral:
167-
ret += "[\(self.host)]"
168-
default:
169-
ret += "\(self.host)"
177+
hostDescription += "[\(self.host)]"
178+
case .ipv4:
179+
hostDescription += self.host
180+
case .unixDomainSocket, .hostname:
181+
hostDescription += self.host.getPercentEncoded(
182+
withAllowedCharacters: MongoConnectionString.allowedForNonOptionEncoding
183+
)
170184
}
171185
if let port = self.port {
172-
ret += ":\(port)"
186+
hostDescription += ":\(port)"
173187
}
174-
return ret
188+
return hostDescription
175189
}
176190
}
177191

@@ -573,7 +587,7 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
573587
self.credential?.source = authSource
574588
} else {
575589
// The authentication mechanism defaults to SCRAM if an authMechanism is not provided, and SCRAM
576-
// requires a username
590+
// requires a username.
577591
throw MongoError.InvalidArgumentError(
578592
message: "No username provided for authentication in the connection string but an authentication"
579593
+ " source was provided. To use an authentication mechanism that does not require a username,"
@@ -754,12 +768,104 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
754768
try? self.init(throwsIfInvalid: description)
755769
}
756770

757-
// TODO: SWIFT-1405: add options to description
758771
public var description: String {
759-
var des = ""
760-
des += "\(self.scheme)://"
761-
des += self.hosts.map { $0.description }.joined(separator: ",")
762-
return des
772+
var uri = ""
773+
uri += "\(self.scheme)://"
774+
if let username = self.credential?.username {
775+
uri += username.getPercentEncoded(withAllowedCharacters: Self.allowedForNonOptionEncoding)
776+
if let password = self.credential?.password {
777+
uri += ":" + password.getPercentEncoded(withAllowedCharacters: Self.allowedForNonOptionEncoding)
778+
}
779+
uri += "@"
780+
}
781+
uri += self.hosts.map { $0.description }.joined(separator: ",")
782+
// A trailing slash in the connection string is valid so we can append this unconditionally.
783+
uri += "/"
784+
if let defaultAuthDB = self.defaultAuthDB {
785+
uri += defaultAuthDB.getPercentEncoded(withAllowedCharacters: Self.allowedForNonOptionEncoding)
786+
}
787+
uri += "?"
788+
uri.appendOption(name: .appName, option: self.appName)
789+
uri.appendOption(name: .authMechanism, option: self.credential?.mechanism?.description)
790+
uri.appendOption(name: .authMechanismProperties, option: self.credential?.mechanismProperties?.map {
791+
var property = $0.key + ":"
792+
switch $0.value {
793+
case let .string(s):
794+
property += s
795+
case let .bool(b):
796+
property += String(b)
797+
// the possible values for authMechanismProperties are only strings and booleans
798+
default:
799+
property += ""
800+
}
801+
return property
802+
}.joined(separator: ","))
803+
uri.appendOption(name: .authSource, option: self.credential?.source)
804+
uri.appendOption(name: .compressors, option: self.compressors?.map {
805+
switch $0._compressor {
806+
case let .zlib(level):
807+
uri.appendOption(name: .zlibCompressionLevel, option: level)
808+
return "zlib"
809+
}
810+
}.joined(separator: ","))
811+
uri.appendOption(name: .connectTimeoutMS, option: self.connectTimeoutMS)
812+
uri.appendOption(name: .directConnection, option: self.directConnection)
813+
uri.appendOption(name: .heartbeatFrequencyMS, option: self.heartbeatFrequencyMS)
814+
uri.appendOption(name: .journal, option: self.writeConcern?.journal)
815+
uri.appendOption(name: .loadBalanced, option: self.loadBalanced)
816+
uri.appendOption(name: .localThresholdMS, option: self.localThresholdMS)
817+
uri.appendOption(name: .maxPoolSize, option: self.maxPoolSize)
818+
uri.appendOption(name: .maxStalenessSeconds, option: self.readPreference?.maxStalenessSeconds)
819+
uri.appendOption(name: .readConcernLevel, option: self.readConcern?.level)
820+
uri.appendOption(name: .readPreference, option: self.readPreference?.mode.rawValue)
821+
if let tagSets = self.readPreference?.tagSets {
822+
for tags in tagSets {
823+
uri.appendOption(name: .readPreferenceTags, option: tags.map {
824+
var tag = $0.key + ":"
825+
switch $0.value {
826+
case let .string(s):
827+
tag += s
828+
// tags are always parsed as strings
829+
default:
830+
tag += ""
831+
}
832+
return tag
833+
}.joined(separator: ","))
834+
}
835+
}
836+
uri.appendOption(name: .replicaSet, option: self.replicaSet)
837+
uri.appendOption(name: .retryReads, option: self.retryReads)
838+
uri.appendOption(name: .retryWrites, option: self.retryWrites)
839+
uri.appendOption(name: .serverSelectionTimeoutMS, option: self.serverSelectionTimeoutMS)
840+
uri.appendOption(name: .socketTimeoutMS, option: self.socketTimeoutMS)
841+
uri.appendOption(name: .srvMaxHosts, option: self.srvMaxHosts)
842+
uri.appendOption(name: .srvServiceName, option: self.srvServiceName)
843+
uri.appendOption(name: .tls, option: self.tls)
844+
uri.appendOption(name: .tlsAllowInvalidCertificates, option: self.tlsAllowInvalidCertificates)
845+
uri.appendOption(name: .tlsAllowInvalidHostnames, option: self.tlsAllowInvalidHostnames)
846+
uri.appendOption(name: .tlsCAFile, option: self.tlsCAFile)
847+
uri.appendOption(name: .tlsCertificateKeyFile, option: self.tlsCertificateKeyFile)
848+
uri.appendOption(name: .tlsCertificateKeyFilePassword, option: self.tlsCertificateKeyFilePassword)
849+
uri.appendOption(
850+
name: .tlsDisableCertificateRevocationCheck,
851+
option: self.tlsDisableCertificateRevocationCheck
852+
)
853+
uri.appendOption(name: .tlsDisableOCSPEndpointCheck, option: self.tlsDisableOCSPEndpointCheck)
854+
uri.appendOption(name: .tlsInsecure, option: self.tlsInsecure)
855+
uri.appendOption(name: .w, option: self.writeConcern?.w.map {
856+
switch $0 {
857+
case let .number(n):
858+
return String(n)
859+
case .majority:
860+
return "majority"
861+
case let .custom(other):
862+
return other
863+
}
864+
})
865+
uri.appendOption(name: .wTimeoutMS, option: self.writeConcern?.wtimeoutMS)
866+
// Pop either the trailing "&" or the trailing "?" if no options were present.
867+
_ = uri.popLast()
868+
return uri
763869
}
764870

765871
/// Returns a document containing all of the options provided after the ? of the URI.
@@ -769,15 +875,15 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
769875
if let appName = self.appName {
770876
options[.appName] = .string(appName)
771877
}
772-
if let authSource = self.credential?.source {
773-
options[.authSource] = .string(authSource)
774-
}
775878
if let authMechanism = self.credential?.mechanism {
776879
options[.authMechanism] = .string(authMechanism.description)
777880
}
778881
if let authMechanismProperties = self.credential?.mechanismProperties {
779882
options[.authMechanismProperties] = .document(authMechanismProperties)
780883
}
884+
if let authSource = self.credential?.source {
885+
options[.authSource] = .string(authSource)
886+
}
781887
if let compressors = self.compressors {
782888
var compressorNames: [BSON] = []
783889
for compressor in compressors {
@@ -1035,6 +1141,17 @@ extension Compressor {
10351141
}
10361142
}
10371143

1144+
extension String {
1145+
fileprivate mutating func appendOption(name: MongoConnectionString.OptionName, option: CustomStringConvertible?) {
1146+
if let option = option {
1147+
let optionString = option.description.getPercentEncoded(
1148+
withAllowedCharacters: MongoConnectionString.allowedForOptionEncoding
1149+
)
1150+
self += name.rawValue + "=" + optionString + "&"
1151+
}
1152+
}
1153+
}
1154+
10381155
extension StringProtocol {
10391156
fileprivate func getPercentDecoded(forKey key: String) throws -> String {
10401157
guard let decoded = self.removingPercentEncoding else {
@@ -1058,8 +1175,12 @@ extension StringProtocol {
10581175
return true
10591176
}
10601177

1178+
fileprivate func getPercentEncoded(withAllowedCharacters allowed: CharacterSet) -> String {
1179+
self.addingPercentEncoding(withAllowedCharacters: allowed) ?? String(self)
1180+
}
1181+
10611182
fileprivate func getValidatedUserInfo(forKey key: String) throws -> String {
1062-
for character in MongoConnectionString.forbiddenUserInfoCharacters {
1183+
for character in MongoConnectionString.genDelims {
10631184
if self.contains(character) {
10641185
throw MongoError.InvalidArgumentError(
10651186
message: "\(key) in the connection string contains invalid character that must be percent-encoded:"

Tests/MongoSwiftTests/ConnectionStringTests.swift

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,38 @@ func shouldSkip(file: String, test: String) -> Bool {
9191
return false
9292
}
9393

94+
extension MongoConnectionString {
95+
fileprivate func assertMatchesTestCase(_ testCase: ConnectionStringTestCase) {
96+
// Assert that hosts match, if present.
97+
if let expectedHosts = testCase.hosts {
98+
let actualHosts = self.hosts
99+
for expectedHost in expectedHosts {
100+
guard actualHosts.contains(where: { expectedHost.matches($0) }) else {
101+
XCTFail("No host found matching \(expectedHost) in host list \(actualHosts)")
102+
return
103+
}
104+
}
105+
}
106+
107+
// Assert that authentication information matches, if present.
108+
if let expectedAuth = testCase.auth {
109+
let actual = self.credential ?? MongoCredential()
110+
guard expectedAuth.matches(actual) else {
111+
XCTFail("Expected credentials: \(expectedAuth) do not match parsed credentials: \(actual)")
112+
return
113+
}
114+
}
115+
116+
// Assert that options match, if present.
117+
if let expectedOptions = testCase.options {
118+
let actualOptions = self.options
119+
for (key, value) in expectedOptions {
120+
expect(actualOptions[key.lowercased()]).to(sortedEqual(value))
121+
}
122+
}
123+
}
124+
}
125+
94126
final class ConnectionStringTests: MongoSwiftTestCase {
95127
func runTests(_ specName: String) throws {
96128
let testFiles = try retrieveSpecTestFiles(specName: specName, asType: ConnectionStringTestFile.self)
@@ -113,35 +145,15 @@ final class ConnectionStringTests: MongoSwiftTestCase {
113145
continue
114146
}
115147

148+
// Assert that the MongoConnectionString matches the expected output.
116149
let connString = try MongoConnectionString(throwsIfInvalid: testCase.uri)
150+
connString.assertMatchesTestCase(testCase)
117151

118-
// Assert that hosts match, if present
119-
if let expectedHosts = testCase.hosts {
120-
let actualHosts = connString.hosts
121-
for expectedHost in expectedHosts {
122-
guard actualHosts.contains(where: { expectedHost.matches($0) }) else {
123-
XCTFail("No host found matching \(expectedHost) in host list \(actualHosts)")
124-
continue
125-
}
126-
}
127-
}
128-
129-
// Assert that auth matches, if present
130-
if let expectedAuth = testCase.auth {
131-
let actual = connString.credential ?? MongoCredential()
132-
guard expectedAuth.matches(actual) else {
133-
XCTFail("Expected credentials: \(expectedAuth) do not match parsed credentials: \(actual)")
134-
continue
135-
}
136-
}
137-
138-
// Assert that options match, if present
139-
if let expectedOptions = testCase.options {
140-
let actualOptions = connString.options
141-
for (key, value) in expectedOptions {
142-
expect(actualOptions[key.lowercased()]).to(sortedEqual(value))
143-
}
144-
}
152+
// Assert that the URI successfully round-trips through the MongoConnectionString's description
153+
// property. Note that we cannot compare the description to the original URI directly because the
154+
// ordering of the options is not preserved.
155+
let connStringFromDescription = try MongoConnectionString(throwsIfInvalid: connString.description)
156+
connStringFromDescription.assertMatchesTestCase(testCase)
145157
}
146158
}
147159
}
@@ -155,11 +167,11 @@ final class ConnectionStringTests: MongoSwiftTestCase {
155167
}
156168

157169
func testCodable() throws {
158-
let connStr = try MongoConnectionString(throwsIfInvalid: "mongodb://localhost:27017")
170+
let connStr = try MongoConnectionString(throwsIfInvalid: "mongodb://localhost:27017/")
159171
let encodedData = try JSONEncoder().encode(connStr)
160172
let decodedResult = try JSONDecoder().decode(MongoConnectionString.self, from: encodedData)
161173
expect(connStr.description).to(equal(decodedResult.description))
162-
expect(connStr.description).to(equal("mongodb://localhost:27017"))
174+
expect(connStr.description).to(equal("mongodb://localhost:27017/"))
163175
}
164176

165177
// TODO: SWIFT-1416 Test string conversion behavior after changing to MongoConnectionString

0 commit comments

Comments
 (0)