NetworkAgent: Simplify Your Swift Networking Layer
What is NetworkAgent?
NetworkAgent is a small, dependency free networking layer for Swift that models your API as a single endpoint enum. It performs HTTP requests with modern async/await, and returns the raw (Data, URLResponse) tuple so you stay in full control of decoding. A Sendable plugin protocol lets you hook into the request/response lifecycle as async interceptors that can mutate the request before it is sent, mutate the response before it reaches the caller, or fire side requests for things like token refresh.
It was inspired by the architecture of the Moya package but designed to be simpler, fully compatible with Swift 6 strict concurrency, and free of any external dependencies.
Why Not Just Use URLSession?
Apple’s URLSession is powerful and flexible, but it leaves a lot of boilerplate to the developer. In a typical app you end up repeating the same patterns over and over: building URLs, setting headers, encoding parameters, decoding responses, and handling errors.
| Aspect | Raw URLSession | NetworkAgent |
|---|---|---|
| API definition | Scattered across functions, often duplicated | Centralized in a single enum conforming to NetworkAgentEndpoint |
| Parameter encoding | Manual URLComponents or JSONSerialization every time |
Declarative via HTTPTask.requestAttributes with .json or .url encoding |
| Request/response hooks | Custom URLProtocol subclass or spaghetti delegate callbacks |
Typed, async plugin chain with onRequest and onResponse interceptors |
| Multipart uploads | Manual boundary generation and body building | Built in via HTTPMultipartTask with automatic boundary headers |
| Concurrency | Manual Task wrapping | Native async/await, fully Sendable conformant |
| Testability | URLProtocol subclass or protocol mocking | Plugin based interception + repository pattern |
| Dependency weight | None (built in) | None (zero external deps) |
Getting Started
Add NetworkAgent through Swift Package Manager:
dependencies: [
.package(url: "https://github.com/radagva/NetworkAgent.git", from: "x.y.z")
]
Then add “NetworkAgent” to your target dependencies.
How It Works
1. Define Your API as an Enum
Every API call is described by a single enum case. You implement the NetworkAgentEndpoint protocol which tells the library the base URL, path, HTTP method, headers, and task type for each case.
import NetworkAgent
enum MyAPI: NetworkAgentEndpoint {
case login(email: String, password: String)
case user(id: Int)
case updateProfile(name: String, bio: String)
var baseURL: URL { URL(string: "https://api.example.com")! }
var path: String {
switch self {
case .login: return "/auth/login"
case .user(let id): return "/users/\(id)"
case .updateProfile: return "/profile"
}
}
var method: HTTPMethod {
switch self {
case .login: return .post
case .user: return .get
case .updateProfile: return .put
}
}
var task: HTTPTask {
switch self {
case .login(let email, let password):
return .requestAttributes(
attributes: ["email": email, "password": password],
encoding: .json
)
case .user:
return .requestPlain
case .updateProfile(let name, let bio):
return .requestAttributes(
attributes: ["name": name, "bio": bio],
encoding: .json
)
}
}
}
2. Create a Provider
The provider is the object you call to perform requests. It is generic over your endpoint enum and fully Sendable.
let provider = NetworkAgentProvider<MyAPI>()
3. Make a Request
Each call returns a (Data, URLResponse) tuple. You decode at the call site, which keeps the library small and gives you full control over your decoding strategy per request.
struct User: Decodable {
let id: Int
let name: String
let email: String
}
let (data, response) = try await provider.request(endpoint: .user(id: 42))
let user = try JSONDecoder().decode(User.self, from: data)
Plugins: The Extension Point
Plugins are async interceptors that let you hook into every request and response. They are Sendable and can mutate the request or response at any point in the chain.
Logging Plugin
The simplest plugin just observes and forwards the request and response unchanged:
struct LoggerPlugin: NetworkAgentPlugin {
func onRequest(
_ request: URLRequest,
endpoint: any NetworkAgentEndpoint
) async throws -> URLRequest {
print("→ \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "")")
return request
}
func onResponse(
_ response: URLResponse,
data: Data,
request: URLRequest,
endpoint: any NetworkAgentEndpoint,
agent: NetworkAgent
) async throws -> (data: Data, response: URLResponse) {
if let http = response as? HTTPURLResponse {
print("← \(http.statusCode) \(request.url?.absoluteString ?? "")")
}
return (data: data, response: response)
}
}
let provider = NetworkAgentProvider<MyAPI>(plugins: [LoggerPlugin()])
Auth Token Injection
The onRequest interceptor can inject authentication headers before the request is sent:
struct AuthPlugin: NetworkAgentPlugin {
let token: @Sendable () async -> String?
func onRequest(
_ request: URLRequest,
endpoint: any NetworkAgentEndpoint
) async throws -> URLRequest {
guard let token = await token() else { return request }
var mutated = request
mutated.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return mutated
}
}
Automatic Token Refresh
The real power of plugins shines when you need to handle 401 responses by refreshing the token and retrying the original request. The agent parameter on onResponse provides a way to fire side requests that do NOT re enter the plugin chain, preventing infinite recursion.
struct TokenRefreshPlugin: NetworkAgentPlugin {
let store: TokenStore
func onResponse(
_ response: URLResponse,
data: Data,
request: URLRequest,
endpoint: any NetworkAgentEndpoint,
agent: NetworkAgent
) async throws -> (data: Data, response: URLResponse) {
guard
let http = response as? HTTPURLResponse,
http.statusCode == 401
else {
return (data: data, response: response)
}
let (refreshData, _) = try await agent.request(MyAPI.refresh)
let refreshed = try JSONDecoder().decode(TokenResponse.self, from: refreshData)
await store.update(refreshed.accessToken)
return try await agent.request(endpoint)
}
}
Multipart Uploads
For file uploads, use HTTPMultipartTask:
var task: HTTPTask {
switch self {
case .uploadAvatar(let imageData):
return .upload(parts: [
HTTPMultipartTask(
data: imageData,
name: "avatar",
filename: "avatar.jpg",
mymetype: "image/jpeg"
),
HTTPMultipartTask(
data: Data("public".utf8),
name: "visibility"
)
])
default: return .requestPlain
}
}
var headers: [String: String] {
["Content-Type": "multipart/form-data"]
}
The provider automatically generates a boundary and appends it to the Content-Type header.
The Repository Pattern
A common and recommended approach is to wrap the provider inside a repository that handles the decoding:
final class UserRepository: Sendable {
private let provider: NetworkAgentProvider<MyAPI>
init(provider: NetworkAgentProvider<MyAPI>) {
self.provider = provider
}
func user(id: Int) async throws -> User {
let (data, _) = try await provider.request(endpoint: .user(id: id))
return try decoder.decode(User.self, from: data)
}
func login(email: String, password: String) async throws -> Session {
let (data, _) = try await provider.request(endpoint: .login(email: email, password: password))
return try decoder.decode(Session.self, from: data)
}
private static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()
}
This keeps your view models clean and makes testing straightforward: you can mock the provider or the repository directly.
Why NetworkAgent Wins Over Raw URLSession
The main advantage of NetworkAgent is that it codifies your API contract in a single place. Any developer joining your team can open the endpoint enum and see every network call the app makes, its HTTP method, its parameters, and its expected encoding, all in one file.
The plugin system means cross cutting concerns like logging, authentication, metrics, and error handling are implemented once and applied everywhere, rather than being scattered across view models or duplicated in every network call.
Because the library does not decode responses, you never fight against a built in JSONDecoder that does not match your API conventions. Each call site decides its own decoding strategy, key mapping, and date handling.
Finally, being 100% dependency free means you never worry about dependency conflicts, breaking changes from indirect dependencies, or bloated package graphs.
Recommendations
If you enjoyed this post, you might also want to read about SwiftQuery, a SwiftUI native data fetching and caching library inspired by TanStack Query that pairs wonderfully with NetworkAgent for building declarative, data driven interfaces.