├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── URLRequestBuilder │ └── URLRequestBuilder.swift └── Tests └── URLRequestBuilderTests └── URLRequestBuilderTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Parable Health 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "URLRequestBuilder", 8 | products: [ 9 | // Products define the executables and libraries a package produces, and make them visible to other packages. 10 | .library( 11 | name: "URLRequestBuilder", 12 | targets: ["URLRequestBuilder"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 21 | .target( 22 | name: "URLRequestBuilder", 23 | dependencies: []), 24 | .testTarget( 25 | name: "URLRequestBuilderTests", 26 | dependencies: ["URLRequestBuilder"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URLRequestBuilder 2 | 3 | Deal with query items, HTTP headers, request body and more in an easy, declarative way 4 | 5 | ## Showcase 6 | 7 | ```swift 8 | let urlRequest = try URLRequestBuilder(path: "users/submit") 9 | .method(.post) 10 | .jsonBody(user) 11 | .contentType(.applicationJSON) 12 | .accept(.applicationJSON) 13 | .timeout(20) 14 | .queryItem(name: "city", value: "San Francisco") 15 | .header(name: "Auth-Token", value: authToken) 16 | .makeRequest(withBaseURL: testURL) 17 | ``` 18 | -------------------------------------------------------------------------------- /Sources/URLRequestBuilder/URLRequestBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias EndpointRequest = URLRequestBuilder 4 | 5 | public struct URLRequestBuilder { 6 | public private(set) var buildURLRequest: (inout URLRequest) -> Void 7 | public private(set) var urlComponents: URLComponents 8 | 9 | private init(urlComponents: URLComponents) { 10 | self.buildURLRequest = { _ in } 11 | self.urlComponents = urlComponents 12 | } 13 | 14 | // MARK: - Starting point 15 | 16 | public init(path: String) { 17 | var components = URLComponents() 18 | components.path = path 19 | self.init(urlComponents: components) 20 | } 21 | 22 | public static func customURL(_ url: URL) -> URLRequestBuilder { 23 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 24 | print("can't make URLComponents from URL") 25 | return URLRequestBuilder(urlComponents: .init()) 26 | } 27 | return URLRequestBuilder( 28 | urlComponents: components 29 | ) 30 | } 31 | 32 | // MARK: - Factories 33 | 34 | public static func get(path: String) -> URLRequestBuilder { 35 | .init(path: path) 36 | .method(.get) 37 | } 38 | 39 | public static func post(path: String) -> URLRequestBuilder { 40 | .init(path: path) 41 | .method(.post) 42 | } 43 | 44 | // MARK: - JSON Factories 45 | 46 | public static func jsonGet(path: String) -> URLRequestBuilder { 47 | .get(path: path) 48 | .contentType(.applicationJSON) 49 | } 50 | 51 | public static func jsonPost(path: String, jsonData: Data) -> URLRequestBuilder { 52 | .post(path: path) 53 | .contentType(.applicationJSON) 54 | .body(jsonData) 55 | } 56 | 57 | public static func jsonPost(path: String, jsonObject: Content, encoder: JSONEncoder = URLRequestBuilder.jsonEncoder) throws -> URLRequestBuilder { 58 | try .post(path: path) 59 | .contentType(.applicationJSON) 60 | .jsonBody(jsonObject, encoder: encoder) 61 | } 62 | 63 | // MARK: - Building Blocks 64 | 65 | public func modifyRequest(_ modifyRequest: @escaping (inout URLRequest) -> Void) -> URLRequestBuilder { 66 | var copy = self 67 | let existing = buildURLRequest 68 | copy.buildURLRequest = { request in 69 | existing(&request) 70 | modifyRequest(&request) 71 | } 72 | return copy 73 | } 74 | 75 | public func modifyURL(_ modifyURL: @escaping (inout URLComponents) -> Void) -> URLRequestBuilder { 76 | var copy = self 77 | modifyURL(©.urlComponents) 78 | return copy 79 | } 80 | 81 | public enum Method: String { 82 | case get = "GET" 83 | case post = "POST" 84 | case put = "PUT" 85 | case head = "HEAD" 86 | case delete = "DELETE" 87 | case patch = "PATCH" 88 | case options = "OPTIONS" 89 | case connect = "CONNECT" 90 | case trace = "TRACE" 91 | } 92 | 93 | public func method(_ method: Method) -> URLRequestBuilder { 94 | modifyRequest { $0.httpMethod = method.rawValue } 95 | } 96 | 97 | public func body(_ body: Data, setContentLength: Bool = false) -> URLRequestBuilder { 98 | let updated = modifyRequest { $0.httpBody = body } 99 | if setContentLength { 100 | return updated.contentLength(body.count) 101 | } else { 102 | return updated 103 | } 104 | } 105 | 106 | public static let jsonEncoder = JSONEncoder() 107 | 108 | public func jsonBody(_ body: Content, encoder: JSONEncoder = URLRequestBuilder.jsonEncoder, setContentLength: Bool = false) throws -> URLRequestBuilder { 109 | let body = try encoder.encode(body) 110 | return self.body(body) 111 | } 112 | 113 | // MARK: Query 114 | 115 | public func queryItems(_ queryItems: [URLQueryItem]) -> URLRequestBuilder { 116 | modifyURL { urlComponents in 117 | var items = urlComponents.queryItems ?? [] 118 | items.append(contentsOf: queryItems) 119 | urlComponents.queryItems = items 120 | } 121 | } 122 | 123 | public func queryItems(_ queryItems: KeyValuePairs) -> URLRequestBuilder { 124 | self.queryItems(queryItems.map { .init(name: $0.key, value: $0.value) }) 125 | } 126 | 127 | public func queryItem(name: String, value: String) -> URLRequestBuilder { 128 | queryItems([name: value]) 129 | } 130 | 131 | // MARK: Content Type 132 | 133 | public struct ContentType { 134 | public static let header = HeaderName(rawValue: "Content-Type") 135 | 136 | public var rawValue: String 137 | 138 | // MARK: - Application 139 | public static let applicationJSON = ContentType(rawValue: "application/json") 140 | public static let applicationOctetStream = ContentType(rawValue: "application/octet-stream") 141 | public static let applicationXML = ContentType(rawValue: "application/xml") 142 | public static let applicationZip = ContentType(rawValue: "application/zip") 143 | public static let applicationXWwwFormUrlEncoded = ContentType(rawValue: "application/x-www-form-urlencoded") 144 | 145 | // MARK: - Image 146 | public static let imageGIF = ContentType(rawValue: "image/gif") 147 | public static let imageJPEG = ContentType(rawValue: "image/jpeg") 148 | public static let imagePNG = ContentType(rawValue: "image/png") 149 | public static let imageTIFF = ContentType(rawValue: "image/tiff") 150 | 151 | // MARK: - Text 152 | public static let textCSS = ContentType(rawValue: "text/css") 153 | public static let textCSV = ContentType(rawValue: "text/csv") 154 | public static let textHTML = ContentType(rawValue: "text/html") 155 | public static let textPlain = ContentType(rawValue: "text/plain") 156 | public static let textXML = ContentType(rawValue: "text/xml") 157 | 158 | // MARK: - Video 159 | public static let videoMPEG = ContentType(rawValue: "video/mpeg") 160 | public static let videoMP4 = ContentType(rawValue: "video/mp4") 161 | public static let videoQuicktime = ContentType(rawValue: "video/quicktime") 162 | public static let videoXMsWmv = ContentType(rawValue: "video/x-ms-wmv") 163 | public static let videoXMsVideo = ContentType(rawValue: "video/x-msvideo") 164 | public static let videoXFlv = ContentType(rawValue: "video/x-flv") 165 | public static let videoWebm = ContentType(rawValue: "video/webm") 166 | 167 | // MARK: - Multipart Form Data 168 | public static func multipartFormData(boundary: String) -> ContentType { 169 | ContentType(rawValue: "multipart/form-data; boundary=\(boundary)") 170 | } 171 | } 172 | 173 | public func contentType(_ contentType: ContentType) -> URLRequestBuilder { 174 | header(name: ContentType.header, value: contentType.rawValue) 175 | } 176 | 177 | public func accept(_ contentType: ContentType) -> URLRequestBuilder { 178 | header(name: .accept, value: contentType.rawValue) 179 | } 180 | 181 | // MARK: Encoding 182 | 183 | public enum Encoding: String { 184 | case gzip 185 | case compress 186 | case deflate 187 | case br 188 | 189 | public static let contentEncodingHeader = HeaderName(rawValue: "Content-Encoding") 190 | public static let acceptEncodingHeader = HeaderName(rawValue: "Accept-Encoding") 191 | } 192 | 193 | public func contentEncoding(_ encoding: Encoding) -> URLRequestBuilder { 194 | header(name: Encoding.contentEncodingHeader, value: encoding.rawValue) 195 | } 196 | 197 | public func acceptEncoding(_ encoding: Encoding) -> URLRequestBuilder { 198 | header(name: Encoding.acceptEncodingHeader, value: encoding.rawValue) 199 | } 200 | 201 | // MARK: Other 202 | 203 | public func contentLength(_ length: Int) -> URLRequestBuilder { 204 | header(name: .contentLength, value: String(length)) 205 | } 206 | 207 | public func header(name: HeaderName, value: String) -> URLRequestBuilder { 208 | modifyRequest { $0.addValue(value, forHTTPHeaderField: name.rawValue) } 209 | } 210 | 211 | public func header(name: HeaderName, values: [String]) -> URLRequestBuilder { 212 | var copy = self 213 | for value in values { 214 | copy = copy.header(name: name, value: value) 215 | } 216 | return copy 217 | } 218 | 219 | public func timeout(_ timeout: TimeInterval) -> URLRequestBuilder { 220 | modifyRequest { $0.timeoutInterval = timeout } 221 | } 222 | } 223 | 224 | // MARK: - Finalizing 225 | 226 | extension URLRequestBuilder { 227 | public func makeRequest(withBaseURL baseURL: URL) -> URLRequest { 228 | makeRequest(withConfig: .baseURL(baseURL)) 229 | } 230 | 231 | public func makeRequest(withConfig config: RequestConfiguration) -> URLRequest { 232 | config.configureRequest(self) 233 | } 234 | } 235 | 236 | extension URLRequest { 237 | public init(baseURL: URL, endpointRequest: URLRequestBuilder) { 238 | self = endpointRequest.makeRequest(withBaseURL: baseURL) 239 | } 240 | } 241 | 242 | extension URLRequestBuilder { 243 | public struct RequestConfiguration { 244 | public init(configureRequest: @escaping (URLRequestBuilder) -> URLRequest) { 245 | self.configureRequest = configureRequest 246 | } 247 | 248 | public let configureRequest: (URLRequestBuilder) -> URLRequest 249 | } 250 | } 251 | 252 | extension URLRequestBuilder.RequestConfiguration { 253 | public static func baseURL(_ baseURL: URL) -> URLRequestBuilder.RequestConfiguration { 254 | return URLRequestBuilder.RequestConfiguration { request in 255 | let finalURL = request.urlComponents.url(relativeTo: baseURL) ?? baseURL 256 | 257 | var urlRequest = URLRequest(url: finalURL) 258 | request.buildURLRequest(&urlRequest) 259 | 260 | return urlRequest 261 | } 262 | } 263 | 264 | public static func base(scheme: String?, host: String?, port: Int?) -> URLRequestBuilder.RequestConfiguration { 265 | URLRequestBuilder.RequestConfiguration { request in 266 | var request = request 267 | request.urlComponents.scheme = scheme 268 | request.urlComponents.host = host 269 | request.urlComponents.port = port 270 | 271 | if !request.urlComponents.path.starts(with: "/") { 272 | request.urlComponents.path = "/" + request.urlComponents.path 273 | } 274 | 275 | guard let finalURL = request.urlComponents.url else { 276 | preconditionFailure() 277 | } 278 | 279 | var urlRequest = URLRequest(url: finalURL) 280 | request.buildURLRequest(&urlRequest) 281 | 282 | return urlRequest 283 | } 284 | } 285 | } 286 | 287 | // MARK: - HeaderName 288 | 289 | extension URLRequestBuilder { 290 | public struct HeaderName { 291 | public var rawValue: String 292 | 293 | public static let userAgent: HeaderName = "User-Agent" 294 | public static let cookie: HeaderName = "Cookie" 295 | public static let authorization: HeaderName = "Authorization" 296 | public static let accept: HeaderName = "Accept" 297 | public static let contentLength: HeaderName = "Content-Length" 298 | 299 | public static let contentType = URLRequestBuilder.ContentType.header 300 | public static let contentEncoding = URLRequestBuilder.Encoding.contentEncodingHeader 301 | public static let acceptEncoding = URLRequestBuilder.Encoding.acceptEncodingHeader 302 | } 303 | } 304 | 305 | extension URLRequestBuilder.HeaderName: ExpressibleByStringLiteral { 306 | public init(stringLiteral value: StringLiteralType) { 307 | self.init(rawValue: value) 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /Tests/URLRequestBuilderTests/URLRequestBuilderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import URLRequestBuilder 3 | 4 | final class URLRequestBuilderTests: XCTestCase { 5 | let testURL = URL(string: "https://example.com")! 6 | let localhost = URL(string: "http://localhost:3000/")! 7 | 8 | func testMultipleHeaders() throws { 9 | let request = URLRequestBuilder(path: "multiple-headers") 10 | .header(name: "Test-Multiple", values: ["A", "B", "C"]) 11 | .makeRequest(withBaseURL: testURL) 12 | XCTAssertEqual(request.value(forHTTPHeaderField: "Test-Multiple"), "A,B,C") 13 | XCTAssertNotEqual(request.value(forHTTPHeaderField: "Test-Multiple"), "A,B") 14 | } 15 | 16 | func testReadme() throws { 17 | let user = 1 18 | let authToken = "aaa" 19 | 20 | let urlRequest = try URLRequestBuilder(path: "users/submit") 21 | .method(.post) 22 | .jsonBody(user) 23 | .contentType(.applicationJSON) 24 | .accept(.applicationJSON) 25 | .timeout(20) 26 | .queryItem(name: "city", value: "San Francisco") 27 | .header(name: "Auth-Token", value: authToken) 28 | .makeRequest(withBaseURL: testURL) 29 | 30 | print(urlRequest) 31 | 32 | XCTAssertEqual(urlRequest.url!.absoluteString, "https://example.com/users/submit?city=San%20Francisco") 33 | } 34 | 35 | func testLocalhost() throws { 36 | let user = 1 37 | let authToken = "aaa" 38 | 39 | let urlRequest = try URLRequestBuilder(path: "users/submit") 40 | .method(.post) 41 | .jsonBody(user) 42 | .contentType(.applicationJSON) 43 | .accept(.applicationJSON) 44 | .timeout(20) 45 | .queryItem(name: "city", value: "San Francisco") 46 | .header(name: "Auth-Token", value: authToken) 47 | .makeRequest(withConfig: .base(scheme: "http", host: "localhost", port: 3000)) 48 | 49 | print(urlRequest) 50 | 51 | // XCTAssertEqual(urlRequest.url!.absoluteString, "https://example.com/users/submit?city=San%20Francisco") 52 | } 53 | } 54 | --------------------------------------------------------------------------------