Writing

Untangling the RESTful Web: Adventures in Networking Chaos

Originally published on Medium.

An illustration of a developer at a laptop buried in tangled cables labelled REST API, JSON, GET and POST.

Picture this: You’re the bright-eyed new developer assigned to take over an app. It’s a great app. A cool app. Except… the networking code is a minefield. You open the project and, boom! There’s a REST call lurking in every corner, each with its own special quirks. One’s an odd URLSession extension buried in a random file. Another is a Frankenstein’s monster of closures and completion handlers that only the original dev could decipher. And to add insult to injury, you find the same boilerplate code written a dozen different ways.

Welcome to the Networking Nightmare.

The Networking Nuisance

It’s not unusual to inherit an app with a tangle of networking code. After all, REST APIs are the backbone of most apps these days. Whether you’re fetching user data, posting a cheeky comment, or syncing up your new high score, REST is everywhere.

But here’s the kicker: REST calls, when scattered like confetti across an app, are a developer’s nemesis. Here’s why:

1. Duplication Devastation

Every REST call seems to be its own snowflake. Need to fetch a list of users? There’s a bespoke function for that. Need to upload a profile pic? Oh, look, here’s another custom implementation. The result? A house of cards where one misstep sends your app’s stability crumbling.

2. The Great Error Handler Mystery

Networking is inherently failure-prone (thanks, unreliable Wi-Fi and rate limits). Yet, somehow, each piece of networking code decides to handle errors differently. Is this one retrying? Is that one failing silently? Who knows? Not you!

3. Concurrency Calamity

Swift’s concurrency model is powerful, but it’s easy to mess up. Is this API using completion handlers? Oh, wait, this one’s on a background thread, but that one forgot to return to the main thread. And, look, here’s one proudly using Combine in just one part of the app.

4. Scaling Silliness

Your app grows. Requirements change. Suddenly, your REST logic needs to support query parameters, authentication, caching, and pagination. When everything’s a one-off implementation, scaling becomes an uphill slog.

It’s the kind of mess that makes you want to stand up, shout, “WHO DID THIS?!” and then realize with horror that you might one day create the same spaghetti code if you’re not careful.

There’s Got to Be a Better Way!

Spoiler alert: There is. The secret to sanity lies in creating a reusable, generic networking layer that centralizes all your REST calls. You want one codebase to rule them all, simplifying request creation, handling failures gracefully, and maintaining consistency throughout your app. Here’s what that solution needs to do:

1. Centralized Networking Logic

Bring all your REST logic into a single place, like a NetworkManager. This acts as the one-stop shop for all REST requests. No more random URLSession calls strewn throughout your view controllers.

2. A Generic, Flexible API

Think about your typical REST operations: GET, POST, PUT, DELETE. Now imagine wrapping them into a reusable, type-safe generic API where all you need is a Decodable model. For example:

let users: [User] = try await NetworkManager.shared.request(endpoint: .getUsers)

Boom. No boilerplate. No fuss.

3. Error Handling Done Right

Networking will fail. Accept it. Timeouts, 500 errors, lost internet connections: embrace them with a unified error-handling strategy. Provide retries, meaningful error messages, and fallback logic, all baked into your networking layer.

4. Concurrency You Can Trust

Whether you’re on async/await, Combine, or (gulp) legacy completion handlers, your networking layer should abstract the concurrency model so your view code doesn’t need to care. Async-friendly APIs are the future, but make the transition seamless.

5. Built to Scale

Pagination? Sure. Query parameters? No problem. Authentication tokens? Of course. Your networking layer should handle all these without breaking a sweat.

Meet the Implementation You’ve Been Waiting For

Enough talk. Here’s the good bit. I’ve created a generic Swift implementation for connecting to REST services that solves the chaos. It’s simple, reusable, and designed to save you from ever touching raw URLSession code again.

Let’s take a peek at the setup:

Step 1: Define Your Endpoints

Start with an Endpoint enum to centralize all your REST calls.

enum Endpoint {
    case getUsers
    case postUser(data: User)
    case deleteUser(id: Int)
    
    var url: URL {
        switch self {
        case .getUsers: return URL(string: "https://api.example.com/users")!
        case .postUser: return URL(string: "https://api.example.com/users")!
        case .deleteUser(let id): return URL(string: "https://api.example.com/users/\(id)")!
        }
    }
    
    var method: String {
        switch self {
        case .getUsers: return "GET"
        case .postUser: return "POST"
        case .deleteUser: return "DELETE"
        }
    }
}

Step 2: Create the NetworkManager

This is the heart of the operation: a centralized, reusable manager that handles all requests.

import Foundation

final class NetworkManager {
    static let shared = NetworkManager()
    private init() {}
    
    func request<T: Decodable>(endpoint: Endpoint, body: Data? = nil) async throws -> T {
        var request = URLRequest(url: endpoint.url)
        request.httpMethod = endpoint.method
        if let body = body {
            request.httpBody = body
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }
        
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.invalidResponse
        }
        
        return try JSONDecoder().decode(T.self, from: data)
    }
}

enum NetworkError: Error {
    case invalidResponse
}

Step 3: Make a Call

Now, using the NetworkManager is straightforward:

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

Task {
  do {
    let users: [User] = try await NetworkManager.shared.request(endpoint: .getUsers)
    print(users)
  } catch {
    print("Failed to fetch users: \(error)")
  }
}

Why It Works

This solution turns your REST calls into concise, readable operations, without duplicating logic all over the place. You’ve got error handling centralized in one place, the flexibility to add authentication headers or caching, and the power of async/await to keep your code clean and modern.

This is Just the Beginning

Of course, what we’ve laid out here is just the foundation of a truly generic REST implementation. There’s still plenty of room to enhance this setup further. For example, you could integrate customizable timeouts to handle sluggish APIs, build a robust retry mechanism with exponential backoff for network blips, or centralize error handling to provide meaningful feedback to users and developers alike. Logging, caching, request queuing, and even real-time metrics could also find a home here, transforming this networking layer into a powerhouse for RESTful communication. The beauty of this approach is its flexibility: you can evolve it to meet your app’s specific needs without introducing unnecessary complexity.

TL;DR

Networking code doesn’t have to be chaos. By creating a centralized, reusable networking layer, you save time, avoid duplication, and future-proof your app. With the implementation I’ve shared, you can kiss goodbye to scattered REST logic and hello to clean, maintainable code.

Now go forth and refactor those networking nightmares. Your future self will thank you.

Till the Next Byte,

British Dev’s Diary

All writing