diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 181d06d4..6c5b45f5 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif public import HTTPTypes extension Converter { @@ -47,8 +51,9 @@ extension Converter { ) for parameter in parameters { let value = try encoder.encode(parameter, forKey: "") - if let range = renderedString.range(of: "{}") { - renderedString = renderedString.replacingOccurrences(of: "{}", with: value, range: range) + if renderedString.contains("{}") { + // Only replacing one at a time + renderedString = renderedString.replacingOccurrences(of: "{}", with: value, maxReplacements: 1) } } return renderedString diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index 50dba1f2..9197647b 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -31,4 +31,158 @@ extension StringProtocol { return String(self[start...end]) } + + /// Returns a new string in which all occurrences of a target + /// string are replaced by another given string. + @inlinable func replacingOccurrences( + of target: String, + with replacement: Replacement, + maxReplacements: Int = .max + ) -> String { + guard !target.isEmpty, maxReplacements > 0 else { return String(self) } + var result = "" + result.reserveCapacity(self.count) + var searchStart = self.startIndex + var replacements = 0 + while replacements < maxReplacements, + let foundRange = self.range(of: target, range: searchStart..? = nil) -> Range? { + guard !aString.isEmpty else { return nil } + + var current = searchRange?.lowerBound ?? self.startIndex + let end = searchRange?.upperBound ?? self.endIndex + + while current < end { + let searchSlice = self[current.. String { + guard !self.isEmpty else { return String(self) } + + let percent = UInt8(ascii: "%") + let space = UInt8(ascii: " ") + let utf8Buffer = self.utf8 + let maxLength = utf8Buffer.count * 3 + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer in + var i = 0 + for byte in utf8Buffer { + if byte.isUnreserved || byte == space { + outputBuffer[i] = byte + i += 1 + } else { + outputBuffer[i] = percent + outputBuffer[i + 1] = hexToAscii(byte >> 4) + outputBuffer[i + 2] = hexToAscii(byte & 0xF) + i += 3 + } + } + return String(decoding: outputBuffer[.. String? { + let percent = UInt8(ascii: "%") + let utf8Buffer = self.utf8 + let maxLength = utf8Buffer.count + + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer -> String? in + var i = 0 + var byte: UInt8 = 0 + var hexDigitsRequired = 0 + + for v in utf8Buffer { + if v == percent { + guard hexDigitsRequired == 0 else { return nil } + hexDigitsRequired = 2 + } else if hexDigitsRequired > 0 { + guard let hex = asciiToHex(v) else { return nil } + + if hexDigitsRequired == 2 { + byte = hex << 4 + } else if hexDigitsRequired == 1 { + byte += hex + outputBuffer[i] = byte + i += 1 + byte = 0 + } + hexDigitsRequired -= 1 + } else { + outputBuffer[i] = v + i += 1 + } + } + + guard hexDigitsRequired == 0 else { return nil } + + return String(bytes: outputBuffer[.. UInt8? { + switch ascii { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return ascii - UInt8(ascii: "0") + case UInt8(ascii: "A")...UInt8(ascii: "F"): return ascii - UInt8(ascii: "A") + 10 + case UInt8(ascii: "a")...UInt8(ascii: "f"): return ascii - UInt8(ascii: "a") + 10 + default: return nil + } +} + +private func hexToAscii(_ hex: UInt8) -> UInt8 { + switch hex { + case 0x0: return UInt8(ascii: "0") + case 0x1: return UInt8(ascii: "1") + case 0x2: return UInt8(ascii: "2") + case 0x3: return UInt8(ascii: "3") + case 0x4: return UInt8(ascii: "4") + case 0x5: return UInt8(ascii: "5") + case 0x6: return UInt8(ascii: "6") + case 0x7: return UInt8(ascii: "7") + case 0x8: return UInt8(ascii: "8") + case 0x9: return UInt8(ascii: "9") + case 0xA: return UInt8(ascii: "A") + case 0xB: return UInt8(ascii: "B") + case 0xC: return UInt8(ascii: "C") + case 0xD: return UInt8(ascii: "D") + case 0xE: return UInt8(ascii: "E") + case 0xF: return UInt8(ascii: "F") + default: fatalError("Invalid hex digit: \(hex)") + } +} + +extension UInt8 { + /// Checks if a byte is an unreserved character per RFC 3986. + fileprivate var isUnreserved: Bool { + switch self { + case UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "a")...UInt8(ascii: "z"), UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), + UInt8(ascii: "~"): + return true + default: return false + } + } } diff --git a/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift index 69bf219d..3c7f631c 100644 --- a/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift +++ b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else public import Foundation +#endif extension URL { /// Returns a validated server URL created from the URL template, or diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index 9e1da427..67881a7c 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif /// A type that can parse a primitive, array, and a dictionary from a URI-encoded string. struct URIParser: Sendable { @@ -337,7 +341,7 @@ extension URIParser { ) -> Raw { // The inverse of URISerializer.computeSafeString. let partiallyDecoded = escapedValue.replacingOccurrences(of: spaceEscapingCharacter.rawValue, with: " ") - return (partiallyDecoded.removingPercentEncoding ?? "")[...] + return (partiallyDecoded.removingPercentEncoding() ?? "")[...] } } diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index aa606381..b2e62dca 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif /// A type that serializes a `URIEncodedNode` to a URI-encoded string. struct URISerializer { @@ -46,22 +50,6 @@ struct URISerializer { } } -extension CharacterSet { - - /// A character set of unreserved symbols only from RFC 6570 (excludes - /// alphanumeric characters). - fileprivate static let unreservedSymbols: CharacterSet = .init(charactersIn: "-._~") - - /// A character set of unreserved characters from RFC 6570. - fileprivate static let unreserved: CharacterSet = .alphanumerics.union(unreservedSymbols) - - /// A character set with only the space character. - fileprivate static let space: CharacterSet = .init(charactersIn: " ") - - /// A character set of unreserved characters and a space. - fileprivate static let unreservedAndSpace: CharacterSet = .unreserved.union(space) -} - extension URISerializer { /// A serializer error. @@ -105,7 +93,7 @@ extension URISerializer { // The space character needs to be encoded based on the config, // so first allow it to be unescaped, and then we'll do a second // pass and only encode the space based on the config. - let partiallyEncoded = unsafeString.addingPercentEncoding(withAllowedCharacters: .unreservedAndSpace) ?? "" + let partiallyEncoded = unsafeString.addingPercentEncodingAllowingUnreservedAndSpace() let fullyEncoded = partiallyEncoded.replacingOccurrences( of: " ", with: configuration.spaceEscapingCharacter.rawValue diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift index 1a21a271..8dd27ad3 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift @@ -46,4 +46,151 @@ final class Test_FoundationExtensions: Test_Runtime { let puncResult = punctuationString.trimming(while: { $0.isPunctuation }) XCTAssertEqual(puncResult, "Hello") } + + func testReplacingOccurencesMatchesFoundationBehaviour() { + // Tuple format: (Input String, Target to replace, Replacement string) + let testCases: [(input: String, target: String, replacement: String)] = [ + ("Hello World", "World", "Swift"), // Standard replacement + ("banana", "a", "o"), // Multiple occurrences + ("aaaa", "aa", "b"), // Overlapping occurrences (should result in "bb") + ("Nothing here", "xyz", "abc"), // Target not found + ("Case sensitive", "case", "X"), // Case sensitivity check (should not replace) + ("👨‍👩‍👧‍👦 Family", "👨‍👩‍👧‍👦", "👪"), // Complex Emoji / Grapheme clusters + ("Café", "é", "e"), // Accented characters + ("", "target", "replacement"), // Empty input string + ("exact match", "exact match", "success"), // Exact full string match + ] + + for testCase in testCases { + let foundationResult = testCase.input.replacingOccurrences(of: testCase.target, with: testCase.replacement) + let customResult = testCase.input.replacingOccurrences( + of: testCase.target, + with: testCase.replacement, + maxReplacements: .max + ) + XCTAssertEqual( + customResult, + foundationResult, + "Mismatch for input: '\(testCase.input)'. Expected '\(foundationResult)', got '\(customResult)'" + ) + } + } + + func testReplacingOccurencesMaxReplacementsLogic() { + let input = "a b a b a" + + let replaceOne = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 1) + XCTAssertEqual(replaceOne, "c b a b a", "Failed to limit replacement to 1") + + let replaceTwo = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 2) + XCTAssertEqual(replaceTwo, "c b c b a", "Failed to limit replacement to 2") + + let replaceTen = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 10) + XCTAssertEqual(replaceTen, "c b c b c", "Failed when maxReplacements is greater than occurrences") + + let replaceZero = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 0) + XCTAssertEqual(replaceZero, input, "String should remain unchanged when maxReplacements is 0") + } + + private var unreservedAndSpace: CharacterSet { + var charset = CharacterSet.alphanumerics + charset.insert(charactersIn: "-._~ ") + return charset + } + + func testAddingPercentEncodingAllowingUnreservedAndSpace() { + let testCases = [ + "HelloWorld", // Alphanumerics (No encoding needed) + "Hello World", // Space (Should NOT be encoded based on requirements) + "user@email.com", // '@' is reserved (Should be encoded) + "price=$100&tax=yes", // '=', '$', '&' are reserved (Should be encoded) + "café", // Non-ASCII character (Should be encoded) + "👨‍💻 swift", // Emoji and space + "~_.-", // Unreserved punctuation (Should NOT be encoded) + "", // Empty string + "100% coverage", // '%' symbol itself (Must be encoded to %25) + ] + + for input in testCases { + let foundationResult = input.addingPercentEncoding(withAllowedCharacters: unreservedAndSpace) + let customResult = input.addingPercentEncodingAllowingUnreservedAndSpace() + XCTAssertEqual( + customResult, + foundationResult, + "Encoding mismatch for input: '\(input)'. Expected '\(String(describing: foundationResult))', got '\(String(describing: customResult))'" + ) + } + } + + func testRemovingPercentEncoding() { + let testCases = [ + "HelloWorld", // Nothing to decode + "Hello%20World", // Standard space decoding + "user%40email.com", // Reserved character decoding ('@') + "caf%C3%A9", // UTF-8 multibyte decoding ('é') + "%F0%9F%91%A8%E2%80%8D%F0%9F%92%BB", // Complex Emoji decoding + "~_.-", // Unencoded unreserved characters + "", // Empty string + "100%25%20coverage", // Decodes to "100% coverage" + + // Edge Cases & Invalid Inputs + "Hello%2World", // Malformed percent encoding (missing second hex digit) + "%ZZ", // Invalid hex characters + "%FF", // Valid hex, but invalid UTF-8 byte sequence + ] + + for input in testCases { + let foundationResult = input.removingPercentEncoding + let customResult = input.removingPercentEncoding() + XCTAssertEqual( + customResult, + foundationResult, + "Decoding mismatch for input: '\(input)'. Expected '\(String(describing: foundationResult))', got '\(String(describing: customResult))'" + ) + } + } + + func testStringRangeOf() { + // Tuple format: (Input, Target, Optional bounds for the search rangess + let testCases: [(input: String, target: String, bounds: (start: Int, end: Int)?)] = [ + ("Hello World", "World", nil), // Standard match, full string + ("Hello World", "Hello", nil), // Standard match at the start + ("banana", "na", nil), // Multiple occurrences, full string + ("banana", "na", (0, 4)), // Bounded search: "bana" (should find first "na") + ("banana", "na", (3, 6)), // Bounded search: "ana" (should find second "na") + ("banana", "na", (0, 3)), // Bounded search: "ban" (should return nil, "na" cut off) + ("aaaa", "aa", nil), // Overlapping occurrences + ("Case Sensitive", "case", nil), // Case mismatch + ("Missing text", "xyz", nil), // Target not in string + ("👨‍👩‍👧‍👦 Family", "Family", nil), // Emoji / Grapheme clusters + ("Café menu", "é", (0, 5)), // Accented characters within bounds "Café " + ("exact match", "exact match", nil), // Target equals entire string + ("Short", "Longer target string", nil), // Target is longer than the input + ("Empty target test", "", nil), // Empty target string + ("", "Target in empty string", nil), // Empty input string + ("", "", nil), // Both empty + ] + + for testCase in testCases { + var searchRange: Range? = nil + if let bounds = testCase.bounds { + let start = testCase.input.index(testCase.input.startIndex, offsetBy: bounds.start) + let end = testCase.input.index(testCase.input.startIndex, offsetBy: bounds.end) + searchRange = start..