Networking in Depth: REST APIs, Codable & Error Handling

iOS Development — Architecture & Data

Chapter 3 · Networking in Depth: REST APIs, Codable & Error Handling

Fundamentals Chapter 9 covered a single, real GET request. Real apps need to send data too — creating, updating, deleting — and need real, descriptive errors when something goes wrong, not a generic failure. This chapter covers both, plus a small architectural pattern for keeping networking code out of ViewModels entirely.

URLRequest: Full Control Over an HTTP Request

URLSession.shared.data(from:) — Fundamentals' own tool — only issues a plain GET. URLRequest is the real, general-purpose struct needed for anything else: setting the HTTP method, real custom headers, and a real request body.

var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(newTask) let (data, response) = try await URLSession.shared.data(for: request)
ToolReal Capability
URLSession.shared.data(from: url)GET only, no headers, no body — Fundamentals' own simple case
URLSession.shared.data(for: request)Any real HTTP method, real custom headers, a real request body via URLRequest

Encoding a Request Body with Encodable

Fundamentals used Decodable to turn JSON into a struct. Sending data back needs the reverse — Encodable, or the combined Codable typealias covering both directions at once:

struct NewTaskRequest: Encodable { let title: String let priority: Int } let body = NewTaskRequest(title: "Buy milk", priority: 2) request.httpBody = try JSONEncoder().encode(body)

Real JSON Key Mismatches: CodingKeys & .convertFromSnakeCase

Real-world APIs rarely use Swift's own camelCase convention — snake_case is genuinely common. Two real, complementary tools handle this:

// Option 1: automatic, applies to every property let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase // Option 2: manual, per-property — needed for real exceptions struct Task: Decodable { let title: String let dueDate: Date? enum CodingKeys: String, CodingKey { case title case dueDate = "due_date" } }
A Real, Practical Rule
.convertFromSnakeCase is genuinely convenient when an entire API consistently uses snake_case — one line, every property. CodingKeys is the real fallback for exceptions: a specific key that doesn't follow the pattern, or a Swift property name that needs to differ from its JSON key for a reason beyond casing alone.

Real, Descriptive Errors

Throwing a generic URLError for every possible failure tells a caller almost nothing useful. A real, custom error type conforming to LocalizedError can carry genuine, specific meaning:

enum APIError: Error, LocalizedError { case invalidURL case requestFailed(statusCode: Int) case decodingFailed var errorDescription: String? { switch self { case .invalidURL: return "The request URL was invalid." case .requestFailed(let statusCode): return "The server returned status \(statusCode)." case .decodingFailed: return "The server's response couldn't be understood." } } }
A Real, Concrete Payoff
A ViewModel catching APIError.requestFailed(statusCode: 404) can show a genuinely specific, useful message — "Task not found" — instead of a generic "Something went wrong," and can react differently to a 401 (real, likely an expired login) than a 500 (a real server problem, not the user's own fault).

A Centralized APIClient

Repeating URLSession/JSONDecoder/status-code-checking logic inside every ViewModel is real, genuine duplication. A small, shared APIClient — following Chapter 1's own separation-of-concerns discipline — centralizes it once:

struct APIClient { func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T { let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse else { throw APIError.requestFailed(statusCode: 0) } guard (200..<300).contains(httpResponse.statusCode) else { throw APIError.requestFailed(statusCode: httpResponse.statusCode) } do { return try JSONDecoder().decode(T.self, from: data) } catch { throw APIError.decodingFailed } } }
Why 200..<300, Not Just == 200
A real, correctly-behaving server can succeed with several different real status codes in the 2xx range — 201 Created, 204 No Content — not just a plain 200. Checking a real range rather than one exact number is the genuinely correct check.

fetch's own generic <T: Decodable> means one real function serves every model type in the app — apiClient.fetch(Quote.self, from: quoteURL), apiClient.fetch([Task].self, from: tasksURL) — with the status-code checking and error handling written exactly once.

Hands-On Exercises

Exercise 1

Write a real function createTask(_ newTask: NewTaskRequest) async throws -> Task that builds a URLRequest with method "POST", a JSON-encoded body via JSONEncoder, and decodes the server's own response back into a Task.

📄 View solution
Exercise 2

Add a real case .unauthorized to APIError, with a matching errorDescription, and update the chapter's own fetch function so a 401 status code throws .unauthorized specifically, rather than the generic .requestFailed(statusCode:) case.

📄 View solution
Exercise 3

Explain, in your own words, why centralizing status-code checking and decoding inside one APIClient, rather than repeating that logic inside every individual ViewModel's own networking code, is a genuine real-world maintenance win — connecting your answer to Chapter 1's own MVVM separation-of-concerns principle.

📄 View solution

Chapter 3 Quick Reference

  • URLRequest — real, full control over HTTP method, headers, and body, needed for anything beyond a plain GET
  • Encodable/JSONEncoder — turns a real Swift struct into a JSON request body
  • .convertFromSnakeCase (automatic) and CodingKeys (manual, per-property) — two real, complementary tools for JSON key mismatches
  • A custom Error conforming to LocalizedError carries real, specific meaning a generic error can't
  • A centralized, generic APIClient avoids repeating status-code/decoding logic inside every ViewModel