Managing URL construction for APIs in Swift

Managing URL construction for APIs in Swift

Managing URL construction for APIs in Swift

Part 2 of 3

Apr 26, 2025

Introduction

In part 1, we built a generic network manager responsible for making network calls and returning a decoded response. Here, in part 2, we will create a service class to define the particular API’s URL path and the function to fetch DW-NOMINATE scores.

As I mentioned in part 1, this example is meant to illustrate a scalable approach to working with multiple APIs and network calls.

Most public APIs have some sort of documentation detailing which endpoints return which data, as well as query parameters for filtering and sorting the response before it’s returned to you. There might also be an API key for authentication and a limit of how many calls you can make in a period of time.

To build our party ideology chart, our job is much simpler: there’s no documentation, no key, no query parameters. Just a predictable URL path. We can download the data we want from https://voteview.com/data:

There, we see 4 categories:

  • Data type, which is set to “Member ideology.” This data includes DW-NOMINATE scores for members of Congress.

  • Chamber, which is set to “Both (House and Senate).” This determines which chamber of congress is included.

  • Congress, which is set to “All” but can also specify any congress from the 1st to the current one, the 119th.

  • File format, which is set to “CSV” and is the recommended file type. The other type is JSON.

Although we can download a local file and bundle it with our app, this is not ideal as the data would have to be updated manually and would increase the size of the app. Luckily, there’s a better way. If we hover over the download button, we see "https://voteview.com/static/data/out/members/HSall_members.csv," suggesting that each CSV or JSON file actually has a specific, predictable, and publicly accessible URL. This small feature makes our lives much easier as developers as it means we can now fetch the most recent data whenever we want.

  • “https” is the URL scheme

  • “voteview.com” is the URL host

  • And “/static/data/out/members/HSall_members.csv” is the file path where

    • “members” refers to the data type we selected

    • “HS” refers to the chamber we selected

    • “all” refers to the congress we selected

    • and “csv” refers to the file format we selected

In part 1, I introduced the Endpoint protocol that defined the different parts of a URL—scheme, host, path, query items, HTTP method, and header. In our case, the scheme is always going to be “https” and the method will always be “GET.”

protocol Endpoint {
    var scheme: String { get }
    var host: String { get }
    var path: String { get }
    var queryItems: [URLQueryItem]? { get }
    var httpMethod: HttpMethod { get }
    var header: [String: String]? { get }
}

extension Endpoint {
    var scheme: String {
        return "https"
    }
}

enum HttpMethod: String {
    case get = "GET"
}

Now, we can create a service class to store an instance of the NetworkManager we made in part 1 and to hold these different URL components. We have an enum that conforms to our Endpoint protocol with one case, which I’m calling route. Complex REST APIs might require several different cases, but for Voteview, we just need to construct a URL composed of 4 different variables—data type, chamber, congress, and file type—so I just used route with the variables as associated values.

Each variable is represented by an enum with a raw value or property of type String corresponding to a portion of the URL we're constructing.

class DWNominateService {
    let manager = NetworkManager()
    
    enum DWNominateRoute: Endpoint {
        case route(DataType, Chamber, Congress, FileType)
        
        enum DataType: String {
            case memberIdeology = "members"
            case congressionalVotes = "rollcalls"
            case membersVotes = "votes"
            case congressionalParties = "parties"
        }
        
        enum Chamber: String {
            case houseAndSenate = "HS"
            case house = "H"
            case senate = "S"
        }
        
        enum Congress {
            case all
            case specificCongress(Int)
            
            var path: String {
			    switch self {
				case .all: return "all"
				case .specificCongress(let congress):
				    // the URL requires a three-digit Congress number, with preceding zeroes for numbers less than 100
					switch congress {
					case 1...9: return "00\\(congress)"
					case 10...99: return "0\\(congress)"
					default: return "\\(congress)"
				    }
			    }
		    }
        }
        
        enum FileType: String {
            case json = "json"
            case csv = "csv"
        }
    }
}

To finish conforming to the Endpoint protocol, we need to add a few more properties:

var host: String {
    return "voteview.com"
}

var httpMethod: HttpMethod {
    return .get
}

var header: [String : String]? {
    return nil
}

var path: String {
     switch self {
     case .route(let type,let chamber, let congress, let fileType):
         return "/static/data/out/\(type.rawValue)/\(chamber.rawValue)\(congress.path)_\(type.rawValue).\(fileType.rawValue)"
     }
}

var queryItems: [URLQueryItem]? {
    return nil
}

This service class can now construct several URLs associated with Voteview data and I typically have a service class for each API or website I'm fetching data from.

Now that we’ve abstracted away the URL construction, network call, decoding, and error handling, the actual function where we fetch these scores can be pretty small. We pass in the data type, chamber, congress, and file type and return an optional array of type DWNominateScore. This is the data model that we will decode to and use in our SwiftUI view. We'll create that model in part 3.

In the body of our function, we use the loadCSVData(endpoint:decodeTo:) method.

If we can choose to fetch our data as either CSV or JSON, why choose CSV? Two main reasons: (1) it’s the type that Voteview recommends and (2) the file size is much smaller, potentially making the network call that much faster. When downloading a local file of all 51,043 members of Congress since the US’s founding, the JSON file was 27.5 MB while the CSV file was just 6.2 MB.

We then pass in the endpoint DWNominateRoute.route(dataType, chamber, congress, fileType) and decode to [DWNominateScore].self.

func fetchDWNominateScores(dataType: DWNominateRoute.DataType, chamber: DWNominateRoute.Chamber, congress: DWNominateRoute.Congress, fileType: DWNominateRoute.FileType) async throws -> [DWNominateScore]? {
    return try await manager.loadCSVData(endpoint: DWNominateRoute.route(dataType, chamber, congress, fileType), decodeTo: [DWNominateScore].self)
}

And that's all we need for this class. For Voteview data, we only need this one function but you can imagine it's now quite scalable since each function here is pretty small. We are now ready to call this function from our view, which we'll do in part 3.