Skip to content

Commit 16eb445

Browse files
feat: update http requests, responses and endpoint with URI (#719)
* chore: update http requests, responses and endpoint with uri * feat: add validations and builder to URI * Remove `try`, update `uri.query` to `uri.queryItems` and add `destination` to `RequestMessage` * Update `URI.query` to `URI.queryItems` * Refactor URI and add docs * Set `SdkHttpRequest.destination` to `let` * Update `URIBuilder` to always return a percent encoded path in `URI` * Update `URIBuilder` to add port to URLComponents and to be able to clear queryItems * Update port to be nullable in SdkHttpRequest and Endpoint * Add URITests * Remove `try` * Update query item related vars to func * Add fragment * Address swiftlint warnings in PR * Add more URI tests * Add isPercentEncoded check to username, password and host in URI * Set urlComponents.percentEncodedHost if os is not linux * Add URITests test cases with encoded and unencoded reserved characters * Address swiftlint warnings
1 parent 0599776 commit 16eb445

File tree

21 files changed

+629
-136
lines changed

21 files changed

+629
-136
lines changed

Sources/ClientRuntime/Endpoints/ServiceEndpointMetadata.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ extension ServiceEndpointMetadata {
5252

5353
return SmithyEndpoint(endpoint: Endpoint(host: hostname,
5454
path: "/",
55-
protocolType: ProtocolType(rawValue: transportProtocol)),
55+
protocolType: ProtocolType(rawValue: transportProtocol)!),
5656
signingName: signingName)
5757
}
5858

Sources/ClientRuntime/Message/RequestMessage.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ public protocol RequestMessage {
1717
/// The body of the request.
1818
var body: ByteStream { get }
1919

20+
// The uri of the request
21+
var destination: URI { get }
22+
2023
/// - Returns: A new builder for this request message, with all properties copied.
2124
func toBuilder() -> RequestBuilderType
2225
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
import Foundation
7+
8+
/// A representation of the RFC 3986 Uniform Resource Identifier
9+
/// Note: URIBuilder returns an URI instance with all components percent encoded
10+
public struct URI: Hashable {
11+
public let scheme: Scheme
12+
public let path: String
13+
public let host: String
14+
public let port: Int16?
15+
public var defaultPort: Int16 {
16+
Int16(scheme.port)
17+
}
18+
public let queryItems: [SDKURLQueryItem]
19+
public let username: String?
20+
public let password: String?
21+
public let fragment: String?
22+
public var url: URL? {
23+
self.toBuilder().getUrl()
24+
}
25+
public var queryString: String? {
26+
self.queryItems.queryString
27+
}
28+
29+
fileprivate init(scheme: Scheme,
30+
path: String,
31+
host: String,
32+
port: Int16?,
33+
queryItems: [SDKURLQueryItem],
34+
username: String? = nil,
35+
password: String? = nil,
36+
fragment: String? = nil) {
37+
self.scheme = scheme
38+
self.path = path
39+
self.host = host
40+
self.port = port
41+
self.queryItems = queryItems
42+
self.username = username
43+
self.password = password
44+
self.fragment = fragment
45+
}
46+
47+
public func toBuilder() -> URIBuilder {
48+
return URIBuilder()
49+
.withScheme(self.scheme)
50+
.withPath(self.path)
51+
.withHost(self.host)
52+
.withPort(self.port)
53+
.withQueryItems(self.queryItems)
54+
.withUsername(self.username)
55+
.withPassword(self.password)
56+
.withFragment(self.fragment)
57+
}
58+
}
59+
60+
/// A builder class for URI
61+
/// The builder performs validation to conform with RFC 3986
62+
/// Note: URIBuilder returns an URI instance with all components percent encoded
63+
public final class URIBuilder {
64+
var urlComponents: URLComponents
65+
66+
public init() {
67+
self.urlComponents = URLComponents()
68+
self.urlComponents.percentEncodedPath = "/"
69+
self.urlComponents.scheme = Scheme.https.rawValue
70+
self.urlComponents.host = ""
71+
}
72+
73+
@discardableResult
74+
public func withScheme(_ value: Scheme) -> URIBuilder {
75+
self.urlComponents.scheme = value.rawValue
76+
return self
77+
}
78+
79+
/// According to https://developer.apple.com/documentation/foundation/nsurlcomponents/1408161-percentencodedpath
80+
/// "Although an unencoded semicolon is a valid character in a percent-encoded path,
81+
/// for compatibility with the NSURL class, you should always percent-encode it."
82+
///
83+
/// URI also always return a percent-encoded path.
84+
/// If an percent-encoded path is provided, we will replace the semicolon with %3B in the path.
85+
/// If an unencoded path is provided, we should percent-encode the path including semicolon.
86+
@discardableResult
87+
public func withPath(_ value: String) -> URIBuilder {
88+
if value.isPercentEncoded {
89+
if value.contains(";") {
90+
let encodedPath = value.replacingOccurrences(
91+
of: ";", with: "%3B", options: NSString.CompareOptions.literal, range: nil)
92+
self.urlComponents.percentEncodedPath = encodedPath
93+
} else {
94+
self.urlComponents.percentEncodedPath = value
95+
}
96+
} else {
97+
if value.contains(";") {
98+
self.urlComponents.percentEncodedPath = value.percentEncodePathIncludingSemicolon()
99+
} else {
100+
self.urlComponents.path = value
101+
}
102+
}
103+
return self
104+
}
105+
106+
@discardableResult
107+
public func withHost(_ value: String) -> URIBuilder {
108+
if value.isPercentEncoded {
109+
// URLComponents.percentEncodedHost follows RFC 3986
110+
// and returns a decoded value if it is set with a percent encoded value
111+
// However on Linux platform, it returns a percent encoded value.
112+
// To ensure consistent behaviour, we will decode it ourselves on Linux platform
113+
if currentOS == .linux {
114+
self.urlComponents.host = value.removingPercentEncoding!
115+
} else {
116+
self.urlComponents.percentEncodedHost = value
117+
}
118+
} else {
119+
self.urlComponents.host = value
120+
}
121+
return self
122+
}
123+
124+
@discardableResult
125+
public func withPort(_ value: Int16?) -> URIBuilder {
126+
self.urlComponents.port = value.map { Int($0) }
127+
return self
128+
}
129+
130+
@discardableResult
131+
public func withPort(_ value: Int?) -> URIBuilder {
132+
self.urlComponents.port = value
133+
return self
134+
}
135+
136+
@discardableResult
137+
public func withQueryItems(_ value: [SDKURLQueryItem]) -> URIBuilder {
138+
if value.isEmpty {
139+
return self
140+
}
141+
if value.containsPercentEncode() {
142+
self.urlComponents.percentEncodedQueryItems = value.toURLQueryItems()
143+
} else {
144+
self.urlComponents.queryItems = value.toURLQueryItems()
145+
}
146+
return self
147+
}
148+
149+
@discardableResult
150+
public func appendQueryItems(_ items: [SDKURLQueryItem]) -> URIBuilder {
151+
guard !items.isEmpty else {
152+
return self
153+
}
154+
var queryItems = self.urlComponents.percentEncodedQueryItems ?? []
155+
queryItems += items.toURLQueryItems()
156+
157+
if queryItems.containsPercentEncode() {
158+
self.urlComponents.percentEncodedQueryItems = queryItems
159+
} else {
160+
self.urlComponents.queryItems = queryItems
161+
}
162+
163+
return self
164+
}
165+
166+
@discardableResult
167+
public func appendQueryItem(_ item: SDKURLQueryItem) -> URIBuilder {
168+
self.appendQueryItems([item])
169+
return self
170+
}
171+
172+
@discardableResult
173+
public func withUsername(_ value: String?) -> URIBuilder {
174+
if let username = value {
175+
if username.isPercentEncoded {
176+
self.urlComponents.percentEncodedUser = username
177+
} else {
178+
self.urlComponents.user = username
179+
}
180+
}
181+
return self
182+
}
183+
184+
@discardableResult
185+
public func withPassword(_ value: String?) -> URIBuilder {
186+
if let password = value {
187+
if password.isPercentEncoded {
188+
self.urlComponents.percentEncodedPassword = password
189+
} else {
190+
self.urlComponents.password = password
191+
}
192+
}
193+
return self
194+
}
195+
196+
@discardableResult
197+
public func withFragment(_ value: String?) -> URIBuilder {
198+
if let fragment = value {
199+
if fragment.isPercentEncoded {
200+
self.urlComponents.percentEncodedFragment = fragment
201+
} else {
202+
self.urlComponents.fragment = fragment
203+
}
204+
}
205+
return self
206+
}
207+
208+
public func build() -> URI {
209+
return URI(scheme: Scheme(rawValue: self.urlComponents.scheme!)!,
210+
path: self.urlComponents.percentEncodedPath,
211+
host: self.urlComponents.percentEncodedHost!,
212+
port: self.urlComponents.port.map { Int16($0) },
213+
queryItems: self.urlComponents.percentEncodedQueryItems?.map {
214+
SDKURLQueryItem(name: $0.name, value: $0.value)
215+
} ?? [],
216+
username: self.urlComponents.percentEncodedUser,
217+
password: self.urlComponents.percentEncodedPassword,
218+
fragment: self.urlComponents.percentEncodedFragment)
219+
}
220+
221+
// We still have to keep 'url' as an optional, since we're
222+
// dealing with dynamic components that could be invalid.
223+
fileprivate func getUrl() -> URL? {
224+
let isInvalidHost = self.urlComponents.host?.isEmpty ?? false
225+
return isInvalidHost && self.urlComponents.path.isEmpty ? nil : self.urlComponents.url
226+
}
227+
}
228+
229+
extension String {
230+
var isPercentEncoded: Bool {
231+
let decoded = self.removingPercentEncoding
232+
return decoded != nil && decoded != self
233+
}
234+
235+
public func percentEncodePathIncludingSemicolon() -> String {
236+
let allowed =
237+
// swiftlint:disable force_cast
238+
(CharacterSet.urlPathAllowed as NSCharacterSet).mutableCopy() as! NSMutableCharacterSet
239+
// swiftlint:enable force_cast
240+
allowed.removeCharacters(in: ";")
241+
return self.addingPercentEncoding(withAllowedCharacters: allowed as CharacterSet)!
242+
}
243+
244+
public func percentEncodeQuery() -> String {
245+
return self.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed as CharacterSet)!
246+
}
247+
}
248+
249+
extension Array where Element == SDKURLQueryItem {
250+
public var queryString: String? {
251+
if self.isEmpty {
252+
return nil
253+
}
254+
return self.map { [$0.name, $0.value].compactMap { $0 }.joined(separator: "=") }.joined(separator: "&")
255+
}
256+
257+
public func toURLQueryItems() -> [URLQueryItem] {
258+
return self.map { URLQueryItem(name: $0.name, value: $0.value) }
259+
}
260+
261+
public func containsPercentEncode() -> Bool {
262+
return self.contains { item in
263+
return item.name.isPercentEncoded || (item.value?.isPercentEncoded ?? false)
264+
}
265+
}
266+
}
267+
268+
extension Array where Element == URLQueryItem {
269+
public func containsPercentEncode() -> Bool {
270+
return self.contains { item in
271+
return item.name.isPercentEncoded || (item.value?.isPercentEncoded ?? false)
272+
}
273+
}
274+
}

0 commit comments

Comments
 (0)