Skip to content

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.

License

Notifications You must be signed in to change notification settings

joshgallantt/Mercury

Repository files navigation

Mercury

“Let Mercury go swiftly, bearing words not his, but heaven’s.”

— Virgil, Aeneid 4.242–243

Platforms

Swift SPM ready Coverage License: MIT

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.



Features

  • 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



Installation

Add Mercury to your project using Swift Package Manager:

dependencies: [
    .package(url: "https://github.com/joshgallantt/Mercury.git", from: "1.0.0")
]



Quick Start

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



Payloads

Raw Data Payloads

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)

Encodable Objects

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)

Custom Encoding

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)



Headers

Default Headers

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)"
    ]
)

Per-Request Headers

Override or add headers for specific requests:

await client.get(
    "/users",
    headers: [
        "X-Request-ID": UUID().uuidString,
        "Accept": "application/vnd.api+json"  // Overrides default
    ]
)



Cache Policies

Default Cache Policy

Set a default policy for all requests:

let client = Mercury(
    host: "https://api.example.com",
    defaultCachePolicy: .reloadIgnoringLocalCacheData
)

Per-Request Cache Policy Overrides

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)



Responses

Every network request returns a Result<MercurySuccess, MercuryFailure>.

MercurySuccess

struct MercurySuccess {
    let data: Data
    let response: HTTPURLResponse
    let requestSignature: String
}
  • data: The raw response body
  • response: The HTTP response metadata
  • requestSignature: A deterministic hash representing the full request

MercuryFailure

struct MercuryFailure: Error {
    let error: MercuryError
    let requestSignature: String
}
  • error: One of the structured MercuryError cases
  • requestSignature: Same unique hash - available even on failure

What is requestSignature?

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



Error Handling

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

Complete Error Handling Example

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



Testing with MockMercury

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

Mock Capabilities

  • 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



License

Mercury is available under the MIT License. See the LICENSE file for more details.


By Josh Gallant, Made with ❤️ for the Swift community

About

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.

Topics

Resources

License

Stars

Watchers

Forks

Languages