In yesterday's episode, I wrote about protecting your application's secretes with a proxy server. In today's episode, I show you how to set up a proxy server with Swift and Vapor.

While I use Swift and Vapor, the tech stack is of less importance and I encourage you to use whatever tech stack you feel most comfortable using. The goal of this episode is to dissect the anatomy of a proxy server with an example.

Protecting an API Key

I enjoy building weather applications, which is one of the reasons I often use a weather application as an example on Cocoacasts (e.g., Mastering MVVM With Swift and Mastering MVVM with SwiftUI). For the longest time, I used Dark Sky to fetch weather data from. Developers could sign up for a developer account and experiment with its API. It was a great fit for Cocoacasts. A few years ago, Apple acquired Dark Sky and its API was discontinued on March 31, 2023.

Like I said, I would ask developers to sign up for a Dark Sky developer account. While that worked fine, it was a hurdle I wanted to remove. The solution was to create a weather API, Clear Sky, developers could use without having to sign up for a Dark Sky developer account. The Clear Sky API still required an API key, but that was mostly to illustrate how to use an API key in an HTTP request and the API key also allowed me to protect the API. Why?

The Clear Sky API was nothing more than a wrapper around the Dark Sky API. If someone misused the Clear Sky API, I could simply rotate the Clear Sky API key without having to worry about a hefty bill from Dark Sky.

Let's take a peek under the hood of the Clear Sky API and break the implementation down line by line.

import Vapor

struct PublicClearSkyController {

    // MARK: - Helper Methods

    func forecastHandler(_ request: Request) async throws -> Response {
        guard
            let lat = try? request.query.get(String.self, at: "lat"),
            let long = try? request.query.get(String.self, at: "long"),
            let apiKey = try? request.query.get(String.self, at: "api_key"),
            apiKey == Environment.clearSkyAPIToken
        else {
            request.logger.error("Bad Clear Sky Request")
            throw Abort(.badRequest)
        }

        let response = try await request.application.client.get(
            .darkSkyURI(lat: lat, long: long)
        )

        return try await response.encodeResponse(
            status: response.status,
            for: request
        )
    }

}

fileprivate extension URI {

    static func darkSkyURI(lat: String, long: String) -> URI {
        URI(string: "https://api.darksky.net/forecast/\(Environment.darkSkyAPIToken)/\(lat),\(long)")
    }

}

The PublicClearSkyController struct is responsible for handling incoming requests sent to /clearsky. When a GET request is sent to /clearsky, the router invokes the forecastHandler(_:) method of the PublicClearSkyController struct.

The controller uses a guard statement to safely extract the query parameters, throwing a badRequest error if the GET request is invalid. The controller expects three query parameters, the coordinates for which to return weather data (lat and long) and the API key (api_key). The API key needs to match the value of the CLEAR_SKY_API_TOKEN environment variable.

guard
    let lat = try? request.query.get(String.self, at: "lat"),
    let long = try? request.query.get(String.self, at: "long"),
    let apiKey = try? request.query.get(String.self, at: "api_key"),
    apiKey == Environment.clearSkyAPIToken
else {
    request.logger.error("Bad Clear Sky Request")
    throw Abort(.badRequest)
}

This snippet could also be written as follows. Vapor throws a badRequest error if one of the query parameters is missing or invalid and the controller throws an unauthorized error if the API key is invalid.

let latitude = try request.query.get(Float.self, at: "lat")
let longitude = try request.query.get(Float.self, at: "long")
let apiKey = try request.query.get(String.self, at: "api_key")

guard apiKey == Environment.clearSkyAPIToken else {
    request.logger.error("Unauthorized Cleary Sky Request")
    throw Abort(.unauthorized)
}

If the request is valid, we create a URI object for the Dark Sky API request, using the values of the lat and long parameters of the original request. Notice that we pass the values of the lat and long parameters to the Dark Sky API as is.

let response = try await request.application.client.get(
    .darkSkyURI(lat: lat, long: long)
)

You may have noticed that the format of a Dark Sky API request is a bit unconventional. That is an oddity the Clear Sky API hides from the user.

fileprivate extension URI {

    static func darkSkyURI(lat: String, long: String) -> URI {
        URI(string: "https://api.darksky.net/forecast/\(Environment.darkSkyAPIToken)/\(lat),\(long)")
    }

}

When the Dark Sky API request completes, the controller encodes the Dark Sky API response into a Response object, propagating the status code of the Dark Sky API response.

return try await response.encodeResponse(
    status: response.status,
    for: request
)

This example is fairly basic so it should give you an idea of what a proxy server does and how it can be used. As I explained in yesterday's episode, a proxy server is flexible and the possibilities are endless.

The objective of the Clear Sky API was to hide the Dark Sky API key and that worked fine. The API key is defined as an environment variable and only known to the Vapor application at runtime. It isn't put under source control. I can freely share the Clear Sky API key and rotate it when needed.

Replacing the Dark Sky API

As I wrote earlier, the Dark Sky API was discontinued on March 31, 2023 and that meant that the Clear Sky API also stopped working on that date. That wasn't an issue thanks to the Clear Sky API. Because the Clear Sky API was nothing more than a proxy for the Dark Sky API, I could switch to another weather service with little effort.

To fill the void the Dark Sky API left, the Clear Sky API temporarily returned a static response. A few days later, I implemented a solution that generated a random response to make the API a bit more interesting. This is what the current implementation looks like.

import Vapor

struct PublicClearSkyController {

    // MARK: - Helper Methods

    func forecastHandler(_ request: Request) async throws -> Response {
        guard
            let latitude = try? request.query.get(Float.self, at: "lat"),
            let longitude = try? request.query.get(Float.self, at: "long"),
            let apiKey = try? request.query.get(String.self, at: "api_key"),
            apiKey == Environment.clearSkyAPIToken
        else {
            request.logger.error("Bad Clear Sky Request")
            throw Abort(.badRequest)
        }

        let headers = HTTPHeaders([
            ("Content-Type", "application/json; charset=utf-8")
        ])

        return try await ClearSkyResponse.build(
            time: Date(),
            latitude: latitude,
            longitude: longitude
        ).encodeResponse(
            status: .ok,
            headers: headers,
            for: request
        )
    }

}

The guard statement hasn't changed. The controller defines the headers for the response and creates a ClearSkyResponse object using the static build(time:latitude:longitude:) method. The ClearSkyResponse struct conforms to Vapor's Content protocol, which means we can encode it into a Response object. Because I didn't want to break applications that depended on the response format of the Dark Sky API, the response format of the Clear Sky API is identical to that of the Dark Sky API.

What's Next?

In yesterday's post, I explain in more detail what the possibilities of a proxy server are. The example I used in this episode is a basic one to dissect the anatomy of a proxy server. It gets more complicated once the proxy server is tasked with additional responsibilities, such as vending and rotating secrets.