Swift Networking — URLSession, Codable, and REST API Integration
In this tutorial, you will learn about Swift Networking. We cover key concepts, practical examples, and best practices to help you master this topic.
Swift networking uses URLSession to perform HTTP requests combined with the Codable protocol to automatically serialize and deserialize JSON data, providing a type-safe, modern approach to API integration.
What You'll Learn
- URLSession basics and configuration
- Making GET and POST requests with async/await
- The Codable protocol for JSON serialization
- Error handling and response validation
- Building a reusable networking layer
- Security considerations (HTTPS, certificate pinning)
Why It Matters
Nearly every modern app communicates with a server. From fetching weather data to submitting user profiles, networking is fundamental. Swift's URLSession combined with Codable provides a first-party solution that is secure, performant, and deeply integrated with the language's type system.
Real-World Use
A news app fetches articles from a REST API. The response JSON is automatically decoded into an array of Article structs via Codable. Each article is then displayed in a UITableView. When the user taps an article, a detail screen is pushed. All networking is handled by a generic APIClient that works with any Codable type.
Learning Path
flowchart LR A[Navigation
Lesson 19] --> B[Networking
You are here] B --> C[Data Persistence
Lesson 21] B --> D[Concurrency
Lesson 22] style B fill:#f90,color:#fff
URLSession Basics
URLSession is the foundation of networking in Swift. It manages HTTP requests, authentication, Caching, and cookie storage.
import Foundation
let session = URLSession.shared
func fetchData() {
let url = URL(string: "https://api.example.com/data")!
let task = session.dataTask(with: url) { data, response, error in
if let error = error {
print("Network error: \(error.localizedDescription)")
return
}
if let data = data {
let result = String(data: data, encoding: .utf8) ?? ""
print("Received: \(result.prefix(100))...")
}
}
task.resume()
}
fetchData()
Output: Received: {"status":"ok","items":[...]...
The shared Singleton session is suitable for most basic requests. The dataTask runs asynchronously and calls its completion handler on a background queue. You must call resume() to start the task — tasks start in a suspended state.
Custom Session Configuration
For more control, create custom session configurations.
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
configuration.waitsForConnectivity = true
configuration.allowsCellularAccess = true
configuration.httpAdditionalHeaders = [
"Accept": "application/json",
"User-Agent": "MyApp/1.0"
]
let customSession = URLSession(configuration: configuration)
print("Custom session created with 30s timeout")
Making GET Requests with async/await
Swift's concurrency model makes networking code much cleaner with async/await.
import Foundation
struct Todo: Decodable, Identifiable {
let id: Int
let title: String
let completed: Bool
}
func fetchTodos() async throws -> [Todo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
return try decoder.decode([Todo].self, from: data)
}
enum NetworkError: Error, CustomStringConvertible {
case httpError(Int)
case decodingError(String)
var description: String {
switch self {
case .httpError(let code):
return "HTTP error: \(code)"
case .decodingError(let message):
return "Decoding error: \(message)"
}
}
}
// Usage:
// Task {
// do {
// let todos = try await fetchTodos()
// for todo in todos.prefix(3) {
// print("\(todo.id): \(todo.title) [\(todo.completed ? "x" : " ")]")
// }
// } catch {
// print("Failed: \(error)")
// }
// }
The async version of data(from:) returns both data and response. Check the HTTP status code before decoding. The method throws on network errors automatically.
Making POST Requests
POST requests send data to the server, typically JSON-encoded.
import Foundation
struct NewTodo: Encodable {
let title: String
let completed: Bool
let userId: Int
}
struct CreatedTodo: Decodable {
let id: Int
let title: String
let completed: Bool
let userId: Int
}
func createTodo() async throws -> CreatedTodo {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let newTodo = NewTodo(title: "Learn Swift Networking", completed: false, userId: 1)
request.httpBody = try JSONEncoder().encode(newTodo)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 201 else {
throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try JSONDecoder().decode(CreatedTodo.self, from: data)
}
// Usage:
// Task {
// do {
// let created = try await createTodo()
// print("Created todo #\(created.id): \(created.title)")
// } catch {
// print("POST failed: \(error)")
// }
// }
For POST requests, construct a URLRequest manually, set the method and headers, and encode the body. Use data(for:) instead of data(from:) for request-based calls.
The Codable Protocol
Codable combines Encodable and Decodable into a single protocol for type-safe JSON handling.
import Foundation
struct User: Codable {
let id: Int
let name: String
let username: String
let email: String
let address: Address
let phone: String
let website: String
let company: Company
}
struct Address: Codable {
let street: String
let suite: String
let city: String
let zipcode: String
let geo: GeoLocation
}
struct GeoLocation: Codable {
let lat: String
let lng: String
}
struct Company: Codable {
let name: String
let catchPhrase: String
let bs: String
}
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://jsonplaceholder.typicode.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
func encodeUser(_ user: User) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(user)
return String(data: data, encoding: .utf8) ?? ""
}
// Usage:
// Task {
// do {
// let user = try await fetchUser(id: 1)
// print("User: \(user.name) (\(user.email))")
// print("City: \(user.address.city)")
// let json = try encodeUser(user)
// print(json)
// } catch {
// print("Error: \(error)")
// }
// }
Nested JSON objects map to nested Codable structs. The decoder automatically matches JSON keys to struct property names. Use CodingKeys for custom mappings when JSON keys differ from Swift conventions.
Custom Coding Keys
struct Post: Codable {
let id: Int
let title: String
let body: String
let userID: Int
enum CodingKeys: String, CodingKey {
case id, title, body
case userID = "userId"
}
}
func decodePost(from json: String) {
let data = Data(json.utf8)
let post = try? JSONDecoder().decode(Post.self, from: data)
if let post = post {
print("Post #\(post.id) by user \(post.userID): \(post.title)")
}
}
let json = """
{
"id": 1,
"title": "Hello",
"body": "World",
"userId": 42
}
"""
decodePost(from: json)
Output: Post #1 by user 42: Hello
The CodingKeys enum maps userID to the JSON key "userId". Swift automatically generates coding keys that match property names, so you only need to override mismatches.
Building a Reusable Networking Layer
A generic API client abstracts networking details so view controllers never deal with URLSession directly.
import Foundation
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
case patch = "PATCH"
}
protocol APIEndpoint {
var baseURL: String { get }
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String] { get }
var body: Data? { get }
}
extension APIEndpoint {
var baseURL: String { return "https://jsonplaceholder.typicode.com" }
var headers: [String: String] { return [:] }
var body: Data? { return nil }
func asURLRequest() throws -> URLRequest {
guard let url = URL(string: baseURL + path) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.allHTTPHeaderFields = headers
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
}
enum NetworkError: Error {
case invalidURL
case noData
case decodingError(Error)
case httpError(Int)
case networkError(Error)
}
class APIClient {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func fetch<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T {
let request = try endpoint.asURLRequest()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.httpError(0)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode)
}
do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError(error)
}
}
func fetchRaw(_ endpoint: APIEndpoint) async throws -> Data {
let request = try endpoint.asURLRequest()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
}
return data
}
}
struct GetTodosEndpoint: APIEndpoint {
var path: String { return "/todos" }
var method: HTTPMethod { return .get }
}
struct GetUserEndpoint: APIEndpoint {
let userID: Int
var path: String { return "/users/\(userID)" }
var method: HTTPMethod { return .get }
}
// Usage:
// let client = APIClient()
// Task {
// let todos: [Todo] = try await client.fetch(GetTodosEndpoint())
// print("Fetched \(todos.count) todos")
// }
This layer separates endpoint definitions from execution. Adding a new API endpoint requires only a new struct conforming to APIEndpoint.
Downloading Images
For binary data like images, use data(from:) and convert to UIImage.
import UIKit
func downloadImage(from urlString: String) async throws -> UIImage {
guard let url = URL(string: urlString) else {
throw NetworkError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw NetworkError.decodingError("Invalid image data" as! Error)
}
return image
}
// Usage:
// Task {
// do {
// let image = try await downloadImage(from: "https://example.com/image.png")
// print("Image downloaded: \(image.size.width)x\(image.size.height)")
// } catch {
// print("Image download failed: \(error)")
// }
// }
Uploading Data
Upload tasks send data to a server, typically with multipart form encoding for files.
func uploadProfileImage(data: Data, filename: String) async throws {
let url = URL(string: "https://api.example.com/upload")!
let boundary = UUID().uuidString
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: image/png\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
}
print("Upload successful")
}
Security Best Practices
Always use HTTPS, validate server certificates, and avoid hardcoding API keys.
import Security
class SecureSessionDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge) async
-> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard challenge.protectionSpace.authenticationMethod ==
NSURLAuthenticationMethodServerTrust else {
return (.performDefaultHandling, nil)
}
// In production, validate the server certificate here
// against a pinned certificate or public key
if let serverTrust = challenge.protectionSpace.serverTrust {
let credential = URLCredential(trust: serverTrust)
return (.useCredential, credential)
}
return (.cancelAuthenticationChallenge, nil)
}
}
let secureDelegate = SecureSessionDelegate()
let secureSession = URLSession(configuration: .default,
delegate: secureDelegate,
delegateQueue: nil)
print("Secure session configured with certificate validation")
Common Mistakes
Forgetting to call resume(): Tasks start in a suspended state. Without
task.resume(), the request never executes.Not checking HTTP status codes: A 404 or 500 response still returns data. Always validate the status code before decoding.
Decoding on the main thread: Network completion handlers run on background threads. Dispatch UI updates to the main queue or use
MainActor.Ignoring error types: Network errors, decoding errors, and HTTP errors all need distinct handling. Use a custom error enum to differentiate.
Hardcoding API keys in source code: API keys in source code can be extracted from the binary. Use environment variables, configuration files, or a backend proxy.
Practice Questions
- Why must you call
resume()on a URLSessionTask? - How does the Codable protocol simplify JSON handling?
- What is the difference between
data(from:)anddata(for:)in URLSession? - How would you handle a 401 Unauthorized response?
- Challenge: Build a generic API client that supports GET, POST, PUT, and DELETE methods. Include automatic retry logic for 5xx errors (up to 3 retries with exponential backoff).
Mini Project
Create a GitHubUserViewer app with:
- A struct
GitHubUser: Codablewithlogin,id,avatar_url,name,public_repos - A function
searchUsers(query: String) async throws -> [GitHubUser] - A function
fetchUserDetails(username: String) async throws -> GitHubUser - An
APIClientclass with genericfetch<T: Decodable>method - Error handling for network errors, invalid JSON, and HTTP errors
- Print the results of searching for "swift" and fetching the first user's details
FAQ
What's Next
After mastering networking, learn how to persist data locally with Data Persistence using Core Data, SwiftData, or UserDefaults, and explore Concurrency for advanced async patterns.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro