Mercury is a lightweight, testable HTTP client that makes Swift networking ergonomic, predictable, and concurrency-safe by default. With built-in support for clean URL construction, customizable headers, and structured error handling, it lets you focus on your app logic - not request plumbing.
- ✅ Swift Concurrency: Built from the ground up with async/await
- ✅ Protocol-Based: Easy dependency injection and testing with
MercuryProtocol
- ✅ Flexible Payloads: Support for raw
Data
,Encodable
objects, and empty bodies - ✅ Customizable Headers: Per-request and default header support
- ✅ Cache Control: Fine-grained caching policy control
- ✅ Query Parameters: Built-in query string and URL fragment support
- ✅ Mock Support: Comprehensive mocking for unit tests
- ✅ Error Handling: Detailed error types for robust error handling
- ✅ Deterministic Signatures: Every request returns a stable, content-aware
requestSignature
Add Mercury to your project using Swift Package Manager:
dependencies: [
.package(url: "https://github.com/joshgallantt/Mercury.git", from: "1.0.0")
]
Basic usage:
import Mercury
let client = Mercury(host: "https://api.example.com")
let result = await client.get("/users")
switch result {
case .success(let success):
let data = success.data
let statusCode = success.response.statusCode
let signature = success.requestSignature
print("✅ Success [\(statusCode)] with signature: \(signature)")
case .failure(let failure):
let error = failure.error
let signature = failure.requestSignature
print("❌ Failure [\(error)] for request: \(signature)")
}
Mercury intelligently parses various host formats:
Mercury(host: "https://api.example.com") // Standard URL
Mercury(host: "api.example.com") // Defaults to HTTPS
Mercury(host: "http://localhost:3000") // Custom protocol and port
Mercury(host: "https://api.example.com/api/v1") // With base path
Mercury(host: "https://api.example.com:8443/v2") // Custom port + base path
Mercury supports all standard HTTP methods:
await client.get("/users")
await client.post("/users", body: newUser)
await client.put("/users/123", body: updatedUser)
await client.patch("/users/123", body: partialUpdate)
await client.delete("/users/123")
Build complex URLs with query parameters and fragments:
await client.get(
"/users",
queryItems: [
"page": "2",
"limit": "20",
"sort": "name"
],
fragment: "results"
)
// Results in: /users?page=2&limit=20&sort=name#results
Perfect for when you have pre-encoded data or need full control:
let jsonData = """
{
"name": "John Doe",
"email": "[email protected]"
}
""".data(using: .utf8)!
let result = await client.post("/users", data: jsonData)
Automatically encode Swift types to JSON:
struct User: Encodable {
let name: String
let email: String
}
let newUser = User(name: "John Doe", email: "[email protected]")
let result = await client.post("/users", body: newUser)
Use custom encoders for specialized formatting:
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .convertToSnakeCase
let result = await client.post("/users", body: newUser, encoder: encoder)
Set headers that apply to all requests:
let client = Mercury(
host: "https://api.example.com",
defaultHeaders: [
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer \(token)"
]
)
Override or add headers for specific requests:
await client.get(
"/users",
headers: [
"X-Request-ID": UUID().uuidString,
"Accept": "application/vnd.api+json" // Overrides default
]
)
Set a default policy for all requests:
let client = Mercury(
host: "https://api.example.com",
defaultCachePolicy: .reloadIgnoringLocalCacheData
)
Override caching for specific requests:
// Use cache if available, otherwise load from network
await client.get("/users", cachePolicy: .returnCacheDataElseLoad)
// Always reload from network
await client.get("/users", cachePolicy: .reloadIgnoringLocalCacheData)
// Only use cached data
await client.get("/users", cachePolicy: .returnCacheDataDontLoad)
Every network request returns a Result<MercurySuccess, MercuryFailure>
.
struct MercurySuccess {
let data: Data
let response: HTTPURLResponse
let requestSignature: String
}
data
: The raw response bodyresponse
: The HTTP response metadatarequestSignature
: A deterministic hash representing the full request
struct MercuryFailure: Error {
let error: MercuryError
let requestSignature: String
}
error
: One of the structuredMercuryError
casesrequestSignature
: Same unique hash - available even on failure
requestSignature
is a unique, content-aware hash that Mercury generates for every request. It's based on the complete request structure (method, URL, headers, body) and is guaranteed to be deterministic - the same request will always produce the same signature.
Use cases:
- Cache invalidation: Target specific cached requests
- Request deduplication: Detect duplicate or replayed requests
- Logging & debugging: Track requests across your app
- Analytics: Group related network calls
enum MercuryError: Error {
case invalidURL
case server(statusCode: Int, data: Data?)
case invalidResponse
case transport(Error)
case encoding(Error)
}
Case | Description |
---|---|
invalidURL |
Failed to build a valid URL |
server(_, data?) |
Non-2xx status code, optional response body available |
invalidResponse |
Response wasn't an HTTPURLResponse |
transport(Error) |
Network issue (no internet, timeout, SSL error) |
encoding(Error) |
Failed to encode Encodable body as JSON |
let result = await client.post("/users", body: newUser)
switch result {
case .success(let success):
print("✅ Created user")
print("Signature: \(success.requestSignature)")
case .failure(let failure):
print("❌ Request failed with signature: \(failure.requestSignature)")
switch failure.error {
case .invalidURL:
print("The URL was malformed")
case .server(let code, let data):
print("Server responded with status: \(code)")
if let data = data {
print(String(decoding: data, as: UTF8.self))
}
case .invalidResponse:
print("No HTTPURLResponse received")
case .transport(let err):
print("Network error: \(err.localizedDescription)")
case .encoding(let err):
print("Encoding error: \(err.localizedDescription)")
}
}
Mercury includes MockMercury
for comprehensive testing support:
import XCTest
@testable import Mercury
final class UserRepositoryTests: XCTestCase {
func test_givenValidUser_whenCreateUser_thenReturnsSuccess() async throws {
// Given
let mock = MockMercury()
let expectedResponse = HTTPURLResponse(
url: URL(string: "https://api.example.com")!,
statusCode: 201,
httpVersion: nil,
headerFields: nil
)!
await mock.setPostResult(
.success(
MercurySuccess(
data: Data(),
response: expectedResponse,
requestSignature: "test-signature"
)
)
)
let repository = UserRepository(client: mock)
// When
let result = await repository.createUser(name: "John", email: "[email protected]")
// Then
XCTAssertTrue(result.isSuccess)
let calls = await mock.recordedCalls
XCTAssertEqual(calls.count, 1)
if case .postEncodable(let path, _, _, _) = calls.first {
XCTAssertEqual(path, "/users")
} else {
XCTFail("Expected postEncodable call")
}
}
}
- ✅ Stub Results: Set custom responses for each HTTP method
- ✅ Call Recording: Inspect all calls made to the mock
- ✅ Method-Specific: Different stubs for GET, POST, PUT, PATCH, DELETE
- ✅ Parameter Capture: Verify paths, headers, query items, and fragments
Mercury is available under the MIT License. See the LICENSE file for more details.
By Josh Gallant, Made with ❤️ for the Swift community