Skip to content

Commit 949dfed

Browse files
authored
Replace Foundation replacingOccurrences(of:with:) and percent encoding (#176)
Part of apple/swift-openapi-generator#868 Introduces a Swift impl of `replacingOccurrences(of:with:)`, `addingPercentEncoding` and `removingPercentEncoding`. The percent encoding were inspired by impl in `swift-foundation`: https://github.com/swiftlang/swift-foundation/blob/aee1d337039b5b2a1f0c3b7f83baa950dac3a84e/Sources/FoundationEssentials/URL/URLParser.swift I also added tests to verify behaviour with Foundation.
1 parent 1ddb3ec commit 949dfed

6 files changed

Lines changed: 322 additions & 20 deletions

File tree

Sources/OpenAPIRuntime/Conversion/Converter+Client.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
#if canImport(FoundationEssentials)
16+
import FoundationEssentials
17+
#else
1518
import Foundation
19+
#endif
1620
public import HTTPTypes
1721

1822
extension Converter {
@@ -47,8 +51,9 @@ extension Converter {
4751
)
4852
for parameter in parameters {
4953
let value = try encoder.encode(parameter, forKey: "")
50-
if let range = renderedString.range(of: "{}") {
51-
renderedString = renderedString.replacingOccurrences(of: "{}", with: value, range: range)
54+
if renderedString.contains("{}") {
55+
// Only replacing one at a time
56+
renderedString = renderedString.replacingOccurrences(of: "{}", with: value, maxReplacements: 1)
5257
}
5358
}
5459
return renderedString

Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,158 @@ extension StringProtocol {
3131

3232
return String(self[start...end])
3333
}
34+
35+
/// Returns a new string in which all occurrences of a target
36+
/// string are replaced by another given string.
37+
@inlinable func replacingOccurrences<Replacement: StringProtocol>(
38+
of target: String,
39+
with replacement: Replacement,
40+
maxReplacements: Int = .max
41+
) -> String {
42+
guard !target.isEmpty, maxReplacements > 0 else { return String(self) }
43+
var result = ""
44+
result.reserveCapacity(self.count)
45+
var searchStart = self.startIndex
46+
var replacements = 0
47+
while replacements < maxReplacements,
48+
let foundRange = self.range(of: target, range: searchStart..<self.endIndex)
49+
{
50+
result.append(contentsOf: self[searchStart..<foundRange.lowerBound])
51+
result.append(contentsOf: replacement)
52+
searchStart = foundRange.upperBound
53+
replacements += 1
54+
}
55+
result.append(contentsOf: self[searchStart..<self.endIndex])
56+
return result
57+
}
58+
59+
@inlinable func range(of aString: String, range searchRange: Range<Self.Index>? = nil) -> Range<Self.Index>? {
60+
guard !aString.isEmpty else { return nil }
61+
62+
var current = searchRange?.lowerBound ?? self.startIndex
63+
let end = searchRange?.upperBound ?? self.endIndex
64+
65+
while current < end {
66+
let searchSlice = self[current..<end]
67+
68+
if searchSlice.hasPrefix(aString) {
69+
// We found the match, so lets iterate the index until we have a full match.
70+
var foundEnd = current
71+
while self[current..<foundEnd] != aString { foundEnd = self.index(after: foundEnd) }
72+
return current..<foundEnd
73+
}
74+
current = self.index(after: current)
75+
}
76+
return nil
77+
}
78+
79+
/// Returns a new string created by replacing all characters in the string
80+
/// not unreserved or spaces with percent encoded characters.
81+
func addingPercentEncodingAllowingUnreservedAndSpace() -> String {
82+
guard !self.isEmpty else { return String(self) }
83+
84+
let percent = UInt8(ascii: "%")
85+
let space = UInt8(ascii: " ")
86+
let utf8Buffer = self.utf8
87+
let maxLength = utf8Buffer.count * 3
88+
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer in
89+
var i = 0
90+
for byte in utf8Buffer {
91+
if byte.isUnreserved || byte == space {
92+
outputBuffer[i] = byte
93+
i += 1
94+
} else {
95+
outputBuffer[i] = percent
96+
outputBuffer[i + 1] = hexToAscii(byte >> 4)
97+
outputBuffer[i + 2] = hexToAscii(byte & 0xF)
98+
i += 3
99+
}
100+
}
101+
return String(decoding: outputBuffer[..<i], as: UTF8.self)
102+
}
103+
}
104+
105+
/// A new string made from the string by replacing all percent encoded
106+
/// sequences with the matching UTF-8 characters.
107+
func removingPercentEncoding() -> String? {
108+
let percent = UInt8(ascii: "%")
109+
let utf8Buffer = self.utf8
110+
let maxLength = utf8Buffer.count
111+
112+
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer -> String? in
113+
var i = 0
114+
var byte: UInt8 = 0
115+
var hexDigitsRequired = 0
116+
117+
for v in utf8Buffer {
118+
if v == percent {
119+
guard hexDigitsRequired == 0 else { return nil }
120+
hexDigitsRequired = 2
121+
} else if hexDigitsRequired > 0 {
122+
guard let hex = asciiToHex(v) else { return nil }
123+
124+
if hexDigitsRequired == 2 {
125+
byte = hex << 4
126+
} else if hexDigitsRequired == 1 {
127+
byte += hex
128+
outputBuffer[i] = byte
129+
i += 1
130+
byte = 0
131+
}
132+
hexDigitsRequired -= 1
133+
} else {
134+
outputBuffer[i] = v
135+
i += 1
136+
}
137+
}
138+
139+
guard hexDigitsRequired == 0 else { return nil }
140+
141+
return String(bytes: outputBuffer[..<i], encoding: .utf8)
142+
}
143+
}
144+
}
145+
146+
private func asciiToHex(_ ascii: UInt8) -> UInt8? {
147+
switch ascii {
148+
case UInt8(ascii: "0")...UInt8(ascii: "9"): return ascii - UInt8(ascii: "0")
149+
case UInt8(ascii: "A")...UInt8(ascii: "F"): return ascii - UInt8(ascii: "A") + 10
150+
case UInt8(ascii: "a")...UInt8(ascii: "f"): return ascii - UInt8(ascii: "a") + 10
151+
default: return nil
152+
}
153+
}
154+
155+
private func hexToAscii(_ hex: UInt8) -> UInt8 {
156+
switch hex {
157+
case 0x0: return UInt8(ascii: "0")
158+
case 0x1: return UInt8(ascii: "1")
159+
case 0x2: return UInt8(ascii: "2")
160+
case 0x3: return UInt8(ascii: "3")
161+
case 0x4: return UInt8(ascii: "4")
162+
case 0x5: return UInt8(ascii: "5")
163+
case 0x6: return UInt8(ascii: "6")
164+
case 0x7: return UInt8(ascii: "7")
165+
case 0x8: return UInt8(ascii: "8")
166+
case 0x9: return UInt8(ascii: "9")
167+
case 0xA: return UInt8(ascii: "A")
168+
case 0xB: return UInt8(ascii: "B")
169+
case 0xC: return UInt8(ascii: "C")
170+
case 0xD: return UInt8(ascii: "D")
171+
case 0xE: return UInt8(ascii: "E")
172+
case 0xF: return UInt8(ascii: "F")
173+
default: fatalError("Invalid hex digit: \(hex)")
174+
}
175+
}
176+
177+
extension UInt8 {
178+
/// Checks if a byte is an unreserved character per RFC 3986.
179+
fileprivate var isUnreserved: Bool {
180+
switch self {
181+
case UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "A")...UInt8(ascii: "Z"),
182+
UInt8(ascii: "a")...UInt8(ascii: "z"), UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"),
183+
UInt8(ascii: "~"):
184+
return true
185+
default: return false
186+
}
187+
}
34188
}

Sources/OpenAPIRuntime/Conversion/ServerVariable.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
#if canImport(FoundationEssentials)
16+
public import FoundationEssentials
17+
#else
1518
public import Foundation
19+
#endif
1620

1721
extension URL {
1822
/// Returns a validated server URL created from the URL template, or

Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
#if canImport(FoundationEssentials)
16+
import FoundationEssentials
17+
#else
1518
import Foundation
19+
#endif
1620

1721
/// A type that can parse a primitive, array, and a dictionary from a URI-encoded string.
1822
struct URIParser: Sendable {
@@ -337,7 +341,7 @@ extension URIParser {
337341
) -> Raw {
338342
// The inverse of URISerializer.computeSafeString.
339343
let partiallyDecoded = escapedValue.replacingOccurrences(of: spaceEscapingCharacter.rawValue, with: " ")
340-
return (partiallyDecoded.removingPercentEncoding ?? "")[...]
344+
return (partiallyDecoded.removingPercentEncoding() ?? "")[...]
341345
}
342346
}
343347

Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
#if canImport(FoundationEssentials)
16+
import FoundationEssentials
17+
#else
1518
import Foundation
19+
#endif
1620

1721
/// A type that serializes a `URIEncodedNode` to a URI-encoded string.
1822
struct URISerializer {
@@ -46,22 +50,6 @@ struct URISerializer {
4650
}
4751
}
4852

49-
extension CharacterSet {
50-
51-
/// A character set of unreserved symbols only from RFC 6570 (excludes
52-
/// alphanumeric characters).
53-
fileprivate static let unreservedSymbols: CharacterSet = .init(charactersIn: "-._~")
54-
55-
/// A character set of unreserved characters from RFC 6570.
56-
fileprivate static let unreserved: CharacterSet = .alphanumerics.union(unreservedSymbols)
57-
58-
/// A character set with only the space character.
59-
fileprivate static let space: CharacterSet = .init(charactersIn: " ")
60-
61-
/// A character set of unreserved characters and a space.
62-
fileprivate static let unreservedAndSpace: CharacterSet = .unreserved.union(space)
63-
}
64-
6553
extension URISerializer {
6654

6755
/// A serializer error.
@@ -105,7 +93,7 @@ extension URISerializer {
10593
// The space character needs to be encoded based on the config,
10694
// so first allow it to be unescaped, and then we'll do a second
10795
// pass and only encode the space based on the config.
108-
let partiallyEncoded = unsafeString.addingPercentEncoding(withAllowedCharacters: .unreservedAndSpace) ?? ""
96+
let partiallyEncoded = unsafeString.addingPercentEncodingAllowingUnreservedAndSpace()
10997
let fullyEncoded = partiallyEncoded.replacingOccurrences(
11098
of: " ",
11199
with: configuration.spaceEscapingCharacter.rawValue

0 commit comments

Comments
 (0)