11import Foundation
22
3+ /// A builder type that appends HTTP request data to a URL.
4+ ///
5+ /// Calling this class's url related functions (the ones that changes path, query, etc) does not modify the
6+ /// original URL string. The URL will be perserved in the final result that's returned by the `build` function.
37final class HTTPRequestBuilder {
48 enum Method : String {
59 case get = " GET "
@@ -13,17 +17,20 @@ final class HTTPRequestBuilder {
1317 }
1418 }
1519
16- private var urlComponents : URLComponents
20+ private let original : URLComponents
1721 private var method : Method = . get
22+ private var appendedPath : String = " "
1823 private var headers : [ String : String ] = [ : ]
24+ private var defaultQuery : [ URLQueryItem ] = [ ]
25+ private var appendedQuery : [ URLQueryItem ] = [ ]
1926 private var bodyBuilder : ( ( inout URLRequest ) throws -> Void ) ?
2027 private( set) var multipartForm : [ MultipartFormField ] ?
2128
2229 init ( url: URL ) {
2330 assert ( url. scheme == " http " || url. scheme == " https " )
2431 assert ( url. host != nil )
2532
26- urlComponents = URLComponents ( url: url, resolvingAgainstBaseURL: true ) !
33+ original = URLComponents ( url: url, resolvingAgainstBaseURL: true ) !
2734 }
2835
2936 func method( _ method: Method ) -> Self {
@@ -34,16 +41,7 @@ final class HTTPRequestBuilder {
3441 func append( path: String ) -> Self {
3542 assert ( !path. contains ( " ? " ) && !path. contains ( " # " ) , " Path should not have query or fragment: \( path) " )
3643
37- var relPath = path
38- if relPath. hasPrefix ( " / " ) {
39- _ = relPath. removeFirst ( )
40- }
41-
42- if urlComponents. path. hasSuffix ( " / " ) {
43- urlComponents. path = urlComponents. path. appending ( relPath)
44- } else {
45- urlComponents. path = urlComponents. path. appending ( " / " ) . appending ( relPath)
46- }
44+ appendedPath = Self . join ( appendedPath, path)
4745
4846 return self
4947 }
@@ -53,32 +51,34 @@ final class HTTPRequestBuilder {
5351 return self
5452 }
5553
54+ func query( defaults: [ URLQueryItem ] ) -> Self {
55+ defaultQuery = defaults
56+ return self
57+ }
58+
5659 func query( name: String , value: String ? , override: Bool = false ) -> Self {
5760 append ( query: [ URLQueryItem ( name: name, value: value) ] , override: override)
5861 }
5962
60- func append( query: [ URLQueryItem ] , override: Bool = false ) -> Self {
61- var allQuery = urlComponents. queryItems ?? [ ]
63+ func query( _ parameters: [ String : Any ] ) -> Self {
64+ append ( query: parameters. flatten ( ) , override: false )
65+ }
6266
67+ func append( query: [ URLQueryItem ] , override: Bool = false ) -> Self {
6368 if override {
6469 let newKeys = Set ( query. map { $0. name } )
65- allQuery . removeAll ( where: { newKeys. contains ( $0. name) } )
70+ appendedQuery . removeAll ( where: { newKeys. contains ( $0. name) } )
6671 }
6772
68- allQuery. append ( contentsOf: query)
69-
70- urlComponents. queryItems = allQuery
73+ appendedQuery. append ( contentsOf: query)
7174
7275 return self
7376 }
7477
75- func body( form: [ String : String ] ) -> Self {
78+ func body( form: [ String : Any ] ) -> Self {
7679 headers [ " Content-Type " ] = " application/x-www-form-urlencoded; charset=utf-8 "
7780 bodyBuilder = { req in
78- let content = form. map {
79- " \( HTTPRequestBuilder . urlEncode ( $0) ) = \( HTTPRequestBuilder . urlEncode ( $1) ) "
80- }
81- . joined ( separator: " & " )
81+ let content = form. flatten ( ) . percentEncoded
8282 req. httpBody = content. data ( using: . utf8)
8383 }
8484 return self
@@ -119,7 +119,29 @@ final class HTTPRequestBuilder {
119119 }
120120
121121 func build( encodeMultipartForm: Bool = false ) throws -> URLRequest {
122- guard let url = urlComponents. url else {
122+ var components = original
123+
124+ var newPath = Self . join ( components. path, appendedPath)
125+ if !newPath. isEmpty, !newPath. hasPrefix ( " / " ) {
126+ newPath = " / \( newPath) "
127+ }
128+ components. path = newPath
129+
130+ // Add default query items if they don't exist in `appendedQuery`.
131+ var newQuery = appendedQuery
132+ if !defaultQuery. isEmpty {
133+ let toBeAdded = defaultQuery. filter { item in
134+ !newQuery. contains ( where: { $0. name == item. name} )
135+ }
136+ newQuery. append ( contentsOf: toBeAdded)
137+ }
138+
139+ // Bypass `URLComponents`'s URL query encoding, use our own implementation instead.
140+ if !newQuery. isEmpty {
141+ components. percentEncodedQuery = Self . join ( components. percentEncodedQuery ?? " " , newQuery. percentEncoded, separator: " & " )
142+ }
143+
144+ guard let url = components. url else {
123145 throw URLError ( . badURL)
124146 }
125147
@@ -175,10 +197,80 @@ extension HTTPRequestBuilder {
175197 }
176198}
177199
178- private extension HTTPRequestBuilder {
200+ extension HTTPRequestBuilder {
179201 static func urlEncode( _ text: String ) -> String {
180202 let specialCharacters = " :#[]@!$&'()*+,;= "
181203 let allowed = CharacterSet . urlQueryAllowed. subtracting ( . init( charactersIn: specialCharacters) )
182204 return text. addingPercentEncoding ( withAllowedCharacters: allowed) ?? text
183205 }
206+
207+ /// Join a list of strings using a separator only if neighbour items aren't already separated with the given separator.
208+ static func join( _ aList: String ... , separator: String = " / " ) -> String {
209+ guard !aList. isEmpty else { return " " }
210+
211+ var list = aList
212+ let start = list. removeFirst ( )
213+ return list. reduce ( into: start) { result, path in
214+ guard !path. isEmpty else { return }
215+
216+ guard !result. isEmpty else {
217+ result = path
218+ return
219+ }
220+
221+ switch ( result. hasSuffix ( separator) , path. hasPrefix ( separator) ) {
222+ case ( true , true ) :
223+ var prefixRemoved = path
224+ prefixRemoved. removePrefix ( separator)
225+ result. append ( prefixRemoved)
226+ case ( true , false ) , ( false , true ) :
227+ result. append ( path)
228+ case ( false , false ) :
229+ result. append ( " \( separator) \( path) " )
230+ }
231+ }
232+ }
233+ }
234+
235+ private extension Dictionary where Key == String , Value == Any {
236+
237+ static func urlEncode( into result: inout [ URLQueryItem ] , name: String , value: Any ) {
238+ switch value {
239+ case let array as [ Any ] :
240+ for value in array {
241+ urlEncode ( into: & result, name: " \( name) [] " , value: value)
242+ }
243+ case let object as [ String : Any ] :
244+ for (key, value) in object {
245+ urlEncode ( into: & result, name: " \( name) [ \( key) ] " , value: value)
246+ }
247+ case let value as Bool :
248+ urlEncode ( into: & result, name: name, value: value ? " 1 " : " 0 " )
249+ default :
250+ result. append ( URLQueryItem ( name: name, value: " \( value) " ) )
251+ }
252+ }
253+
254+ func flatten( ) -> [ URLQueryItem ] {
255+ reduce ( into: [ ] ) { result, entry in
256+ Self . urlEncode ( into: & result, name: entry. key, value: entry. value)
257+ }
258+ }
259+
260+ }
261+
262+ extension Array where Element == URLQueryItem {
263+
264+ var percentEncoded : String {
265+ map {
266+ let name = HTTPRequestBuilder . urlEncode ( $0. name)
267+ guard let value = $0. value else {
268+ return name
269+ }
270+
271+ return " \( name) = \( HTTPRequestBuilder . urlEncode ( value) ) "
272+ }
273+ . joined ( separator: " & " )
274+ }
275+
184276}
0 commit comments