Instruction
Parsing JSON Data
Parsing JSON data is one of the most fundamental skills to learn as an iOS Developer. Most apps make a network request to get data from a remote server, and once that request is executed, you’ll need to convert that data into a Swift object that can be used within your app.
The first step in this process is to analyze the data you plan to parse or decode. To make the initial steps easy to process, you’ll make the initial request in your browser. This will provide the opportunity to see the JSON structure formatted. Navigate to the following URL:
https://api.openweathermap.org/data/2.5/weather?q=orlando&units=imperial&appid=YOUR_API_KEY_HERE
In this case, the query parameter units in the URL are a value of imperial. This will allow the temperature values to come back to be in Fahrenheit. The default value for the OpenWeatherMap API is standard, which translates to temperature values in Kelvin. Once you make that request, you should return a response that looks similar to this:
When evaluating JSON data, it’s recommended that you determine which properties you care about first. In this case, at the top level of the response are four properties you want to decode: id, name, weather and main. These properties contain all of the information needed to make the SwiftWeather app. Take a closer look at each one:
- id: The unique identifier for the city’s weather that was fetched.
- name: The name of the city.
-
weather: An array of weather conditions in a city. The first is the
primaryweather condition. - main: An object representing the current city temperature data.
Now that you understand how the JSON data is structured, you can create the proper Swift object to represent this structure in the SwiftyWeather app. Open the starter project.
Navigate to the TopResponse file in the Models directory. This file will represent the structure for the outermost layer of the response from the server. This means that when you request data from the API, you don’t immediately get weather information at the root of the response. Instead, you receive several properties and objects that separate the information into smaller sections. The TopResponse will make understanding the data coming back easier. Inside the TopResponse struct, add an id property of type Int and a name property of type String. The result should look something like this:
struct TopResponse: Decodable {
let id: Int
let name: String
}
First, you’ll only parse these two properties to ensure your plumbing is set up correctly. When decoding, several things can go wrong, so start small and work your way up.
You can now decode your data into the first object you created. Navigate to the ContentView, and go to the onSubmit modifier you finished in lesson 2. Notice that towards the bottom of this implementation, you’re simply printing the resulting data to the console as a string. Look for the following line of code:
print(String(data: data, encoding: .utf8) ?? "")
You’ll replace that code with a JSONDecoder capable of converting the data you received on the line above into a Swift object. Add the following code in place of the existing print statement:
do {
let topResponse = try JSONDecoder().decode(TopResponse.self, from: data)
print(topResponse.name)
} catch {
print(error.localizedDescription)
}
The code above first creates a new instance of the JSONDecoder object. The decode method is called from that object, which takes two parameters. The first is the model object into which the data should be decoded. In your case, this is a TopResponse. The second parameter represents the raw data fetched from the network request. If this method is successful, it will return an object of the type you specified. However, the process of decoding data can throw an error.
For this reason, you must prefix the JSONDecoder with the try keyword. You wrap the operation in a do catch block to handle an error that can be thrown properly. In the do section, an attempt will be made to parse the data into a TopResponse. If successful, the code execution will continue to the following line. The code in the catch block will be executed if unsuccessful. For this initial purpose, you’ll print the name of the city that was searched in the console.
Later in this section, you’ll connect this data to the UI to display it appropriately. With that code in place, build and run your app. In the search bar, type your chosen city and tap Search on the keyboard. If everything is in place, you should see the name of the city you searched printed on the console.
Note: The city name printed in the console isn’t simply the text from the text field but a result of a network request made to the OpenWeatherMap API. That request data returned to your app is parsed and converted into a TopResponse object and the name and id property set as a result of the decoding process.
As an exercise, change the print statement from topResponse.name to topResponse.id and rerun your app. Enter the same city name and notice a different result in the console.
Now that you’ve parsed the id and name property, tackle the weather property. If you take another look at the JSON response, you’ll notice that the value of weather is an array of objects. This means that to parse this object, you need to create another structure that can represent this data. The project already has a WeatherCondition object to represent this data. Go to the WeatherCondition file in the project and replace WeatherCondition with the following code:
struct WeatherCondition: Decodable {
let id: Int
let categoryDescription: String
let iconString: String
}
The first property, id, uniquely identifies this weather condition. The categoryDescription property represents the type of weather, such as “clear sky” or “thunderstorm”. Notice that in the JSON, the property name is description, but you’ve marked the property name as categoryDescription in your struct. This is intentional. The description property is already used in Swift. You don’t want to conflict with that. The same goes for the iconString property. In the JSON, the property is marked as icon. There are several ways to address this problem. The simplest is to use an enum that conforms to String and CodingKey. You may name this enum whatever you want. However, a common convention is to call them CodingKeys. Within your WeatherCondition struct, add the following enum:
enum CodingKeys: String, CodingKey {
case id
case categoryDescription = "description"
case iconString = "icon"
}
The CodingKeys enum should include a case for every struct property that corresponds to its name, such as id. If the JSON key for a value differs from the case name, like categoryDescription, you can specify a custom raw value to map the data from the JSON key description to the property categoryDescription. With this code in place, you should now be able to complete the TopResponse object.
Navigate to the TopResponse file and add another property called weatherConditions of type [WeatherCondition]. Your TopResponse should now look like this:
struct TopResponse: Decodable {
let id: Int
let name: String
let weatherConditions: [WeatherCondition]
}
However, there’s one small problem. There’s no property called weatherConditions for the top-level response. The actual property in the JSON is called weather. Luckily, you just discovered how to use CodingKeys and realized you could use them here. Add the following code just below your weatherConditions property:
enum CodingKeys: String, CodingKey {
case id
case name
case weatherConditions = "weather"
}
Like before, notice that the weatherConditions case has a raw value of weather, allowing the data mapping to work. With that data in place, you can now parse weather conditions in your app. To validate this logic, open ContentView.swift. Where you print the topResponse.name or id, replace the old print statement with this new one:
print(topResponse.weatherConditions.first?.categoryDescription ?? "")
This code will first access the topResponse and then the first weather object in the array. From there, the categoryDescription property can be accessed. If you now build and run your app, you should be able to enter a city name into the search bar and see a printed description of the current weather conditions.
The last property to address is the main property. This property is also an object and requires a struct to represent it. The word main is not a very descriptive name so you could change to TemperatureData to be more descriptive. Navigate to the TemperatureData file and replace TemperatureData with the following code:
struct TemperatureData: Decodable {
let current: Double
let low: Double
let high: Double
enum CodingKeys: String, CodingKey {
case current = "temp"
case low = "temp_min"
case high = "temp_max"
}
}
In this scenario, you used more meaningful names, like current, low, and high, and use the CodingKeys enum to map those names back to their respective properties in the JSON data. Now open TopResponse.swift, and add the TemperatureData as a property. The final code should look like the following:
struct TopResponse: Decodable {
let id: Int
let name: String
let weatherConditions: [WeatherCondition]
let temperatureData: TemperatureData
enum CodingKeys: String, CodingKey {
case id
case name
case weatherConditions = "weather"
case temperatureData = "main"
}
}
With that code in place, you have all the necessary models to display the core information for your SwiftyWeather app. To validate that the parsing is happening correctly, navigate to the ContentView file and replace the print statement with something like:
print(topResponse.temperatureData.current)
After running your app, you should notice the current temperature in Fahrenheit printed on the console. This validates that your properties are properly set up and that the JSONDecoder can decode your objects.
Handling Errors Gracefully
When you’re making networking requests and parsing JSON data, there’ll be plenty of opportunities for error. The errors can show up in many forms. The two most common categories are errors related to networking, and errors associated with decoding or parsing. To cover these scenarios, you’ll implement a NetworkError enum that contains the error cases you want to cover. These cases are somewhat arbitrary based on the scenarios the code naturally leads into whenever you hit an else or error block. Navigate to the NetworkError file and replace NetworkError with the following code:
enum NetworkError: Error {
case invalidURL
case invalidResponse
case noData
case decodeError(message: String, error: Error?)
}
Each case above represents the following:
- invalidURL: This is for scenarios where a URL is improperly formatted and invalid. In the case of this app, because this URL is manually constructed, this would more likely be a developer error.
-
invalidResponse: This is for scenarios where the server responded with a status code outside of the
200to300range. - noData: This is for scenarios where the response contains no data.
- decodeError: For scenarios where the request and response were successful, however, decoding the data failed.
Now that there’s a dedicated object for handling the errors you could encounter, you can consider separating concerns and extracting certain logic into separate buckets. Currently, all network fetching is directly happening in your ContentView. You want to change this. To do so, you will implement the NetworkService file to create an object responsible for making the network request to fetch the data.
Before implementing the NetworkService, notice that it should conform to the WeatherFetchable protocol. The purpose of this protocol is to create a blueprint of how any given service should operate. In your specific scenario, an object capable of fetching weather information should have a function that allows it to do so. This function should take in the name of the city being requested, and when the request is complete, it should return the TopResponse object along with any NetworkError that would occur during the process. Add the following method to your WeatherFetchable file:
func fetchWeather(for cityName: String, completion: @escaping (Result<TopResponse, NetworkError>) -> Void)
With that in place, the compiler should now throw an error in the NetworkService file because it no longer conforms to the protocol. Add the fetchWeather method that was recently added to the WeatherFetchable protocol. The next step is providing a URLSession object to perform the request. To do this, add a private urlSession property to the network service like so:
private let urlSession: URLSession
Secondly, create an initializer for this service so that an instance of urlSession can be passed in. For ease of use, set the session’s default value to .shared so that you always have an instance of one without specifying it. The code would look like the following:
init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
Third, implement the body of the fetchWeather method to make the necessary request similar to what was done in the onSubmit modifier on ContentView. The first step is to prepare the URL:
guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(cityName)&units=imperial&appid=YOUR_APP_ID_HERE") else {
completion(.failure(.invalidURL))
return
}
Next, implement the dataTask(with:) method to perform the fetch:
urlSession.dataTask(with: url) { data, response, _ in
// handle response and data here.
}
When the request is complete, and you’ve parsed the response, you must update the user interface with the corresponding data. Naturally, when a network request is made, it happens on a background thread. To ensure the app remains performant for the user, user interface updates or changes must be made on the main thread. To ensure this, you’re going to force the response to be handled on the main queue with the following code inside the dataTask closure:
DispatchQueue.main.async {
// UI update code on the main thread here.
}
Next, you’ll process the response code coming back from the server. A good response code is anything between 200 and 300. Add the following code inside of your DispatchQueue:
guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
completion(.failure(NetworkError.invalidResponse))
return
}
Notice that in your failure case, you call the invalidResponse error set up beforehand. If the response is between 200 and 300, continue executing the remaining code. Now that you know your response is valid, check the data. If there’s no data, specify the error case .noData.
Add the following code underneath the response code:
guard let data else {
completion(.failure(.noData))
return
}
The final step in this method is to perform the decoding process to leave you with a TopResponse object. Here, you would use the JSONDecoder and call the decode method passing in an object that conforms to the Decodable protocol and the Data to decode. If it’s successful at decoding, it will return the decoded object to you for processing. If the decoding fails, several preset DecodingError cases can represent how and why the decoding failed.
Add the following code underneath the guard that unwraps the data:
do {
let topResponse = try JSONDecoder().decode(TopResponse.self, from: data)
completion(.success(topResponse))
} catch DecodingError.keyNotFound(let key, let context) {
completion(
.failure(
.decodeError(
message: "Could not find key \(key) in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch DecodingError.valueNotFound(let type, let context) {
completion(
.failure(
.decodeError(
message: "Could not find type \(type) in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch DecodingError.typeMismatch(let type, let context) {
completion(
.failure(
.decodeError(
message: "Type mismatch for type \(type) in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch DecodingError.dataCorrupted(let context) {
completion(
.failure(
.decodeError(
message: "Data found to be corrupted in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch {
completion(.failure(.decodeError(message: "Generic Decoding Error", error: error)))
}
The last and most crucial step, is to call resume() at the end of the data task. This will allow URLSession to make the request. Here’s the complete method to ensure you have all of the data:
func fetchWeather(for cityName: String, completion: @escaping (Result<TopResponse, NetworkError>) -> Void) {
guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(cityName)&units=imperial&appid=YOUR_API_KEY_HERE") else {
completion(.failure(.invalidURL))
return
}
urlSession.dataTask(with: url) { data, response, _ in
DispatchQueue.main.async {
guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
completion(.failure(NetworkError.invalidResponse))
return
}
guard let data else {
completion(.failure(.noData))
return
}
do {
let topResponse = try JSONDecoder().decode(TopResponse.self, from: data)
completion(.success(topResponse))
} catch DecodingError.keyNotFound(let key, let context) {
completion(
.failure(
.decodeError(
message: "Could not find key \(key) in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch DecodingError.valueNotFound(let type, let context) {
completion(
.failure(
.decodeError(
message: "Could not find type \(type) in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch DecodingError.typeMismatch(let type, let context) {
completion(
.failure(
.decodeError(
message: "Type mismatch for type \(type) in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch DecodingError.dataCorrupted(let context) {
completion(
.failure(
.decodeError(
message: "Data found to be corrupted in JSON: \(context.debugDescription)",
error: nil
)
)
)
} catch {
completion(.failure(.decodeError(message: "Generic Decoding Error", error: error)))
}
}
}
.resume()
}
Connecting Parsed Data to UI
Up to this point, you’ve parsed the necessary models and handled the potential errors that could come up. You’re finally able to show this data via the UI. Before you update the ContentView, you’ll implement the WeatherViewModel, which will be your layer updating the user interface. Navigate to the WeatherViewModel file and add the following properties and constructor:
private var networkService: WeatherFetchable
var topResponse: TopResponse?
var errorMessage: String?
var isSearching = false
init(networkService: WeatherFetchable = NetworkService()) {
self.networkService = networkService
}
The first property, networkService, is the object that makes the network request to fetch the data. Since the UI will be responsible for using this view model, this property doesn’t need to be publicly accessible. It’s an internal implementation detail. The topResponse property will contain the data that drives the UI. The errorMessage is responsible for publishing any error messages that must be displayed to the user. The isSearching Boolean is a flag that will let the UI know if a network request is happening so that you may show a loading indicator. Lastly, there’s the initializer to set up the network service.
The final part of this view model is to have a fetchWeather method that’s publicly accessible and triggers the network service to make its own fetchWeather request. Having this method at the viewModel layer allows you to set the error messages in the viewModel and publish those changes for the UI to react to.
Add the following fetchWeather implementation just below the initializer:
func fetchWeather(for cityName: String) {
isSearching.toggle()
networkService.fetchWeather(for: cityName) { [weak self] result in
guard let self else { return }
switch result {
case .success(let topResponse):
self.topResponse = topResponse
self.isSearching.toggle()
case .failure(let error):
switch error {
case .decodeError(let message, _):
self.errorMessage = "\(message)"
case .invalidResponse:
self.errorMessage = "Invalid City Name"
case .invalidURL:
self.errorMessage = "Invalid URL"
case .noData:
self.errorMessage = "No data"
}
self.isSearching.toggle()
}
}
}
With the WeatherViewModel complete, you’re ready to update the UI on the ContentView. Navigate to the ContentView file and add the following property between the body and errorMessage:
private var weatherViewModel = WeatherViewModel()
This creates a new instance of your WeatherViewModel that provides your UI with the necessary data. Now, you can begin to edit the UI.
First, inside the NavigationStack, locate the VStack and remove that code. When complete, you should have an empty NavigationStack that looks like this:
NavigationStack {
}
The overall goal of the user interface is to perform the following checks:
- Determine if
topResponseisnil. If you have data to display, display it. - If you don’t have data to display, display a loading spinner if you’re currently searching or performing a request.
- If you don’t have data to display and aren’t actively searching or fetching data, display a
ContentUnableViewwith an error message. - If there’s no error message, display instructions on how the user should search for a city to get weather data.
Now that you have a clear understanding of the logic needed add the following lines into the NavigationStack:
if let topResponse = weatherViewModel.topResponse {
// Weather data UI code here
} else {
// Progress and error state UI here
}
With that logic in place, you can add the user interface code to display the UI. The layout will consist of a vertical stack of content. The first item in that vertical stack will display a weather icon corresponding to the type of weather for that city.
The second item will be the name of the city.
The third item will be the current temperature.
The fourth item is a description of the weather.
Lastly, a horizontal stack of information will display that city’s high and low temperatures.
To ensure the data will be formatted correctly, you can use a helper property that rounds the temperature to a whole number. It’s called formattedTemp, and you’ll see it used below.
Add the following code in the top section where you get a successful topResponse:
VStack {
WeatherIconView(iconString: topResponse.weatherConditions[0].iconString)
Text(topResponse.name)
.font(.title)
Text(topResponse.temperatureData.current.formattedTemp)
.font(.largeTitle)
Text(topResponse.weatherConditions.first?.categoryDescription.capitalized ?? "")
.font(.title3)
HStack {
Text("H: \(topResponse.temperatureData.high.formattedTemp)")
.font(.headline)
Text("L: \(topResponse.temperatureData.low.formattedTemp)")
.font(.headline)
}
}
This code creates the logic mentioned above while setting the font sizes to ensure the UI has some hierarchy. In the else block, you must add the UI code that handles the errors and when the data is being fetched.
Add the following code to the else block:
if weatherViewModel.isSearching {
ProgressView()
} else {
VStack {
ContentUnavailableView(
errorMessage ?? "Please type a city above and press enter",
systemImage: "magnifyingglass"
)
}
}
You’ve now completed the UI code necessary for the app to work.
The last part will use the view model to fetch the weather. Your onSubmit modifier currently has the old code you used to make a basic fetch. In the previous section, you abstracted all that logic behind the NetworkService and WeatherViewModel. This allows the code to be much more modular and testable. Testing is outside this lesson’s scope, however, I suggest checking some of the content we have on the site.
To complete the fetch, delete all of the code inside of the onSubmit modifier and replace it with this code instead:
weatherViewModel.topResponse = nil
weatherViewModel.fetchWeather(for: searchText)
That code first clears out a response information that was previously there and then calls the fetchWeather(for:) method to fetch the weather data. Once that data is fetched, the view model will internally set the topResponse. Because the WeatherViewModel is Observable, the topResponse value is published, and you’re binding that property to your user interface. Your UI will refresh to reflect it as soon as the data is set.
You need to add one more small change to wrap up the app completely. This change is related to observing whenever the errorMessage is set or changed.
Add the following onChange modifier directly underneath the onSubmit modifier:
.onChange(of: weatherViewModel.errorMessage) { oldValue, newValue in
guard oldValue != newValue else {
return
}
self.errorMessage = newValue
}
That’s it! Build and run your app. Enter a city name, and notice the data displayed on your user interface!