maniramezan/swiftynetwork
A modern, Swift-native networking library built with Swift 6 concurrency, providing a clean and type-safe API for network operations with built-in caching, authentication, and error handling.
Features
- Swift 6 Concurrency: Built with async/await and actors for thread-safe operations
- Type-Safe Endpoints: Protocol-based endpoint definitions with compile-time safety
- Flexible Caching: Pluggable caching system with multiple policies
- Authentication Support: Built-in OAuth and custom authentication providers
- SSL Pinning: Optional certificate and public-key pinning for controlled hosts
- Comprehensive Error Handling: Detailed error types with localized descriptions
- Repository Pattern: Clean separation between network and local data sources
- Automatic Auth Refresh: Refreshes credentials and replays the request once on
401 - Fire-and-Forget Mutations: Background retry, key-based coalescing, and pluggable persistence via
MutationQueue - Fetch Deduplication:
SingleFlightCacheandRemoteDataCacheshare one fetch across concurrent callers - Reachability & Instrumentation:
NetworkMonitorstreams reachability;NetworkInstrumentationexposes request timing and outcomes - Test Doubles: The
SwiftyNetworkTestingproduct shipsMockAPIClientfor unit tests and previews - Thread-Safe: All operations are thread-safe using Swift's actor model
Requirements
- Swift 6.2+
- iOS 18.0+ / macOS 15.0+
Dependencies
SwiftyNetwork depends only on SwiftCommons for its string, duration, logging, concurrency, and error helpers. It declares that dependency with traits: , so SwiftCommons' SwiftData-backed APIs are not pulled in on its behalf.
Code Quality
This project uses swift-format (Apple's official Swift formatter) for code formatting and linting.
Local Development
Format code manually:
swift format format --in-place --recursive --parallel Sources Tests # Format in-place
swift format lint --recursive --parallel Sources Tests # Check for violationsCI
swift-format runs in strict mode on all PRs (warnings treated as errors). Configuration: .swift-format
Installation
Swift Package Manager
Add SwiftyNetwork to your Package.swift file:
dependencies: [
.package(url: "https://github.com/maniramezan/SwiftyNetwork.git", from: "1.0.0")
]Or add it through Xcode: File → Add Package Dependencies → Enter the repository URL.
Quick Start
1. Define an Endpoint
/// Example endpoint implementation for a REST API.
public struct UserEndpoint: NetworkEndpoint {
public let baseURL: String
public let userId: String
public init(baseURL: String, userId: String) {
self.baseURL = baseURL
self.userId = userId
}
public var path: String {
"/users/\(userId)"
}
public var method: HTTPMethod {
.get
}
public var headers: [String : String]? {
["Accept": "application/json"]
}
}2. Create a Data Model
/// Example user model.
public struct User: Codable, Sendable {
public let id: String
public let name: String
public let email: String
public init(id: String, name: String, email: String) {
self.id = id
self.name = name
self.email = email
}
}3. Make a Network Request
// Simple network request
let client = NetworkClient.shared
let endpoint = UserEndpoint(baseURL: "https://api.example.com", userId: "123")
do {
let user = try await client.request(endpoint, responseType: User.self)
print("User: \(user.name)")
} catch {
print("Error: \(error)")
}Advanced Usage
Complete Repository Setup with Caching
/// Example function showing how to set up a complete repository with caching.
func createUserRepository() -> GenericRepository<User> {
// Create network client with configuration
let configuration = NetworkClientConfiguration(
maxAuthRefreshAttempts: 3,
timeoutInterval: 30
)
let networkClient = NetworkClient(configuration: configuration)
// Create cache-based local data source
let cache = InMemoryCache<User>()
let localDataSource = CacheBasedLocalDataSource(cache: cache)
// Create and return repository
return GenericRepository(
networkDataSource: networkClient,
localDataSource: localDataSource
)
}Unified APIClient Interface
NetworkClient conforms to APIClient, so you can accept any implementation in higher-level code:
func fetchUser(client: any APIClient, baseURL: String, userId: String) async throws -> User {
let endpoint = UserEndpoint(baseURL: baseURL, userId: userId)
return try await client.request(endpoint, responseType: User.self)
}Layered Caching with a Custom Persistent Cache
Implement PersistentCache in your app to define on-disk behavior, then combine it with the built-in in-memory cache using LayeredCache.
public actor UserDiskCache: PersistentCache {
public typealias Value = User
// Replace this storage with file-backed I/O in your app.
private var storage: [CacheKey: (User, Date)] = [:]
public init() {}
public func value(forKey key: CacheKey) async -> User? {
storage[key]?.0
}
public func setValue(_ value: User, forKey key: CacheKey) async {
storage[key] = (value, Date())
}
public func removeValue(forKey key: CacheKey) async {
storage.removeValue(forKey: key)
}
public func removeAll() async {
storage.removeAll()
}
public func timestamp(forKey key: CacheKey) async -> Date? {
storage[key]?.1
}
}
func createLayeredRepository() -> GenericRepository<User> {
let memoryCache = InMemoryCache<User>()
let diskCache = UserDiskCache()
let layeredCache = LayeredCache(memoryCache: memoryCache, persistentCache: diskCache)
let localDataSource = CacheBasedLocalDataSource(cache: layeredCache)
return GenericRepository(
networkDataSource: NetworkClient.shared,
localDataSource: localDataSource
)
}Fetching with Different Cache Strategies
// Example 1: Cache-first strategy
func fetchUser(userId: String, baseURL: String) async throws -> User {
let repository = createUserRepository()
let endpoint = UserEndpoint(baseURL: baseURL, userId: userId)
let cacheKey = CacheKey.user(userId, resource: "profile")
// Fetch with cache fallback - tries cache first, then network
return try await repository.fetch(
using: endpoint,
cacheKey: cacheKey,
policy: .returnCacheElseLoad
)
}
// Example 2: Force refresh from network
func refreshUser(userId: String, baseURL: String) async throws -> User {
let repository = createUserRepository()
let endpoint = UserEndpoint(baseURL: baseURL, userId: userId)
let cacheKey = CacheKey.user(userId, resource: "profile")
// Always fetch from network and update cache
return try await repository.fetch(
using: endpoint,
cacheKey: cacheKey,
policy: .reloadIgnoringCache
)
}
// Example 3: Cache with expiration
func fetchUserWithExpiration(
userId: String,
baseURL: String,
maxCacheAge: TimeInterval = 300 // 5 minutes
) async throws -> User {
let repository = createUserRepository()
let endpoint = UserEndpoint(baseURL: baseURL, userId: userId)
let cacheKey = CacheKey.user(userId, resource: "profile")
// Use cached data if not older than maxCacheAge, otherwise fetch from network
return try await repository.fetch(
using: endpoint,
cacheKey: cacheKey,
policy: .returnCacheIfNotExpired(maxAge: maxCacheAge)
)
}OAuth Authentication
/// Example showing OAuth authentication setup.
func createAuthenticatedClient(accessToken: String) -> NetworkClient {
let authProvider = OAuthAuthorizationProvider(initialAccessToken: accessToken) {
// In a real app, this would refresh the token using a refresh token
// For this example, we just return nil to indicate refresh failure
return nil
}
let configuration = NetworkClientConfiguration(
authorizationProvider: authProvider,
maxAuthRefreshAttempts: 2
)
return NetworkClient(configuration: configuration)
}
// Usage
let authenticatedClient = createAuthenticatedClient(accessToken: "your-access-token")
let endpoint = UserEndpoint(baseURL: "https://api.example.com", userId: "123")
do {
let user = try await authenticatedClient.request(endpoint, responseType: User.self)
print("Authenticated request successful: \(user.name)")
} catch {
print("Error: \(error)")
}OAuthAuthorizationProvider coalesces concurrent refreshes, and a 401 for a token that another request already replaced retries with the new token instead of refreshing again. That matters for servers that rotate refresh tokens. Call updateAccessToken(_:) after sign-in to swap the token without rebuilding the client. Custom providers get the same behavior by implementing refreshAuthorization(rejecting:).
Fire-and-Forget Mutations with MutationQueue
Use MutationQueue for "forgivable" mutations -- liking a post, updating a setting -- that shouldn't block the UI on the network round trip and shouldn't be silently lost to a transient failure. NetworkClient deliberately doesn't retry transient/5xx errors on its own; MutationQueue is the dedicated retry loop for this class of call, with exponential backoff, coalescing by key, and status reporting.
let queue = MutationQueue(client: NetworkClient.shared)
// Enqueue returns immediately; the network call and any retries run in the background.
let request = MutationRequest(endpoint: LikeVideoEndpoint(videoID: "42"))
await queue.enqueue(request, key: "like:video:42")
// Rapid toggling coalesces to the latest desired state instead of replaying
// every intermediate call.
let undo = MutationRequest(endpoint: UnlikeVideoEndpoint(videoID: "42"))
await queue.enqueue(undo, key: "like:video:42")
// Observe outcomes to reflect status in the UI; MutationQueue reports what
// happened, it doesn't manage optimistic UI state itself.
for await event in await queue.events() where event.key == "like:video:42" {
switch event.status {
case .succeeded:
break
case .failed:
showLikeFailedError()
case .pending, .retrying:
break
}
}Persistence is pluggable via the MutationStore protocol. InMemoryMutationStore ships as the default (mutations are lost if the process is killed, which is fine for most forgivable mutations); implement MutationStore yourself for durable persistence that survives a relaunch. Because MutationRequest is Codable, a durable store can serialize "which endpoint plus what body" directly and replay it on the next launch via resumePendingMutations().
let queue = MutationQueue(client: NetworkClient.shared, store: MyDurableMutationStore())
await queue.resumePendingMutations() // call once at startupNote:
MutationRequestcan carry secrets (bearer tokens, API keys) via its capturedAuthorizationType. A durableMutationStoreis responsible for storing the encoded request securely (e.g. Keychain-backed) -- SwiftyNetwork does not encrypt persisted mutations itself.
SSL Pinning
Use SSL pinning when your app and API are operated together and you can safely rotate pins before server certificates or keys change. Pinning is enforced only for configured hosts; all other hosts use normal URLSession trust handling.
func makePinnedClient() throws -> NetworkClient {
guard let apiPublicKeyPin = SSLPinningConfiguration.Pin.publicKeySHA256(
base64Encoded: "BASE64_ENCODED_SHA256_PUBLIC_KEY_HASH"
) else {
throw NetworkError.invalidData
}
let pinning = SSLPinningConfiguration(
pinnedHosts: [
"api.example.com": [apiPublicKeyPin]
],
includesSubdomains: false,
requiresDefaultTrustValidation: true
)
let configuration = NetworkClientConfiguration(
sslPinning: pinning,
timeoutInterval: 30
)
return NetworkClient(configuration: configuration)
}Public-key pins use the industry-standard SPKI SHA-256 format (the same format as HPKP and TrustKit). Generate one for a live server with:
openssl s_client -connect api.example.com:443 < /dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64For certificate pins, use .certificate(_:) with DER certificate data or .certificateSHA256(base64Encoded:). For safer rotations, configure more than one accepted pin during certificate or key rollovers.
Deduplicating Fetches with SingleFlightCache and RemoteDataCache
SingleFlightCache wraps any Cache so concurrent misses for the same key share one fetch. RemoteDataCache builds on it to cache raw response bytes (for example images) by URL.
let users = SingleFlightCache(InMemoryCache<User>(maxSize: 500))
// Ten concurrent callers trigger one network request.
let user = try await users.value(forKey: "user:42") {
try await client.request(UserEndpoint(baseURL: baseURL, userId: "42"), responseType: User.self)
}
let images = RemoteDataCache(cache: InMemoryCache<Data>(maxSize: 200))
let avatar = try await images.data(for: avatarURL)RemoteDataCache keys only by URL. Use a separate instance per account for authenticated content.
Reachability with NetworkMonitor
let monitor = NetworkMonitor.shared
await monitor.startMonitoring()
for await status in await monitor.updates {
offlineBanner.isHidden = status == .reachable
}Each updates stream starts with the current status and only keeps the latest value for slow consumers.
Request Instrumentation
Conform to NetworkInstrumentation to feed request timing and outcomes into your observability stack. Every method has a no-op default, so implement only what you need.
struct RequestMetrics: NetworkInstrumentation {
func requestCompleted(_ event: NetworkRequestCompletion) async {
Metrics.record("http.duration", event.duration, tags: ["status": "\(event.statusCode)"])
}
func requestFailed(_ event: NetworkRequestFailure) async {
Metrics.increment("http.failure", tags: ["category": "\(event.error.classification)"])
}
}
let client = NetworkClient(configuration: NetworkClientConfiguration(instrumentation: RequestMetrics()))Events include full URLs and errors. Redact them before exporting.
Custom Cache Implementation
// Implement custom cache
public actor MyCustomCache<T: Sendable>: Cache {
public typealias Value = T
// Your custom cache implementation here
public func value(forKey key: CacheKey) async -> T? { /* ... */ }
public func setValue(_ value: T, forKey key: CacheKey) async { /* ... */ }
public func removeValue(forKey key: CacheKey) async { /* ... */ }
public func removeAll() async { /* ... */ }
public func timestamp(forKey key: CacheKey) async -> Date? { /* ... */ }
}
// Use with repository
let customCache = MyCustomCache<User>()
let local = CacheBasedLocalDataSource(cache: customCache)
// ... rest of repository setupCache Policies
SwiftyNetwork supports three caching strategies:
.returnCacheElseLoad: Return cached data if available, otherwise fetch from network.reloadIgnoringCache: Always fetch from network and update cache.returnCacheIfNotExpired(maxAge:): Use cached data if not older than specified age
Error Handling
SwiftyNetwork provides comprehensive error handling:
do {
let user = try await client.request(endpoint, responseType: User.self)
} catch NetworkError.unauthorized {
// Handle authentication error
} catch NetworkError.serverError(let statusCode, let data) {
// Handle server error with specific status code
} catch NetworkError.decodingFailed(let underlying) {
// Handle JSON decoding error
} catch {
// Handle other errors
}Use NetworkError.isTransient (or classification for telemetry tags) to decide whether a retry could help. Timeouts, lost connectivity, malformed responses, and 5xx statuses count as transient. This is the same rule MutationRetryPolicy.default uses.
Configuration
Network Client Configuration
let configuration = NetworkClientConfiguration(
session: URLSession.shared,
decoder: JSONDecoder(),
encoder: JSONEncoder(),
authorizationProvider: authProvider,
maxAuthRefreshAttempts: 3,
timeoutInterval: 30
)
let client = NetworkClient(configuration: configuration)Custom JSON Decoding
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let configuration = NetworkClientConfiguration(decoder: decoder)Architecture
SwiftyNetwork is built around several key protocols and types:
NetworkEndpoint: Defines API endpointsNetworkDataSource: Abstract network request interfaceCache: Generic caching interfaceLocalDataSource: Local storage abstractionRepository: Combines network and local data sourcesAuthorizationProvider: Handles authenticationSSLPinningConfiguration: Configures certificate and public-key pinningMutationQueue: Fire-and-forget mutations with background retry and coalescingMutationStore: Pluggable persistence for pending mutations
This design promotes clean separation of concerns, testability, and flexibility.
Thread Safety
All operations in SwiftyNetwork are thread-safe:
- Network clients use Swift actors for thread-safe state management
- Caches are implemented as actors
- All protocols are marked as
Sendable
Testing
Add the SwiftyNetworkTesting product to your test target to get MockAPIClient, a scriptable APIClient that records every request:
.testTarget(
name: "MyAppTests",
dependencies: [
"MyApp",
.product(name: "SwiftyNetworkTesting", package: "SwiftyNetwork"),
]
)import SwiftyNetwork
import SwiftyNetworkTesting
import Testing
@Test func likeRetriesTransientFailures() async throws {
let client = MockAPIClient()
// The first call times out, then every later call succeeds.
await client.stub(.post, "/likes", with: .failure(NetworkError.timeout), .empty)
let queue = MutationQueue(client: client, retryPolicy: .immediate())
await queue.enqueue(MutationRequest(endpoint: LikeVideoEndpoint(videoID: "42")), key: "like:video:42")
// ... await a `.succeeded` event from `queue.events()`
let sent = await client.requests(to: "/likes")
#expect(sent.count == 2)
}
@Test func repositoryCachesUsers() async throws {
let client = MockAPIClient()
try await client.stub(.get, "/users/123", returning: User(id: "123", name: "Ada", email: "ada@example.com"))
let repository = GenericRepository(
networkDataSource: client,
localDataSource: CacheBasedLocalDataSource(cache: InMemoryCache<User>())
)
// ...
}Responses for a route are consumed in order and the last one repeats. Unstubbed routes throw MockAPIClient.UnstubbedRequestError unless you set a fallback with setFallback(_:).
Contributing
We welcome contributions! Please read our CONTRIBUTING.md guidelines and submit pull requests to our GitHub repository.
Running Tests
# Run all tests
swift test
# Run tests with coverage
swift test --enable-code-coverage
# Generate coverage report
xcrun llvm-cov report .build/debug/SwiftyNetworkPackageTests.xctest/Contents/MacOS/SwiftyNetworkPackageTests \
-instr-profile .build/debug/codecov/default.profdataThe test suite covers caching, networking, auth, SSL pinning, and repository flows.
License
SwiftyNetwork is available under the MIT license. See the LICENSE file for more info.
Support
- Documentation: Full API documentation is published via GitHub Pages
- Live docs: https://maniramezan.github.io/SwiftyNetwork
- Issues: GitHub Issues
- Discussions: GitHub Discussions
GraphQL
See the GraphQL guide for typed variables, response envelopes, partial results, and transport limitations. GraphQL uses the existing HTTP pipeline; no schema compiler or additional dependency is required.
Architecture and review
ARCHITECTURE.md describes the current implementation and REVIEW.md records remaining correctness and performance work.
Package Metadata
Repository: maniramezan/swiftynetwork
Default branch: main
README: README.md