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:
- Part 1: Intro
- Part 2: Setting up the Development Environment
- Part 3: Setting up UserEvent
- Part 4: Dynamic Output
- Part 5: The Database
- Part 6: Retrieving Info About Users
- Part 7: Writing a Client
- Part 8: Prepopulating the Database
- Part 9: Filtering Requests
- server project on github