February 28, 2023

Writing an Analytics Server using Vapor Part 2 - Setting Up the Development Environment

In my previous post, I described the goals of this blog series. Put simply, I want to write a server in Vapor that I can use from my app to track when users use a certain feature. I'm writing a pared-down version of the server to accompany this blog series.

Installing Vapor

According to Vapor's Getting Started Page, installing should be a simple homebrew command.

I opened a new Terminal window and typed:

brew install vapor

and after a few minutes, I was able to access vapor:

joseph ~ % vapor --help        
Usage: vapor <command>

Vapor Toolbox (Server-side Swift web framework)

Commands:
       build Builds an app in the console.
       clean Cleans temporary files.
      heroku Commands for working with Heroku.
         new Generates a new app.
         run Runs an app from the console.
             Equivalent to `swift run Run`.
             The --enable-test-discovery flag is automatically set if needed.
  supervisor Commands for working with supervisord.
       xcode Opens an app in Xcode.

Use `vapor <command> [--help,-h]` for more information on a command.

Creating the Project

So I created a new folder for the server project, moved to it in my Terminal, and then typed:

vapor new Simple_Analytics -n

This created a new directory called "Simple_Analytics" inside the folder I had just created.

I moved into this directory:

cd Simple_Analytics

and listed the contents of the directory:

ls -al
total 40
drwxr-xr-x  11 joseph  staff   352 Feb 28 12:39 .
drwxr-xr-x@  3 joseph  staff    96 Feb 28 12:39 ..
-rw-r--r--   1 joseph  staff    18 Feb 28 12:39 .dockerignore
drwxr-xr-x  12 joseph  staff   384 Feb 28 12:39 .git
-rw-r--r--   1 joseph  staff    86 Feb 28 12:39 .gitignore
-rw-r--r--@  1 joseph  staff  2696 Feb 28 12:39 Dockerfile
-rw-r--r--@  1 joseph  staff  1201 Feb 28 12:39 Package.swift
drwxr-xr-x   3 joseph  staff    96 Feb 28 12:39 Public
drwxr-xr-x   4 joseph  staff   128 Feb 28 12:39 Sources
drwxr-xr-x   3 joseph  staff    96 Feb 28 12:39 Tests
-rw-r--r--@  1 joseph  staff   877 Feb 28 12:39 docker-compose.yml

So I don't understand everything going on, but I can see that it's set up as a Swift Package Manager package, it is already set up as a git repository, and it already has tests of some sort (though I'm not sure how they're set up).

a look at Package.swift shows the following:

// swift-tools-version:5.6
import PackageDescription

let package = Package(
    name: "Simple_Analytics",
    platforms: [
       .macOS(.v12)
    ],
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "Vapor", package: "vapor")
            ],
            swiftSettings: [
                // Enable better optimizations when building in Release configuration. Despite the use of
                // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release
                // builds. See <https://github.com/swift-server/guides/blob/main/docs/building.md#building-for-production> for details.
                .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
            ]
        ),
        .executableTarget(name: "Run", dependencies: [.target(name: "App")]),
        .testTarget(name: "AppTests", dependencies: [
            .target(name: "App"),
            .product(name: "XCTVapor", package: "vapor"),
        ])
    ]
)

So there is a separate Test target and there is only one dependency (on Vapor itself).

to open the project, I type:

open Package.swift

and a window appears showing the contents of the directory including a long list of packages. XCode immediately begins downloading them.

The documentation makes a big deal of choosing an appropriate run scheme, but My Mac was selected by default, and that makes the most sense to me.

A command-R begins the process of compiling the project, which is quite large. On my M1 macbook air it takes a minute or so.

And when it's done building, I see the following in the debug console:

[ WARNING ] No custom working directory set for this scheme, using [path to]/Developer/Xcode/DerivedData/Simple_Analytics-anwkjivypuzzzdcqpggarcfbiluh/Build/Products/Debug
[ NOTICE ] Server starting on http://127.0.0.1:8080

and opening a web browser and going to http://localhost:8080 gives me the result:

It works!

To make sure that things are editable, I opened Sources/App/routes.swift and added a third route:

func routes(_ app: Application) throws {
    app.get { req async in
        "It works!"
    }
    
    app.get("hello") { req async -> String in
        "Hello, world!"
    }
    
    app.get("howdy") { req async -> String in
        "Howdy y'all!"
    }
}

When I re-ran the project and visited http://localhost:8080/howdy, I saw:

Howdy y'all!

I then commented out all the routes that were there and tried again. When I visited http://localhost:8080/howdy again, I saw:

{"error":true,"reason":"Not Found"}

With the project in a state where it returns 404 for any route, I created a github repo for the project.

Setting Up Testing

I cancelled the running server and tried just hitting command-U to see what happens. XCode began a testing session, and because I had commented out all the routes, I almost immediately had a failed test.

...
Test Suite 'All tests' failed at 2023-02-28 13:09:18.767.
     Executed 1 test, with 2 failures (0 unexpected) in 3.805 (3.807) seconds
Program ended with exit code: 1

I uncommented the routes and hit command-U again, and all tests passed:

...
Test Suite 'All tests' passed at 2023-02-28 13:14:20.163.
     Executed 1 test, with 0 failures (0 unexpected) in 0.015 (0.017) seconds
Program ended with exit code: 0

So zero setup and we have tests working. Not bad, Vapor team.

But my app shouldn't respond to any endpoints but the ones I'm going to write, so I re-commented the other routes and changed the test in AppTests.swift to read:

    func test_hello_returns_404() throws {
        let app = Application(.testing)
        defer { app.shutdown() }
        try configure(app)

        // the server should respond with a 404
        // for any endpoint that isn't in
        // the controllers we add
        // so make sure that it does so for the original endpoint
        // that was there when the project was created
        try app.test(.GET, "hello", afterResponse: { res in
            XCTAssertEqual(res.status, .notFound)
        })
    }

and since I also don't want a request to the root to return anything, I added a test for that case:

    func test_root_returns_404() throws {
        let app = Application(.testing)
        defer { app.shutdown() }
        try configure(app)

        // a request to root should return a 404
        try app.test(.GET, "", afterResponse: { res in
            XCTAssertEqual(res.status, .notFound)
        })
    }

Immediately I noticed that I'm going to want to avoid having to repeat the setup and cleanup code for every test from here on out. I decided to change that for my own tests.

Adding A Controller

I reviewed the documentation about controllers and decided to use a controller to respond to the various analytics routes. This will be the only source of routes in the project.

I added a new test file called UserEventControllerTests and wrote a single test:

@testable import App
import XCTVapor

final class UserEventControllerTests: XCTestCase {

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

        try sut.test(.POST, UserEventController.userevents, afterResponse: { res in
            XCTAssertEqual(res.status, .ok)
            XCTAssertEqual(res.body.string, "")
        })
    }
}

I created a new file called UserEventController.swift in the Controllers folder and gave it the following content:

struct UserEventController {
    static var userevents: String { #function }
}

with that, I could compile the project and get a failing test for test_post_returns_200().

So I made UserEventController implement RouteCollection in the simplest way I could think of

extension UserEventController: RouteCollection {
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        let group = routes.grouped(.constant(Self.userevents))
        group.post(use: create)
    }
    
    func create(req: Request) async throws -> String {
        ""
    }
}

and I rewrote routes.swift to register UserEventController:

func routes(_ app: Application) throws {
    
    try app.register(collection: UserEventController())
}

and then the test passed.

I also want to make sure that a get to that endpoint fails unless it has a query attached, so I added a test for that:

    func test_get_with_no_query_returns_404() throws {
        
        try sut.test(.GET, UserEventController.userevents, afterResponse: { res in
            XCTAssertEqual(res.status, .notFound)
        })
    }

and I want to make sure that the userevents can't be deleted or edited through the API, so I added tests for those cases:

    func test_put_returns_404() throws {

        try sut.test(.PUT, UserEventController.userevents, afterResponse: { res in
            XCTAssertEqual(res.status, .notFound)
        })
    }

    func test_delete_returns_404() throws {

        try sut.test(.DELETE, UserEventController.userevents, afterResponse: { res in
            XCTAssertEqual(res.status, .notFound)
        })
    }

With that, I have a working, but essentially useless server with one endpoint. It's a good place to stop for now.

Posts in this Series:

Tagged with: