Add support for query params on GET and DELETE requests

This commit is contained in:
Sami Samhuri 2025-06-15 08:26:22 -07:00
parent 863d712e42
commit 77446ccf2d
No known key found for this signature in database
5 changed files with 122 additions and 22 deletions

View file

@ -1,8 +1,9 @@
# Changelog # Changelog
## [2.0.0] - 2025-06-15 ## [2.0.0] - Unreleased
### Added ### 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 - **Enhanced error types** with localized descriptions and failure reasons
- **Header convenience method** `addHeader(name:value:)` on `HTTPRequest` - **Header convenience method** `addHeader(name:value:)` on `HTTPRequest`
- **Comprehensive test coverage** - **Comprehensive test coverage**

View file

@ -76,6 +76,14 @@ Basic usage:
```swift ```swift
let url = URL(string: "https://example.net")! 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) let request = HTTPRequest(method: .get, url: url)
``` ```
@ -84,7 +92,9 @@ More advanced usage with parameters and headers:
```swift ```swift
let url = URL(string: "https://example.net")! let url = URL(string: "https://example.net")!
let params = ["email": "freddie@example.net", "password": "BohemianRhapsody"] 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.addHeader(name: "x-custom", value: "42")
request.addMultipartJPEG(name: "avatar", image: UIImage(), quality: 1, filename: "avatar.jpg") request.addMultipartJPEG(name: "avatar", image: UIImage(), quality: 1, filename: "avatar.jpg")
``` ```

View file

@ -21,8 +21,11 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPRequest")
/// ## Usage /// ## Usage
/// ///
/// ```swift /// ```swift
/// // Simple GET request /// // GET request with query parameters
/// let request = HTTPRequest.get(URL(string: "https://api.example.net/users")!) /// let getRequest = HTTPRequest.get(
/// URL(string: "https://api.example.net/users")!,
/// parameters: ["page": "1", "limit": "10"]
/// )
/// ///
/// // POST with JSON parameters /// // POST with JSON parameters
/// let jsonRequest = HTTPRequest.post( /// 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"] /// 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 /// // Multipart form with file upload
/// var multipartRequest = HTTPRequest.post(URL(string: "https://api.example.net/upload")!) /// var multipartRequest = HTTPRequest.post(URL(string: "https://api.example.net/upload")!)
/// multipartRequest.parts = [ /// multipartRequest.parts = [
@ -78,10 +87,10 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
/// Creates a GET request. /// Creates a GET request.
/// - Parameters: /// - Parameters:
/// - url: The target URL /// - url: The target URL
/// - contentType: The content type (typically .none for GET) /// - parameters: Optional parameters to include as query string
/// - Returns: A configured HTTPRequest /// - Returns: A configured HTTPRequest
public static func get(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { public static func get(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
HTTPRequest(method: .get, url: url, contentType: contentType) HTTPRequest(method: .get, url: url, contentType: .none, parameters: parameters)
} }
/// Creates a PUT request. /// Creates a PUT request.
@ -107,10 +116,10 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
/// Creates a DELETE request. /// Creates a DELETE request.
/// - Parameters: /// - Parameters:
/// - url: The target URL /// - url: The target URL
/// - contentType: The content type (typically .none for DELETE) /// - parameters: Optional parameters to include as query string
/// - Returns: A configured HTTPRequest /// - Returns: A configured HTTPRequest
public static func delete(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { public static func delete(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
HTTPRequest(method: .delete, url: url, contentType: contentType) HTTPRequest(method: .delete, url: url, contentType: .none, parameters: parameters)
} }
#if canImport(UIKit) #if canImport(UIKit)

View file

@ -42,24 +42,22 @@ public final class RequestBuilder {
/// Converts an HTTPRequest to a URLRequest ready for use with URLSession. /// Converts an HTTPRequest to a URLRequest ready for use with URLSession.
/// ///
/// This method handles encoding of parameters according to the request's content type: /// This method handles encoding of parameters according to the request's method and content type:
/// - `.json`: Parameters are encoded as JSON in the request body /// - **GET/DELETE**: Parameters are encoded as query string parameters
/// - `.formEncoded`: Parameters are URL-encoded in the request body /// - `.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) /// - `.multipart`: Parts are encoded as multipart/form-data (in memory)
/// - `.none`: Falls back to form encoding for compatibility /// - `.none`: Falls back to form encoding for compatibility
/// ///
/// - Parameter request: The HTTPRequest to convert /// - Parameter request: The HTTPRequest to convert
/// - Returns: A URLRequest ready for URLSession /// - Returns: A URLRequest ready for URLSession
/// - Throws: `RequestBuilderError.invalidFormData` if form encoding fails, /// - Throws: `RequestBuilderError.invalidFormData` if form encoding fails, if GET/DELETE
/// or various encoding errors from JSONSerialization or MultipartFormEncoder /// 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, /// - Warning: Multipart requests are encoded entirely in memory. For large files,
/// consider using MultipartFormEncoder.encodeFile() directly /// consider using MultipartFormEncoder.encodeFile() directly
public class func build(request: HTTPRequest) throws -> URLRequest { 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) var result = URLRequest(url: request.url)
result.httpMethod = request.method.string result.httpMethod = request.method.string
@ -67,8 +65,14 @@ public final class RequestBuilder {
result.addValue(value, forHTTPHeaderField: name) result.addValue(value, forHTTPHeaderField: name)
} }
// When parts are provided then override to be multipart regardless of the content type. // Handle parameters based on HTTP method
if !request.parts.isEmpty || request.contentType == .multipart { 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 { if request.contentType != .multipart {
log.info("Encoding request as multipart, overriding its content type of \(request.contentType)") 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 { } else if let params = request.parameters {
try encodeParameters(to: &result, request: request, parameters: params) try encodeParameters(to: &result, request: request, parameters: params)
} }
return result return result
} }
@ -116,4 +120,23 @@ public final class RequestBuilder {
} }
urlRequest.httpBody = formData 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
}
} }

View file

@ -236,4 +236,61 @@ class RequestBuilderTests: XCTestCase {
XCTAssertTrue(bodyString.contains("band=Queen")) 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)
}
}
} }