Interacting with Endpoints (Updated)

There is source code available for this project for fetching the available Categories for the TheMealDB available on GitHub.

In a past article I've discussed using a Data Transfer Object (DTO) to convert imperfect data from TheMealDB to a more easily modeled form. This time we'll look at how to interact with the remote server to retrieve data. This time we'll look at the various Categories of food available.

The calls you make to a webserver interact with what are called Endpoints. Each endpoint returns a different type of data. Many endpoints require some sort of parameters to be provided that specify that data to return. For example, to get a list of all the recipes in a category, you might pass "Desserts" as a parameter.

But what's the best way to specify which endpoint to request and the values to pass? You could have multiple different functions that take parameters and make similar calls, but is there an easier solution?

Introducing EndpointRequestType

My solution is to create an EndpointRequestType enumerator that has cases for each endpoint. Endpoints that require parameters include those with the individual case as associated values.

This approach is versatile as you can recreate a new EndpointRequestType for a new application without having to change the EndpointRequest class to do that actual data downloading. Likewise, adding new a endpoint is a matter of adding a new case to the enum.

Listing 1 contains a skeleton EndpointRequestType for the TheMealDB that was discussed in Working With the JSON You Get.

// 1
enum EndpointRequestType {
    case recipes(category: String)
    case recipe(id: String)
    case categories

// 2
    func urlRequest() -> URLRequest {
        // Coming soon
    }
}

Listing 1 - EndpointRequestType enum

Section 1 shows the EndpointRequestType that specifies three yet only one, categories, doesn't require a parameter, so no value is associated with the categories case. The categories case is the only one we'll be using for example code.

Notice, that the recipes case does have an associated value, the category of the recipes. That endpoint returns an array of all the recipes with the specified category. That category is passed as an associated value with the recipes case, in the form recipes(category: "Dessert"). The final case shown is the recipe(id: String) case which represents the endpoint that returns a specific recipe instruction (RecipeDetailDTO, specifically) for the provided id. (Technically, if you've read Working With the JSON You Get you'll know that it returns a RecipeDetailDTO that is then converted to a usable RecipeDetail object.)

Section 2 of the endpoint enumerator is a function called urlRequest() that creates and returns URLRequest that's used to query the backend. The URLRequest is what the server expects to receive when being queried to receive or send data.

The Listing 2 shows the implementation of the urlRequest() function.

func urlRequest() -> URLRequest {

// 1
    let headers = [
        "accept": "application/json",
    ]
// 2
    var urlComponents = URLComponents()
    urlComponents.scheme = "https"
    urlComponents.host = "themealdb.com"
    var queryItemsArray: [URLQueryItem] = []

// 3
    switch self {
    case .recipes(let category):
        urlComponents.path = "/api/json/v1/1/filter.php"
        queryItemsArray.append(URLQueryItem(name: "c", value: category))
    case .recipe(let id):
        urlComponents.path = "/api/json/v1/1/lookup.php"
        queryItemsArray.append(URLQueryItem(name: "i", value: id))
    case .categories:
        urlComponents.path = "/api/json/v1/1/categories.php"
    }

// 4
    urlComponents.queryItems = queryItemsArray
    var request = URLRequest(url: urlComponents.url!)
    request.allHTTPHeaderFields = headers
    request.httpMethod = "GET"
    return request
}

Listing 2 - EndpointRequestType

The function is broken down into four parts.

  1. The first part makes the array of required headers. For our needs, this is just telling the server that we want JSON to be returned.
  2. The second part constructs the details of the server that is to be contacted. Using URLComponents, the scheme and host are provided. The queryItemsArray is also created as an empty array so that the individual cases can add to it, and it is still in scope after the switch statement in Section 3.
  3. Section 3 is a switch statement that, depending on the entpoingRequestType that self represents will sets the urlComponents.path to the appropriate path for the specific endpoint. Then the URL query values are added and, where necessary, the associated values for the specific endpoint case are added to the URLQueryItem. For example if the EndpointResultsType is recipe(id: "4523") then the component path is set to "/api/json/v1/1/filter.php" and a URLQueryItem with the name I and value 'id' is created.
  4. Section 4 combines the URLComponents together to create the URLRequest. It takes the url, the component query items, the headers specified in step 1, and specifies an HTTPMethod of GET (which is appropriate for this else calls), and returns the value for the function.

Using this approach you write the following code to setup an endpoint and create the appropriate URLRequest for the categories endpoint.

let endpointType: EndpointRequestType = .categories.urlRequest

Now that we have a properly formatted endpoint, how can we use it?

Fetching from the Endpoint

Corresponding to the EndpointRequestType, I have a class called EndpointRequest. It is extremely simple, and in any practical use would need to be extended to do application appropriate actions for responses outside of the 200 - 299 range. But for this purpose, it works fine. The EndpointRequest class is shown in Listing 3.

final class EndpointRequest {

// 1
    func handleResponse(data: Data?, response: URLResponse?) -> Data? {
        let statusCodeRange = 200...299

        guard let data,
            let response = response as? HTTPURLResponse,
            statusCodeRange.contains(response.statusCode) else {
            return nil
        }
        return data
    }

//2
func downloadAsync(endpointRequest: EndpointRequestType) async throws -> Data? {
    do {
        let request = endpointRequest.urlRequest()
        let (JSONData, response) = try await URLSession.shared.data(for: request)
        let rawJSONData = handleResponse(data: JSONData, response: response)
        return rawJSONData
    } catch let error as NSError {
        print("Debug feedback \(#file): \(error.localizedDescription)")
        throw error
    }
}

Listing 3 - EndpointRequest.swift

In section 1, the function handleResponse, just ensures that the statusCode that's returned by the response is within the range we find acceptable. In production code this should throw in a manner that a few layer could provide more detailed information on what caused the error and however can recover, if possible. TheMealDB doesn't offer anything like that, so I'm ignoring it.

In section 2, the function downloadAsync converts the endpointRequestType to a URLRequest and then tries downloading it using URLSession.shared.data(for:). If the statusCode is acceptable it passes the data back to the calling function to decode the JSON.

Download and Decode Shortcut

Using the download function shown above, you'll need to handle the decoding from the Data object to JSON all over the application. Which is a bunch of boilerplate code.

What if we could do that in one shot? We can using generics, as shown in Listing 4.

func downloadAsyncAndDecode<T: Decodable>(_: T.Type, endpointRequest: EndpointRequestType) async throws -> T? {
    do {
        let request = endpointRequest.urlRequest()
        let (JSONData, response) = try await URLSession.shared.data(for: request)
        let rawJSONData = handleResponse(data: JSONData, response: response)
        if let rawJSONData {
            return try JSONDecoder().decode(T.self, from: rawJSONData)
        }
    } catch let error as NSError {
        debugPrint("Debug feedback \(#file): \(error.localizedDescription)")
        throw error
    }
    return nil
}

Listing 4 - downloadAsyncAndDecode

This combines the standard download code along with a call to JSONDecoder(). You pass the result type, for example Categories.self, as the first parameter, followed by the EndpointRequestType. The function returns the type based on the passing of the Categories.self that is the first parameter.

Listing 5 downloads the list of meal categories and converts them to Category objects by consuming the JSON. you to download and decode the JSON.

@MainActor
private func fetchCategories() async throws -> [Category] {
    let requestType: EndpointRequestType = .categories
    let retrievedCategories = try await EndpointRequest().downloadAsyncAndDecode(Categories.self, endpointRequest: requestType)
    if let retrievedCategories {
        return retrievedCategories.categories
    } 
    return []
}

Listing 5 - Using downloadAsyncAndDecode

The code in Listing 5 is ignoring any throws from the the loading or parsing, and your could should wrap that call in a do/catch and respond as appropriate. This code is only intended as a guide as to how to call the downloadAsyncAndDecode function.

For each request you create a new instance of the EndpointRequest. It is not a singleton. It maintains no state, so I don't believe there would be an advantage to changing it to be an actor.

What if an Endpoints Supports Paging?

Some endpoints support the concept of paging. You request a specific page of data, rather than getting all the results at once. None of the endpoints that TheMealDB provides supports paging. But paging is very easy to add to this approach using an EndpointRequestType.

Let's assume there is an endpoint called PagedRecipes that returns the recipes in the database for a specific category of food. However, it only returns 100 recipes at a time. In order to get more, you need to specify the page to read. We can enable that by creating a new case for the EndpointRequestType enum of the form shown in Listing 6.

    case pagedRecipes(category: String, page: Int)

Listing 6 - Example pagedRecipes case

By adding another switch case to the function that returns the URLRequest and have it append the page count to the query you'd have support for paged requests. Listing 7 shows an example of such a switch statement addition.

    case .pagedRecipies(let category, let page):
        urlComponents.path = "/api/json/v1/1/pagedRecipies.php"
        queryItemsArray.append(URLQueryItem(name: "c", value: category))
        queryItemsArray.append(URLQueryItem(name: "page", value: page))

Listing 7 - Example pagedRecipies case

Summary

This isn't the only way to handle endpoint requests, not by any means. But it does allow for easy extension, and combining the download and the decoding helps eliminate a step that otherwise is required at every calling location where you need to end capture JSON.

Of course, if have endpoints that return data other than JSON, you can opt instead to add the downloadAsync function in Listing 3. It uses the same endpoint pattern, but doesn't attempt to decode the values.

There is source code available for this project for fetching the available Categories for the TheMealDB available on GitHub.

I'd love to hear your comments. Feel free to reach out to me at @sanguish on Mastodon.

sanguish (@sanguish@iosdev.space)
6.27K Posts, 159 Following, 1K Followers · Searching for a macOS or iOS engineering position Ex-Apple DevPubs (12+yrs, remote). I’d go back. macOS/iOS engineer, & author. OCD-sufferer. Like any rational adult, I dislike mimes.
I'm looking for a full-time iOS or macOS senior position. Feel free to contact me or check out my Resume.