March 2, 2023

Writing an Analytics Server using Vapor Part 4 - Dynamic Output

In the previous post, I created a UserEvent struct, which is expected by my one POST endpoint in my analytics server.

But as I look at the design, something stands out to me. The endpoint sends a response that is the same UserEvent that is passed in. This was great for development and testing, because I could verify that input matched output and that the server was handling the data as expected.

But in production, my client app isn't going to care about that output. The client app will simply send a UserEvent to the server. It doesn't care what the server does with it. All my client app will need to get back is a 200 status code and an empty string. Anything more than that is just a waste of network traffic.

Yet I have a problem: I've written all these tests that expect to get back a UserEvent. If I just change the output to an empty string, I'll lose the value of the tests. So I need a way for the endpoint to respond with a UserEvent sometimes and with an empty string other times. Probably the simplest approach would be to look for a special value in the request header. If the request header includes the key "verbose", then I can send back the UserEvent, but if it doesn't, then I can send back an empty string.

Vapor offers a way to alter a response before it's sent called Middleware (docs). I'll create a new middleware that checks the request header and decides what to send back based on its contents.

Adding A Middleware

I create a new file called HeaderCheckingMiddleware and add a new struct to it:

struct HeaderCheckingMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
        let response = try await next.respond(to: request)
        return response
    }
}

at the moment, HeaderCheckingMiddleware just passes the request down the chain and sends the response that it receives.

I add a controller to the file that I'll just use to test HeaderCheckingMiddleware. It will just respond to a single endpoint and it will send an Int as a response.

/// a controller that offers one GET route
/// used for testing HeaderCheckingMiddleware
struct HeaderCheckingMiddlewareTestsController {
    static var example: String { #function }
}

// MARK: - HeaderCheckingMiddlewareTestsController: RouteCollection

extension HeaderCheckingMiddlewareTestsController: RouteCollection {
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        let group = routes.grouped(.constant(Self.example))
        group.get { _ in 42 }
    }
}

I then add a new unit test module called HeaderCheckingMiddlewareTests and I add a test to make sure that the route works:

final class HeaderCheckingMiddlewareTests: XCTestCase {

    private var sut: Application!
    
    override func setUp() {
        sut = Application(.testing)
        try! configure(sut)
   }
    
    override func tearDown() {
        sut.shutdown()
    }

    func test_get_responds_with_200() throws {
        try sut.test(.GET, HeaderCheckingMiddlewareTestsController.example) { response in
            XCTAssertEqual(response.status, .ok)
        }
    }
}

and to make the route work, I register it in my routes.swift:

func routes(_ app: Application) throws {
    
    // only use routes from the UserEventController
    try app.register(collection: UserEventController())
    
    try app.register(collection: HeaderCheckingMiddlewareTestsController())
}

Changing the Response

So at the moment, a GET request to "middleware_example" will get back a response with the number 42. But I want the middleware to change the response to send back an empty string instead.

So I add a test case to verify that I get back an empty string:

func test_get_responds_with_empty_string_by_default() throws {
    try sut.test(.GET, HeaderCheckingMiddlewareTestsController.middleware_example) { response in
        XCTAssert(response.body.readableBytesView.isEmpty)
    }
}

and it fails.

So I change the respond() method to return a response with an empty string (keeping all other aspects of the response the same):

func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
    let response = try await next.respond(to: request)
    return Response(status: .ok, version: response.version, headersNoUpdate: response.headers, body: "")
}

and I alter the boot() method to use the middleware:

func boot(routes: Vapor.RoutesBuilder) throws {
    let group = routes
        .grouped(.constant(Self.middleware_example))
        .grouped(HeaderCheckingMiddleware())
    group.get { _ in 42 }
}

now the test passes.

Varying the Response Based on Request Headers

Now I want to have HeaderCheckingMiddleware check the request headers and only return an empty string if the header doesn't contain a certain value.

I alter the controller to create a middleware with some default values:

struct HeaderCheckingMiddlewareTestsController {
    static var middleware_example: String { #function }
    static var example_header_key: String { #function }
    static var example_header_value: String { #function }
    static let middleware =
        HeaderCheckingMiddleware(key: Self.example_header_key, value: Self.example_header_value)
}

extension HeaderCheckingMiddlewareTestsController: RouteCollection {
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        let group = routes
            .grouped(.constant(Self.middleware_example))
            .grouped(Self.middleware)
        group.get { _ in 42 }
    }
}

I add a test case:

private var exampleHeaders: HTTPHeaders {
    HTTPHeaders([(HeaderCheckingMiddlewareTestsController.example_header_key, HeaderCheckingMiddlewareTestsController.example_header_value)])
}
func test_get_responds_with_original_value_if_header_contains_required_key_and_value() throws {
    try sut.test(.GET, HeaderCheckingMiddlewareTestsController.middleware_example, headers: exampleHeaders) { response in
        let body = response.body.string
        XCTAssertEqual(body, "42")
    }
}

and I alter HeaderCheckingMiddleware by adding a key and value property:

struct HeaderCheckingMiddleware: AsyncMiddleware {
    
    let key: String
    let value: String

...    

and with all that, the test fails.

So now I alter HeaderCheckingMiddleware.respond(to request:, chainingTo:) to check the header:

    func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
        let response = try await next.respond(to: request)
        if let value = request.headers[key].first,
           value == self.value {
            return response
        }
        return Response(status: .ok, version: response.version, headersNoUpdate: response.headers, body: "")
    }

and now the test passes.

Using the Middleware in UserEventController

Now that I have the middleware working, it's time to use it in UserEventController. First, I add a couple static variables to hold the values I want to check against in the header:

struct UserEventController {
    static var userevents: String { #function }
    static var verbose: String { #function }
    static var verboseTrue: String { "true" }
}

Then I add the middleware to my boot() method:

func boot(routes: Vapor.RoutesBuilder) throws {
    let group = routes
        .grouped(.constant(Self.userevents))
        .grouped(HeaderCheckingMiddleware(key: Self.verbose, value: Self.verboseTrue))
    group.post(use: create)
}

The I add two new test cases to UserEventControllerTests.swift:

one to check that the response is empty by default:

    func test_responds_with_empty_string_by_default() async throws {
        let sent = UserEvent(action: .start, userID: exampleUserID)
        try await testPOST(sent.toByteBuffer()) { response in
            XCTAssert(response.body.readableBytesView.isEmpty)
        }
    }

and one to test that the response is not empty when the proper headers are sent:

    private var verboseHeaders: HTTPHeaders { HTTPHeaders(dictionaryLiteral:
        ("content-type", "application/json"),
        (UserEventController.verbose, UserEventController.verboseTrue)
    ) }

    func test_responds_with_values_if_verbose_is_true_in_headers() async throws {
        let sent = UserEvent(action: .start, userID: exampleUserID)
        try await testPOST(sent.toByteBuffer(),
                           headers: verboseHeaders
        ) { response in
            XCTAssert(!response.body.readableBytesView.isEmpty)
        }
    }

These tests pass, but there are 3 tests in this module that fail, because they explicitly test the UserEvent object returned from the endpoint. So I have to change them to send the new header:

    func test_post_responds_with_UserEvent_that_was_passed_in() async throws {

        let expected = UserEvent(action: .start, userID: exampleUserID)
        try await testPOST(expected.toByteBuffer(), headers: verboseHeaders) { response in
            let received = try JSONDecoder().decode(UserEvent.self, from: response.body)
            XCTAssertEqual(received, expected)
        }
    }
...
2 more tests

And now I have what I wanted. If the endpoint is sent a UserEvent, the UserEvent is handled and an empty string is sent back. That is, unless the headers contain the pair "verbose" and "true", in which case the original UserEvent is sent back. So I can have my tests, but also have a more efficient endpoint when I use the server in production.

Dealing with the Environment

Speaking of production, now I have a new problem. I've added a second controller and a second set of routes to the server, which means that the server has a whole new set of endpoints (okay, one endpoint) that it responds to. I needed this second controller in order to test the middleware, but it's not something I want exposed to the world when I deploy my server.

Vapor can let me alter the behavior of the server in development versus testing versus production using Environment (docs). I can use Environment to decide whether to allow the second endpoint or not depending on whether the server is being run in a production environment or just for testing.

So in routes.swift, I test against the environment to only use the HeaderCheckingMiddlewareTestsController when I'm testing.

func routes(_ app: Application) throws {
    
    // only use routes from the UserEventController
    try app.register(collection: UserEventController())
    
    if app.environment == .testing {
        try app.register(collection: HeaderCheckingMiddlewareTestsController())
    }
}

and now if I try to request the endpoint, I get a 404 response:

~ % curl localhost:8080/middleware_example
{"error":true,"reason":"Not Found"}%  

Summary

And so now, I can test my endpoint and make sure it gives me back what I sent it, but by default, the endpoint responds with an empty body. I believe that this route is done. I just need to start using it to interact with a databse.

Posts in this Series:

Tagged with: