Skip to content

Build a Swift Networking Library — Reusable SPM Package with async/await

DodaTech Updated 2026-06-28 8 min read

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

  1. Not making the client an actor: Without actor isolation, simultaneous requests can cause data races in session configuration.

  2. Hardcoding base URLs: Always inject the base URL. Different environments (dev, staging, prod) need different URLs.

  3. Ignoring URL encoding: Query parameters with special characters need proper percent encoding. URLComponents handles this.

  4. Not handling empty responses: Some APIs return 204 No Content with no body. Handle this case specifically.

  5. Forgetting to set Accept headers: Always tell the server what format you expect, even if the server is well-behaved.

Practice Questions

  1. Why does APIClient use an actor instead of a class?
  2. How does the RetryHandler's exponential backoff work?
  3. What is the purpose of MockURLProtocol in testing?
  4. How would you add OAuth2 token refresh to this library?
  5. Challenge: Add request/response interceptors (like Alamofire's RequestInterceptor) that can modify requests before sending and handle responses before returning.

FAQ

Should I use this library instead of Alamofire?

This library is intentionally minimal. If you need advanced features like request queuing, response caching, or certificate pinning, consider Alamofire or extend this library.

How do I handle authentication?

Add an AuthInterceptor that checks if the response is 401, refreshes the token, and retries the request with the new token.

Can I upload files with this library?

Yes. Create a MultipartFormData helper that builds multipart request bodies. Add a MultipartUploadRequest protocol.

How do I cancel requests?

The calling Task handles cancellation. Pass the task reference and call task.cancel(). The URLSession task cancels automatically.

How do I add request logging in production?

Make the logger conditional with a build flag or runtime configuration. Only log in debug builds by default.

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