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
## [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**

View file

@ -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")
```

View file

@ -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)

View file

@ -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
}
}

View file

@ -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)
}
}
}