Skip to content

An easy to use HTTP networking library for Swift with built-in JSON encoding/decoding, comprehensive error handling, and powerful testing capabilities.

License

Notifications You must be signed in to change notification settings

joshgallantt/Mercury

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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 Size

An easy to use HTTP networking library for Swift with built-in JSON encoding/decoding, comprehensive error handling, and powerful testing capabilities.

Table of Contents

  1. Features
  2. Installation
  3. Quick Start
  4. Advanced
  5. Cache Management
  6. Error Handling
  7. Testing
  8. License

Features

  • Automatic Coding: Automatically encodes requests and decodes responses
  • Result-based: Uses Swift’s Result type for clean error handling
  • Cache: Ready-to-use with URLCache
  • Flexible: Supports all HTTP methods (GET, POST, PUT, PATCH, DELETE)
  • Configurable: Custom headers, query parameters, caching, and more
  • Testable: Built-in mocking and stubbing for reliable tests
  • Lightweight: ~2MB and 0 dependancies, now and forever.

Installation

Add Mercury to your project with Swift Package Manager:

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

Then add "Mercury" to your targets:

.target(
    name: "YourApp",
    dependencies: ["Mercury"]
),
.testTarget(
    name: "YourAppTests",
    dependencies: ["Mercury", "MercuryTesting"]
)

Quick Start

GET

import Mercury

let client = Mercury(host: "https://accounts.yourApp.com")

struct User: Decodable {
    let id: Int
    let name: String
    let email: String
}

let result = await client.get(
    path: "/users/123",
    decodeTo: User.self
)

switch result {
case .success(let success):
    print("Got user: \(success.data.name)")
    // Console: Got user: John Doe

case .failure(let failure):
    print("Request failed: \(failure)")
    /*
    // Console example outputs:
    Request failed: Decoding failed in 'User' for key 'email'
    Request failed: 401 Unauthorized: Invalid API token
    Request failed: 404 Not Found
    Request failed: Transport error: Lost Connection
    */
}

POST

struct CreateUserRequest: Encodable {
    let name: String
    let email: String
}

struct CreateUserResponse: Decodable {
    let id: Int
    let name: String
    let email: String
    let createdAt: String
}

let newUser = CreateUserRequest(name: "John Doe", email: "[email protected]")

let result = await client.post(
    path: "/users",
    body: newUser,
    decodeTo: CreateUserResponse.self
)

Advanced

Client Setup

let client = Mercury(
    host: "https://api.example.com:8080/v1",
    defaultHeaders: [
        "Accept": "application/json",
        "Authorization": "Bearer your-token"
    ],
    defaultCachePolicy: .reloadIgnoringLocalCacheData,
    cache: .isolated(
        memorySize: 4_000_000, // 4MB in-memory
        diskSize: 10_000_000   // 10MB disk
    )
)

Handling Nested Types

You can decode deeply nested objects just by specifying the type:

struct UserProfile: Decodable {
    let user: User
    let preferences: UserPreferences
    let addresses: [Address]
}
let result = await client.get(
    path: "/users/123/profile",
    decodeTo: UserProfile.self
)

Decoding Options

Decode into any object:

struct User: Decodable {name: String}

let result = await client.get(
    path: "/users/123",
    decodeTo: User.self
)

Or simple text:

let result = await client.get(
    path: "/health",
    decodeTo: String.self
)

No decode type? No Problem, will return binary Data() by default:

let result = await client.post(
    path: "/health",
    body: newUser
)

Per Request Overrides

You can override headers, add new ones, or specify a custom cache policy for each request:

let result = await client.get(
    path: "/users/123",
    headers: [
        "X-Custom-Header": "custom-value",
        "Accept-Language": "en-US"
    ],
    query: [
        "include": "profile,preferences",
        "format": "detailed"
    ],
    fragment: "section",
    cachePolicy: .reloadIgnoringLocalCacheData,
    decodeTo: User.self
)

Cache Management

Mercury supports two caching strategies:

  • .shared (default): uses URLCache.shared
  • .isolated: your own limits per client

Example:

let client = Mercury(
    host: "https://api.example.com",
    cache: .isolated(memorySize: 4_000_000, diskSize: 20_000_000)
)

client.clearCache() // Clears this client’s cache only

To clear all shared cache:

Mercury.clearSharedURLCache()

Warning

Mercury.clearSharedURLCache() clears the global shared URLCache for your process, this includes any URLSession cache outside of Mercury or its clients.

Error Handling

Pesky decoding errors? Not anymore, see exatly which key you forgot to make optional:

Simple error handling:

switch result {
case .success(let success):
    print("Got user: \(success.data.name)")
    // Console: Got user: John Doe

case .failure(let failure):
    print("Request failed: \(failure)")
    /*
    // Example outputs:
    Request failed: Decoding failed in 'User' for key 'email'
    Request failed: 401 Unauthorized: Invalid API token
    Request failed: 404 Not Found
    Request failed: Transport error: Lost Connection
    */
}

Handle specific errors if you need more control:

switch failure.error {
case .invalidURL:
    print("Invalid URL configuration")
case .server(let statusCode, let data):
    print("Server error: \(statusCode)")
case .invalidResponse:
    print("Invalid response from server")
case .transport(let error):
    print("Network error: \(error.localizedDescription)")
case .encoding(let error):
    print("Encoding failed: \(error)")
case .decoding(let namespace, let key, let error):
    print("Failed to decode \(namespace).\(key): \(error)")
}

Testing

Mercury ships with a mock client for robust, fully isolated unit tests.

Basic Setup

import XCTest
import Mercury
import MercuryTesting

final class UserServiceTests: XCTestCase {
    private var mockClient: MockMercury!
    private var userService: UserService!

    override func setUp() {
        super.setUp()
        mockClient = MockMercury()
        userService = UserService(client: mockClient)
    }

    override func tearDown() {
        mockClient.reset()
        mockClient = nil
        userService = nil
        super.tearDown()
    }
}

Stubbing Successful Responses

func test_givenValidUserId_whenFetchUser_thenReturnsUser() async {
    // Given
    let expectedUser = User(id: 123, name: "John Doe", email: "[email protected]")
    mockClient.stubGet(path: "/users/123", response: expectedUser)

    // When
    let user = await userService.fetchUser(id: 123)

    // Then
    XCTAssertEqual(user?.id, 123)
    XCTAssertEqual(user?.name, "John Doe")
    XCTAssertTrue(mockClient.wasCalled(method: .GET, path: "/users/123"))
}

Stubbing Failures

func test_givenServerError_whenFetchUser_thenReturnsNil() async {
    // Given
    mockClient.stubFailure(
        method: .GET,
        path: "/users/123",
        error: .server(statusCode: 404, data: nil),
        decodeTo: User.self
    )

    // When
    let user = await userService.fetchUser(id: 123)

    // Then
    XCTAssertNil(user)
}

Verifying Calls

func test_givenUserId_whenFetchUser_thenMakesCorrectRequest() async {
    // Given
    let user = User(id: 123, name: "John Doe", email: "[email protected]")
    mockClient.stubGet(path: "/users/123", response: user)

    // When
    _ = await userService.fetchUser(id: 123)

    // Then
    XCTAssertEqual(mockClient.callCount(for: .GET, path: "/users/123"), 1)
    XCTAssertTrue(mockClient.wasCalled(method: .GET, path: "/users/123"))
    let calls = mockClient.recordedCalls
    XCTAssertEqual(calls.count, 1)
    XCTAssertEqual(calls[0].method, .GET)
    XCTAssertEqual(calls[0].path, "/users/123")
    XCTAssertFalse(calls[0].hasBody)
}

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

An easy to use HTTP networking library for Swift with built-in JSON encoding/decoding, comprehensive error handling, and powerful testing capabilities.

Topics

Resources

License

Stars

Watchers

Forks

Languages