Most applications need to communicate with a server. We may need to register a user, log in to an account, load courses, or submit information. Although URLSession provides everything we need to perform these requests, using it directly throughout the application can quickly lead to repeated code.
For every request, we usually perform the same steps. We create a URLRequest, configure the HTTP method and headers, send the request, inspect the status code, and decode the returned data. We must also decide how to handle client errors, server errors, and decoding failures.
Instead of repeating this logic for every endpoint, we can move it into a reusable HTTPClient.
In this article, we will build a small networking layer using URLSession and Swift concurrency. We will create a generic Resource type to describe API requests, represent different HTTP methods, handle network errors, and decode responses into strongly typed Swift models.
By the end, we will use the same HTTPClient to perform both GET and POST requests and integrate it with a SwiftUI application.
There are several ways to implement an HTTPClient. One approach is to create a separate function for every operation:
func login(...)
func register(...)
func loadCourses(...)
func loadExams(...)
func submitExam(...)
This may work for a small application, but it can quickly become a maintenance problem. As the application grows, the HTTPClient may end up with hundreds of functions.
Each function will perform many of the same steps. It will create a request, add headers and authentication tokens, encode the request body, send the request, inspect the status code, decode the response, and handle errors.
After writing a few of these functions, you will notice that most of the code is repeated. The main things that change are the endpoint, HTTP method, request body, and expected response type.
Instead of creating a separate networking function for every operation, we can create a single generic function that works with different types of requests and responses:
func fetch<T>(_ resource: Resource<T>) async throws -> T
The Resource describes the request, while the generic type T represents the response we expect from the server.
For a login request, T may be LoginResponse:
let resource = Resource(
url: Constants.Urls.login,
method: .post(try loginForm.encoded()),
responseType: LoginResponse.self
)
For a request that loads courses, T may be [Course]:
let resource = Resource(
url: Constants.Urls.courses,
responseType: [Course].self
)
Both requests use the same fetch function:
let loginResponse = try await httpClient.fetch(loginResource)
let courses = try await httpClient.fetch(coursesResource)
The requests are different, but the networking steps remain the same. By using generics, we can reuse those steps while still returning strongly typed responses.
Before implementing HTTPClient, we need a few supporting types. These types will describe how the request should be sent, what kind of response we expect, and what can go wrong during the request.
Let’s start with HTTPMethod.
enum HTTPMethod {
case get
case post(Data)
case delete
case put
var rawValue: String {
switch self {
case .get:
"GET"
case .post:
"POST"
case .delete:
"DELETE"
case .put:
"PUT"
}
}
var body: Data? {
switch self {
case .post(let data):
data
default:
nil
}
}
}
The HTTPMethod enum represents the HTTP method associated with a request. In our application, we currently support GET, POST, DELETE, and PUT.
The post case contains a Data value. This represents the request body that will be sent to the server. For example, when registering a user, we can encode the registration form into JSON and pass the resulting data to the post case.
let data = try registerForm.encoded()
let method = HTTPMethod.post(data)
The rawValue computed property returns the value expected by URLRequest:
request.httpMethod = resource.method.rawValue
The body computed property returns the associated data for a POST request. For all other request types, it returns nil.
request.httpBody = resource.method.body
Next, let’s look at Resource.
struct Resource<T: Decodable> {
let url: URL
var method: HTTPMethod = .get
let responseType: T.Type
}
A Resource represents an API endpoint. It tells HTTPClient three things:
The generic type T must conform to Decodable because the server response will be decoded into that type.
For example, the following resource sends a login request and expects a LoginResponse:
let resource = Resource(
url: Constants.Urls.login,
method: .post(try loginForm.encoded()),
responseType: LoginResponse.self
)
The response type is an important part of the resource. It allows the networking layer to remain generic. The same HTTPClient can return a LoginResponse, a User, a Course, or an array of courses.
For requests that do not provide a method, .get is used by default:
let resource = Resource(
url: Constants.Urls.courses,
responseType: [Course].self
)
Finally, we need to think about errors. A network request can fail for several reasons. The server may reject the request, the response may not be valid, or the returned JSON may not match the response type we expected.
We can represent these situations using NetworkError.
enum NetworkError: Error {
case badRequest(ErrorResponse)
case invalidResponse
case decodingFailed(Error)
case serverError
}
Each case represents a different kind of failure.
The badRequest case is used when the server returns a client error. This may happen when the user submits an invalid email address, enters an incorrect password, or forgets to provide required information. The associated ErrorResponse contains the message returned by the server.
The invalidResponse case is used when the response cannot be converted into an HTTPURLResponse.
The decodingFailed case is used when the server returns data, but the data cannot be decoded into the expected Swift type. We also store the original error, which can be helpful when debugging the problem.
The serverError case represents errors generated by the server, typically responses with status codes in the 500...599 range.
At the moment, these errors do not contain messages that are suitable for displaying in the user interface. We can fix that by conforming NetworkError to LocalizedError.
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .badRequest(let response):
response.errorMessage
case .invalidResponse:
"The server returned an invalid response."
case .decodingFailed:
"Unable to process the server response."
case .serverError:
"The server encountered an error. Please try again later."
}
}
}
Now the application can use localizedDescription to display an appropriate message:
do {
let response = try await httpClient.fetch(resource)
} catch {
showToast(error.localizedDescription)
}
At this point, we have everything we need to describe a network request and communicate errors to the user. In the next section, we will use these types to build HTTPClient.
Now that we have created Resource, HTTPMethod, and NetworkError, we can implement HTTPClient.
The job of HTTPClient is to create a request, send it to the server, and decode the response into the type specified by the resource.
struct HTTPClient {
private let session: URLSession
private let decoder: JSONDecoder
private let defaultHeaders: [String: String]
init(
session: URLSession = .shared,
decoder: JSONDecoder = JSONDecoder(),
defaultHeaders: [String: String] = [
"Content-Type": "application/json"
]
) {
self.session = session
self.decoder = decoder
self.defaultHeaders = defaultHeaders
}
func fetch<T>(_ resource: Resource<T>) async throws -> T {
var request = URLRequest(url: resource.url)
request.httpMethod = resource.method.rawValue
request.allHTTPHeaderFields = defaultHeaders
request.httpBody = resource.method.body
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
do {
return try decoder.decode(T.self, from: data)
} catch {
print(error.localizedDescription)
throw NetworkError.decodingFailed(error)
}
case 400...499:
do {
let errorResponse = try decoder.decode(
ErrorResponse.self,
from: data
)
throw NetworkError.badRequest(errorResponse)
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.decodingFailed(error)
}
case 500...599:
throw NetworkError.serverError
default:
throw NetworkError.invalidResponse
}
}
}
The HTTPClient depends on URLSession, JSONDecoder, and a collection of default headers.
private let session: URLSession
private let decoder: JSONDecoder
private let defaultHeaders: [String: String]
URLSession is used to send the request, while JSONDecoder converts the returned JSON into a Swift type. The default headers are added to every request.
We pass these dependencies through the initializer:
init(
session: URLSession = .shared,
decoder: JSONDecoder = JSONDecoder(),
defaultHeaders: [String: String] = [
"Content-Type": "application/json"
]
) {
self.session = session
self.decoder = decoder
self.defaultHeaders = defaultHeaders
}
Since each dependency has a default value, we can create an instance without providing any arguments:
let httpClient = HTTPClient()
Passing these dependencies through the initializer also makes the client easier to configure and test. For example, we can provide a decoder that supports ISO 8601 dates:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let httpClient = HTTPClient(decoder: decoder)
The fetch function accepts a resource and returns the response type associated with that resource:
func fetch<T>(_ resource: Resource<T>) async throws -> T
We begin by creating a URLRequest using the information stored in the resource:
var request = URLRequest(url: resource.url)
request.httpMethod = resource.method.rawValue
request.allHTTPHeaderFields = defaultHeaders
request.httpBody = resource.method.body
This is one of the main benefits of using Resource. The HTTPClient does not need to know whether it is sending a login request, registering a user, or loading courses. The resource already contains everything needed to create the request.
Next, we send the request:
let (data, response) = try await session.data(for: request)
The returned response is a URLResponse, but we need an HTTPURLResponse to access the HTTP status code.
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
Once we have the status code, we can decide how to process the response.
A status code in the 200...299 range means the request was successful.
case 200...299:
do {
return try decoder.decode(T.self, from: data)
} catch {
print(error.localizedDescription)
throw NetworkError.decodingFailed(error)
}
The response is decoded into T. The actual type of T depends on the resource.
If the resource expects a LoginResponse, fetch returns a LoginResponse:
let resource = Resource(
url: Constants.Urls.login,
method: .post(try loginForm.encoded()),
responseType: LoginResponse.self
)
let response = try await httpClient.fetch(resource)
If decoding fails, we wrap the original error inside NetworkError.decodingFailed.
Status codes in the 400...499 range mean there is a problem with the request.
case 400...499:
do {
let errorResponse = try decoder.decode(
ErrorResponse.self,
from: data
)
throw NetworkError.badRequest(errorResponse)
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.decodingFailed(error)
}
The server may return a useful error message when a request fails. For example, it may tell us that an account already exists or that the provided credentials are incorrect.
Instead of throwing a generic error, we decode the response into ErrorResponse and include it with NetworkError.badRequest.
You may be wondering why we need two catch blocks.
After decoding ErrorResponse, we intentionally throw NetworkError.badRequest. The first catch catches that error and throws it again:
catch let error as NetworkError {
throw error
}
The second catch handles an error that occurs while decoding ErrorResponse:
catch {
throw NetworkError.decodingFailed(error)
}
Without the first catch, our badRequest error would be caught by the general catch block and incorrectly changed into a decoding error.
A status code in the 500...599 range means something went wrong on the server.
case 500...599:
throw NetworkError.serverError
There is usually nothing the client can do to fix a server error. We can catch this error in the user interface and ask the user to try again later.
Finally, any status code that does not belong to one of the expected ranges is treated as an invalid response:
default:
throw NetworkError.invalidResponse
Our HTTPClient is now ready to use. It can send different types of requests, decode successful responses, and convert unsuccessful responses into errors that can be handled by the application.
Now that our HTTPClient is ready, let’s use it to send requests to the server.
We will begin with a simple GET request that loads a list of courses.
struct Course: Decodable {
let id: Int
let title: String
}
Next, we create a resource that describes the request:
let resource = Resource(
url: Constants.Urls.courses,
responseType: [Course].self
)
Since we did not provide an HTTP method, the resource uses .get by default. We also specified [Course].self as the response type because we expect the server to return an array of courses.
We can now pass the resource to HTTPClient:
let httpClient = HTTPClient()
let courses = try await httpClient.fetch(resource)
The return type of fetch is inferred from the resource. Since the resource expects [Course], the returned value is also [Course].
The complete request looks like this:
func loadCourses() async {
let resource = Resource(
url: Constants.Urls.courses,
responseType: [Course].self
)
do {
let courses = try await httpClient.fetch(resource)
print(courses)
} catch {
print(error.localizedDescription)
}
}
The networking code does not need to create a URLRequest, configure headers, inspect status codes, or decode JSON. All of that work is handled by HTTPClient.
Let’s look at a more practical example by sending a login request.
We will start with the form that contains the user’s email and password:
struct LoginForm: Encodable {
var email = ""
var password = ""
}
We also need a type that represents the server response:
struct LoginResponse: Decodable {
let user: User
let accessToken: String
}
Before sending the request, the login form must be converted into Data. We can add an encoded function to Encodable:
extension Encodable {
func encoded() throws -> Data {
try JSONEncoder().encode(self)
}
}
Now we can create the login resource:
let resource = Resource(
url: Constants.Urls.login,
method: .post(try loginForm.encoded()),
responseType: LoginResponse.self
)
The resource contains everything needed to perform the request:
We can send the request by passing the resource to fetch:
let response = try await httpClient.fetch(resource)
Since the resource expects LoginResponse, the returned value is automatically inferred as LoginResponse.
In a SwiftUI application, network requests should not be performed directly inside the view. Instead, we can inject HTTPClient into a store and allow the store to manage the operation.
For example, the AuthenticationStore can use HTTPClient to log in the user:
import Observation
@Observable
class AuthenticationStore {
private let httpClient: HTTPClient
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
func login(form: LoginForm) async throws {
let resource = Resource(
url: Constants.Urls.login,
method: .post(try form.encoded()),
responseType: LoginResponse.self
)
let response = try await httpClient.fetch(resource)
print(response.user)
print(response.accessToken)
}
}
The store does not need to know how URLSession works or how the response is decoded. Its responsibility is to create the correct resource and use the returned result.
We can create the store when the application starts:
@main
struct MathLabClientApp: App {
@State private var authenticationStore: AuthenticationStore
init() {
let httpClient = HTTPClient()
_authenticationStore = State(
initialValue: AuthenticationStore(
httpClient: httpClient
)
)
}
var body: some Scene {
WindowGroup {
LoginScreen()
.environment(authenticationStore)
}
}
}
This also means that the same HTTPClient instance can be shared with other stores:
let httpClient = HTTPClient()
let authenticationStore = AuthenticationStore(
httpClient: httpClient
)
let courseStore = CourseStore(
httpClient: httpClient
)
The LoginScreen can call the store when the user taps the login button:
struct LoginScreen: View {
@State private var loginForm = LoginForm()
@State private var isLoggingIn = false
@Environment(AuthenticationStore.self)
private var authenticationStore
@Environment(\.showToast)
private var showToast
var body: some View {
Form {
TextField("Email", text: $loginForm.email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
SecureField("Password", text: $loginForm.password)
Button("Login") {
Task {
await login()
}
}
.disabled(isLoggingIn)
}
}
private func login() async {
isLoggingIn = true
defer { isLoggingIn = false }
do {
try await authenticationStore.login(form: loginForm)
} catch {
showToast(error.localizedDescription)
}
}
}
The view is responsible for collecting input, displaying progress, and showing errors. The store performs the login operation, while HTTPClient handles the networking details.
This gives us a simple flow:
HTTPClient sends the request.By separating these responsibilities, we can reuse the same HTTPClient throughout the application without repeating networking code in every screen.
You can download the source code here
In this article, we created a reusable networking layer using URLSession, async/await, and Swift generics.
We started by creating HTTPMethod to represent the type of request being sent. We then introduced Resource, which contains the URL, HTTP method, and expected response type. Finally, we created NetworkError to represent the different failures that may occur while communicating with the server.
Using these types, we implemented an HTTPClient that can create requests, send them asynchronously, inspect HTTP status codes, and decode successful responses into Swift models. We also used the client inside an AuthenticationStore, keeping networking logic out of the SwiftUI view.
The important part of this approach is not the amount of code we wrote. It is the separation of responsibilities. The view collects input and displays the result. The store coordinates the operation. The resource describes the request. The HTTP client handles communication with the server.
As the application grows, the same client can be used for registration, authentication, courses, exams, grades, and other API requests without repeating the underlying networking code.
Become an AzamSharp School member and get access to more than 250 hours of practical courses covering SwiftUI, SwiftData, testing, architecture, AI, machine learning, and more.
Your membership also includes access to AzamSharp books, live workshops, office hours, and new content added regularly.