Build a Swift Networking Library — Reusable SPM Package with async/await
In this tutorial, you will learn about Build a Swift Networking Library. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a reusable networking library as an SPM package that provides a generic API client, protocol-based request definitions, automatic JSON decoding, logging, retry logic, and unit tests.
What You'll Build
- SPM package with multiple targets
- Generic API client supporting GET, POST, PUT, DELETE
- Protocol-based endpoint definitions
- Automatic Codable Serialization
- Configurable retry with exponential backoff
- Request logging and debugging
- Mock URLProtocol for testing
Why It Matters
Every app needs networking. Abstracting it into a reusable library means you never write boilerplate URLSession code again. This library pattern is used by Alamofire, Moya, and every production iOS team's internal tools.
Real-World Use
A team of 10 iOS developers maintains 5 apps. They built an internal "NetworkingKit" SPM package shared across all apps. Adding a new API endpoint is one struct definition. Error handling, logging, retry, and authentication are centralized.
Learning Path
flowchart LR A[Project: Weather App
Lesson 34] --> B[Project: Networking Library
You are here] B --> C[Project: Game
Lesson 36] B --> D[Project: CLI Tool
Lesson 37] style B fill:#f90,color:#fff
Package Structure
NetworkingKit/
Package.swift
Sources/
NetworkingKit/
APIClient.swift
APIRequest.swift
APIError.swift
APILogger.swift
APIRetry.swift
MultipartFormData.swift
Tests/
NetworkingKitTests/
APIClientTests.swift
MockURLProtocol.swift
RequestTests.swift
Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "NetworkingKit",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
.library(name: "NetworkingKit", targets: ["NetworkingKit"])
],
targets: [
.target(name: "NetworkingKit"),
.testTarget(name: "NetworkingKitTests", dependencies: ["NetworkingKit"])
]
)
Core Protocol
import Foundation
public enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
public protocol APIRequest {
associatedtype Response: Decodable & Sendable
var path: String { get }
var method: HTTPMethod { get }
var queryItems: [URLQueryItem] { get }
var headers: [String: String] { get }
var body: Data? { get }
var cachePolicy: URLRequest.CachePolicy { get }
var timeout: TimeInterval { get }
}
public extension APIRequest {
var queryItems: [URLQueryItem] { [] }
var headers: [String: String] { [:] }
var body: Data? { nil }
var cachePolicy: URLRequest.CachePolicy { .useProtocolCachePolicy }
var timeout: TimeInterval { 30 }
}
API Client
import Foundation
public actor APIClient {
private let baseURL: URL
private let session: URLSession
private let logger: APILogging?
private let retryHandler: RetryHandler?
public init(baseURL: URL,
session: URLSession = .shared,
logger: APILogging? = nil,
retryHandler: RetryHandler? = nil) {
self.baseURL = baseURL
self.session = session
self.logger = logger
self.retryHandler = retryHandler
}
public func send<T: APIRequest>(_ request: T) async throws -> T.Response {
let urlRequest = try buildRequest(from: request)
logger?.logRequest(urlRequest)
var attempts = 0
let maxRetries = retryHandler?.maxRetries ?? 0
while true {
do {
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
logger?.logResponse(httpResponse, data: data)
switch httpResponse.statusCode {
case 200...299:
return try JSONDecoder().decode(T.Response.self, from: data)
case 401:
throw APIError.unauthorized
case 403:
throw APIError.forbidden
case 404:
throw APIError.notFound
case 429:
throw APIError.rateLimited
case 500...599:
throw APIError.serverError(httpResponse.statusCode)
default:
throw APIError.httpError(httpResponse.statusCode)
}
} catch {
attempts += 1
if attempts <= maxRetries, let handler = retryHandler {
let delay = handler.delay(for: attempts)
logger?.logRetry(attempt: attempts, maxRetries: maxRetries, delay: delay)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
continue
}
throw error
}
}
}
private func buildRequest<T: APIRequest>(from request: T) throws -> URLRequest {
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true)!
components.path += request.path
if !request.queryItems.isEmpty {
components.queryItems = request.queryItems
}
guard let url = components.url else {
throw APIError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.httpBody = request.body
urlRequest.cachePolicy = request.cachePolicy
urlRequest.timeoutInterval = request.timeout
request.headers.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) }
if request.body != nil {
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
return urlRequest
}
}
Error Types
import Foundation
public enum APIError: Error, LocalizedError, CustomStringConvertible {
case invalidURL
case invalidResponse
case unauthorized
case forbidden
case notFound
case rateLimited
case serverError(Int)
case httpError(Int)
case decodingError(Error)
case networkError(Error)
public var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .invalidResponse: return "Invalid server response"
case .unauthorized: return "Authentication required"
case .forbidden: return "Access denied"
case .notFound: return "Resource not found"
case .rateLimited: return "Too many requests"
case .serverError(let code): return "Server error: \(code)"
case .httpError(let code): return "HTTP error: \(code)"
case .decodingError(let error): return "Decoding error: \(error.localizedDescription)"
case .networkError(let error): return "Network error: \(error.localizedDescription)"
}
}
public var description: String { errorDescription ?? "Unknown error" }
}
Logging
import Foundation
import os.log
public protocol APILogging {
func logRequest(_ request: URLRequest)
func logResponse(_ response: HTTPURLResponse, data: Data)
func logRetry(attempt: Int, maxRetries: Int, delay: TimeInterval)
}
public struct ConsoleLogger: APILogging {
private let logger = Logger(subsystem: "com.dodatech.networkingkit", category: "API")
public init() {}
public func logRequest(_ request: URLRequest) {
let method = request.httpMethod ?? "GET"
let url = request.url?.absoluteString ?? "unknown"
logger.info("[\(method)] \(url)")
}
public func logResponse(_ response: HTTPURLResponse, data: Data) {
let body = String(data: data, encoding: .utf8)?.prefix(200) ?? ""
logger.info("Status: \(response.statusCode), Body: \(body)")
}
public func logRetry(attempt: Int, maxRetries: Int, delay: TimeInterval) {
logger.warning("Retry \(attempt)/\(maxRetries) after \(delay)s")
}
}
Retry Handler
import Foundation
public struct RetryHandler {
public let maxRetries: Int
public let baseDelay: TimeInterval
public init(maxRetries: Int = 3, baseDelay: TimeInterval = 1.0) {
self.maxRetries = maxRetries
self.baseDelay = baseDelay
}
public func delay(for attempt: Int) -> TimeInterval {
baseDelay * pow(2.0, Double(attempt - 1))
}
}
Example Usage
import Foundation
import NetworkingKit
struct GetUserRequest: APIRequest {
typealias Response = User
let userID: Int
var path: String { "/users/\(userID)" }
var method: HTTPMethod { .get }
}
struct CreateUserRequest: APIRequest {
typealias Response = User
let name: String
let email: String
var path: String { "/users" }
var method: HTTPMethod { .post }
var body: Data? {
let body = ["name": name, "email": email]
return try? JSONSerialization.data(withJSONObject: body)
}
}
struct User: Decodable, Sendable {
let id: Int
let name: String
let email: String
}
// Usage:
// let client = APIClient(
// baseURL: URL(string: "https://jsonplaceholder.typicode.com")!,
// logger: ConsoleLogger(),
// retryHandler: RetryHandler(maxRetries: 2)
// )
//
// Task {
// let user = try await client.send(GetUserRequest(userID: 1))
// print(user.name)
// }
Testing with MockURLProtocol
import XCTest
@testable import NetworkingKit
class MockURLProtocol: URLProtocol {
static var mockData: Data?
static var mockStatusCode = 200
static var mockError: Error?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
if let error = Self.mockError {
client?.urlProtocol(self, didFailWithError: error)
return
}
let response = HTTPURLResponse(
url: request.url!,
statusCode: Self.mockStatusCode,
httpVersion: nil,
headerFields: nil
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Self.mockData ?? Data())
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
class APIClientTests: XCTestCase {
var client: APIClient!
override func setUp() {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
client = APIClient(baseURL: URL(string: "https://test.com")!, session: session)
}
func testSuccessfulRequest() async throws {
let userJSON = """
{"id": 1, "name": "Alice", "email": "alice@test.com"}
"""
MockURLProtocol.mockData = userJSON.data(using: .utf8)
MockURLProtocol.mockStatusCode = 200
let user = try await client.send(GetUserRequest(userID: 1))
XCTAssertEqual(user.id, 1)
XCTAssertEqual(user.name, "Alice")
}
func testNotFoundError() async {
MockURLProtocol.mockStatusCode = 404
do {
_ = try await client.send(GetUserRequest(userID: 999))
XCTFail("Expected error")
} catch APIError.notFound {
// Expected
} catch {
XCTFail("Wrong error: \(error)")
}
}
func testRetryOnServerError() async throws {
MockURLProtocol.mockData = """
{"id": 1, "name": "Alice", "email": "alice@test.com"}
""".data(using: .utf8)
MockURLProtocol.mockStatusCode = 503
do {
_ = try await client.send(GetUserRequest(userID: 1))
} catch {
XCTAssertTrue(error is APIError)
}
}
}
Key Takeaways
- Protocol-based design makes the library extensible
- Actor isolation prevents data races in the client
- MockURLProtocol enables fast, reliable tests
- Retry with exponential backoff handles transient errors
- SPM packaging makes it reusable across projects
Common Mistakes
Not making the client an actor: Without actor isolation, simultaneous requests can cause data races in session configuration.
Hardcoding base URLs: Always inject the base URL. Different environments (dev, staging, prod) need different URLs.
Ignoring URL encoding: Query parameters with special characters need proper percent encoding. URLComponents handles this.
Not handling empty responses: Some APIs return 204 No Content with no body. Handle this case specifically.
Forgetting to set Accept headers: Always tell the server what format you expect, even if the server is well-behaved.
Practice Questions
- Why does APIClient use an actor instead of a class?
- How does the RetryHandler's exponential backoff work?
- What is the purpose of MockURLProtocol in testing?
- How would you add OAuth2 token refresh to this library?
- Challenge: Add request/response interceptors (like Alamofire's RequestInterceptor) that can modify requests before sending and handle responses before returning.
FAQ
What's Next
Apply this library in the Project: Weather App, or learn different patterns in Project: Game.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro