From 77446ccf2deb1aac78850a83ede186f017cfe122 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 15 Jun 2025 08:26:22 -0700 Subject: [PATCH] Add support for query params on GET and DELETE requests --- CHANGELOG.md | 3 +- Readme.md | 12 ++++- Sources/Osiris/HTTPRequest.swift | 25 ++++++--- Sources/Osiris/RequestBuilder.swift | 47 ++++++++++++----- Tests/OsirisTests/RequestBuilderTests.swift | 57 +++++++++++++++++++++ 5 files changed, 122 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b49ca..6ff930d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog -## [2.0.0] - 2025-06-15 +## [2.0.0] - Unreleased ### Added +- **GET/DELETE query parameter support** - Parameters are now automatically encoded as query strings for GET and DELETE requests - **Enhanced error types** with localized descriptions and failure reasons - **Header convenience method** `addHeader(name:value:)` on `HTTPRequest` - **Comprehensive test coverage** diff --git a/Readme.md b/Readme.md index 0caa2b0..6968d0a 100644 --- a/Readme.md +++ b/Readme.md @@ -76,6 +76,14 @@ Basic usage: ```swift let url = URL(string: "https://example.net")! + +// GET request with query parameters +let getRequest = HTTPRequest.get(url, parameters: ["page": "1", "limit": "10"]) + +// DELETE request with query parameters +let deleteRequest = HTTPRequest.delete(url, parameters: ["confirm": "true"]) + +// Or use the general initializer let request = HTTPRequest(method: .get, url: url) ``` @@ -84,7 +92,9 @@ More advanced usage with parameters and headers: ```swift let url = URL(string: "https://example.net")! let params = ["email": "freddie@example.net", "password": "BohemianRhapsody"] -let request = HTTPRequest(method: .post, url: url, contentType: .json, parameters: params) + +// POST with JSON parameters (goes in request body) +let request = HTTPRequest.post(url, contentType: .json, parameters: params) request.addHeader(name: "x-custom", value: "42") request.addMultipartJPEG(name: "avatar", image: UIImage(), quality: 1, filename: "avatar.jpg") ``` diff --git a/Sources/Osiris/HTTPRequest.swift b/Sources/Osiris/HTTPRequest.swift index 020f427..03d5e23 100644 --- a/Sources/Osiris/HTTPRequest.swift +++ b/Sources/Osiris/HTTPRequest.swift @@ -21,8 +21,11 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPRequest") /// ## Usage /// /// ```swift -/// // Simple GET request -/// let request = HTTPRequest.get(URL(string: "https://api.example.net/users")!) +/// // GET request with query parameters +/// let getRequest = HTTPRequest.get( +/// URL(string: "https://api.example.net/users")!, +/// parameters: ["page": "1", "limit": "10"] +/// ) /// /// // POST with JSON parameters /// let jsonRequest = HTTPRequest.post( @@ -31,6 +34,12 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPRequest") /// parameters: ["name": "Jane", "email": "jane@example.net"] /// ) /// +/// // DELETE with query parameters +/// let deleteRequest = HTTPRequest.delete( +/// URL(string: "https://api.example.net/users/123")!, +/// parameters: ["confirm": "true"] +/// ) +/// /// // Multipart form with file upload /// var multipartRequest = HTTPRequest.post(URL(string: "https://api.example.net/upload")!) /// multipartRequest.parts = [ @@ -78,10 +87,10 @@ public struct HTTPRequest: Sendable, CustomStringConvertible { /// Creates a GET request. /// - Parameters: /// - url: The target URL - /// - contentType: The content type (typically .none for GET) + /// - parameters: Optional parameters to include as query string /// - Returns: A configured HTTPRequest - public static func get(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { - HTTPRequest(method: .get, url: url, contentType: contentType) + public static func get(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest { + HTTPRequest(method: .get, url: url, contentType: .none, parameters: parameters) } /// Creates a PUT request. @@ -107,10 +116,10 @@ public struct HTTPRequest: Sendable, CustomStringConvertible { /// Creates a DELETE request. /// - Parameters: /// - url: The target URL - /// - contentType: The content type (typically .none for DELETE) + /// - parameters: Optional parameters to include as query string /// - Returns: A configured HTTPRequest - public static func delete(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { - HTTPRequest(method: .delete, url: url, contentType: contentType) + public static func delete(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest { + HTTPRequest(method: .delete, url: url, contentType: .none, parameters: parameters) } #if canImport(UIKit) diff --git a/Sources/Osiris/RequestBuilder.swift b/Sources/Osiris/RequestBuilder.swift index ce641b9..5f4f809 100644 --- a/Sources/Osiris/RequestBuilder.swift +++ b/Sources/Osiris/RequestBuilder.swift @@ -42,24 +42,22 @@ public final class RequestBuilder { /// Converts an HTTPRequest to a URLRequest ready for use with URLSession. /// - /// This method handles encoding of parameters according to the request's content type: - /// - `.json`: Parameters are encoded as JSON in the request body - /// - `.formEncoded`: Parameters are URL-encoded in the request body + /// This method handles encoding of parameters according to the request's method and content type: + /// - **GET/DELETE**: Parameters are encoded as query string parameters + /// - `.json`: Parameters are encoded as JSON in the request body (POST/PUT/PATCH) + /// - `.formEncoded`: Parameters are URL-encoded in the request body (POST/PUT/PATCH) /// - `.multipart`: Parts are encoded as multipart/form-data (in memory) /// - `.none`: Falls back to form encoding for compatibility /// /// - Parameter request: The HTTPRequest to convert /// - Returns: A URLRequest ready for URLSession - /// - Throws: `RequestBuilderError.invalidFormData` if form encoding fails, - /// or various encoding errors from JSONSerialization or MultipartFormEncoder + /// - Throws: `RequestBuilderError.invalidFormData` if form encoding fails, if GET/DELETE + /// requests contain multipart parts, or various encoding errors from JSONSerialization + /// or MultipartFormEncoder /// - /// - Note: GET and DELETE requests with parameters are not currently supported /// - Warning: Multipart requests are encoded entirely in memory. For large files, /// consider using MultipartFormEncoder.encodeFile() directly public class func build(request: HTTPRequest) throws -> URLRequest { - assert(!(request.method == .get && request.parameters != nil), "encoding GET params is not yet implemented") - assert(!(request.method == .delete && request.parameters != nil), "encoding DELETE params is not yet implemented") - var result = URLRequest(url: request.url) result.httpMethod = request.method.string @@ -67,8 +65,14 @@ public final class RequestBuilder { result.addValue(value, forHTTPHeaderField: name) } - // When parts are provided then override to be multipart regardless of the content type. - if !request.parts.isEmpty || request.contentType == .multipart { + // Handle parameters based on HTTP method + if request.method == .get || request.method == .delete, let params = request.parameters { + // Validate that GET and DELETE requests don't want request bodies, which we don't support. + guard request.contentType != .multipart, request.parts.isEmpty else { + throw RequestBuilderError.invalidFormData(request) + } + try encodeQueryParameters(to: &result, parameters: params) + } else if !request.parts.isEmpty || request.contentType == .multipart { if request.contentType != .multipart { log.info("Encoding request as multipart, overriding its content type of \(request.contentType)") } @@ -76,7 +80,7 @@ public final class RequestBuilder { } else if let params = request.parameters { try encodeParameters(to: &result, request: request, parameters: params) } - + return result } @@ -116,4 +120,23 @@ public final class RequestBuilder { } urlRequest.httpBody = formData } + + private class func encodeQueryParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws { + guard let url = urlRequest.url else { + return + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let newQueryItems = parameters.compactMap { (key, value) -> URLQueryItem? in + URLQueryItem(name: key, value: String(describing: value)) + } + + if let existingQueryItems = components?.queryItems { + components?.queryItems = existingQueryItems + newQueryItems + } else { + components?.queryItems = newQueryItems + } + + urlRequest.url = components?.url ?? url + } } diff --git a/Tests/OsirisTests/RequestBuilderTests.swift b/Tests/OsirisTests/RequestBuilderTests.swift index 9bddae2..aa3175f 100644 --- a/Tests/OsirisTests/RequestBuilderTests.swift +++ b/Tests/OsirisTests/RequestBuilderTests.swift @@ -236,4 +236,61 @@ class RequestBuilderTests: XCTestCase { XCTAssertTrue(bodyString.contains("band=Queen")) } + func testBuildGETRequestWithQueryParameters() throws { + let httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "John Doe", "email": "john@example.net"]) + let urlRequest = try RequestBuilder.build(request: httpRequest) + + XCTAssertEqual(urlRequest.httpMethod, "GET") + XCTAssertNil(urlRequest.httpBody) + XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type")) + + let urlString = urlRequest.url?.absoluteString ?? "" + XCTAssertTrue(urlString.contains("name=John%20Doe"), "URL should contain encoded name parameter") + XCTAssertTrue(urlString.contains("email=john@example.net"), "URL should contain email parameter") + XCTAssertTrue(urlString.contains("?"), "URL should contain query separator") + } + + func testBuildDELETERequestWithQueryParameters() throws { + let httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123", "confirm": "true"]) + let urlRequest = try RequestBuilder.build(request: httpRequest) + + XCTAssertEqual(urlRequest.httpMethod, "DELETE") + XCTAssertNil(urlRequest.httpBody) + XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type")) + + let urlString = urlRequest.url?.absoluteString ?? "" + XCTAssertTrue(urlString.contains("id=123")) + XCTAssertTrue(urlString.contains("confirm=true")) + XCTAssertTrue(urlString.contains("?")) + } + + func testBuildGETRequestWithExistingQueryString() throws { + let urlWithQuery = URL(string: "https://api.example.net/users?existing=param")! + let httpRequest = HTTPRequest.get(urlWithQuery, parameters: ["new": "value"]) + let urlRequest = try RequestBuilder.build(request: httpRequest) + + let urlString = urlRequest.url?.absoluteString ?? "" + XCTAssertTrue(urlString.contains("existing=param")) + XCTAssertTrue(urlString.contains("new=value")) + XCTAssertTrue(urlString.contains("&")) + } + + func testBuildGETRequestWithMultipartThrowsError() throws { + var httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "value"]) + httpRequest.contentType = HTTPContentType.multipart + + XCTAssertThrowsError(try RequestBuilder.build(request: httpRequest)) { error in + XCTAssertTrue(error is RequestBuilderError) + } + } + + func testBuildDELETERequestWithPartsThrowsError() throws { + var httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123"]) + httpRequest.parts = [MultipartFormEncoder.Part.text("value", name: "test")] + + XCTAssertThrowsError(try RequestBuilder.build(request: httpRequest)) { error in + XCTAssertTrue(error is RequestBuilderError) + } + } + }