March 1, 2023
Writing an Analytics Server using Vapor Part 3 - Setting up UserEvent
In the previous post, I created a new Vapor project for my analytics server, added a controller, and created a new test module for it. Now it's time for the endpoint to actually receive and interpret some data. After I have the endpoint receiving and sending back a real object, then I can add database support in the next post.
Review: What do I want in my data
This is an analytics engine. I want to know when a user performs certain actions, and maybe some information about that action. In my real use case, I have slightly different requirements, but for the purposes fo this blog series, I need to know:
- what action was performed
- when was it performed
- an id to uniquely identify this event (for the purposes of the database)
- which user performed this event (not anything about them, just so I can group actions by user)
- an extra Boolean flag, just because
adding a blank UserEvent
Vapor provides a protocol called Content
(docs) which represents a type that can be received and sent in a route. I create a new type that conforms to Content
called UserEvent
:
struct UserEvent: Content, Equatable {}
at the moment, it contains, no properties, but I just want to make sure that Vapor can receive and return it. So I change UserEventController
's create(req:)
method to read a UserEvent
from the request and return it instead of a String.
func create(req: Request) async throws -> UserEvent {
let event = try req.content.decode(UserEvent.self)
return event
}
and I replace the one test I had with 2 separate tests:
func test_post_responds_with_200() async throws {
try await testPOST(UserEvent(action: .start, userID: exampleUserID).toByteBuffer()) { response in
XCTAssertEqual(response.status, .ok)
}
}
func test_post_responds_with_UserEvent_that_was_passed_in() async throws {
let expected = UserEvent()
try await testPOST(expected.toByteBuffer()) { response in
let received = try JSONDecoder().decode(UserEvent.self, from: response.body)
XCTAssertEqual(received, expected)
}
}
You'll notice that these tests use some helpers that I wrote because I expect to be testing this endpoint a lot. Here they are:
private func testPOST(_ byteBuffer: ByteBuffer,
tests: (XCTHTTPResponse) async throws ->(),
file: StaticString = #filePath, line: UInt = #line) async throws {
try await sut.test(.POST, UserEventController.userevents, body: byteBuffer, afterResponse: tests)
}
and
fileprivate extension UserEvent {
func toByteBuffer() -> ByteBuffer {
try! JSONEncoder().encodeAsByteBuffer(self, allocator: .init())
}
}
But the tests won't pass because Vapor expects a content-type in the header in order to decode the request's body, so I adjust the helper method accordingly:
private var defaultHeaders: HTTPHeaders { HTTPHeaders(dictionaryLiteral: ("content-type", "application/json")) }
private func testPOST(_ byteBuffer: ByteBuffer,
headers: HTTPHeaders? = nil,
tests: (XCTHTTPResponse) async throws ->(),
file: StaticString = #filePath, line: UInt = #line) async throws {
try await sut.test(.POST, UserEventController.userevents, headers: headers ?? defaultHeaders, body: byteBuffer, afterResponse: tests)
}
and I add a test to verify that the headers are needed:
func test_post_responds_with_415_if_given_no_headers() async throws {
let expected = UserEvent(action: .start, userID: exampleUserID)
try await testPOST(expected.toByteBuffer(), headers: HTTPHeaders()) { response in
XCTAssertEqual(response.status, .unsupportedMediaType)
}
}
And now all tests pass.
adding a timestamp property
It's time to add properties to the UserEvent
type, and probably the most important property is the date and time when the event occured. It also turns out to be the msot troublesome to get right.
You see, there are many ways to represent a Date, and we have to be explicit about which one we want to use. The first time I wrote this blog post (😮💨) I went through all the steps of trying one approach, then finding that it gave a certain error, then trying a different approach, and on and on. Suffice it to say that:
- Swift's
Date
object likes to store dates as aTimeInterval
(Double
) representing the number of seconds since a certain reference date. - Most web applications like to pass dates as an ISO8601-formatted string
- Vapor tries to respect either approach
- I ran into some edge cases 🥺
So I ended up writing a separate Date
wrapper type that encodes and decodes a date as a TimeInterval. The time interval works for my needs since I don't intend for the content to be human-readable and all processing both in the server and in the client app will be written in Swift.
Along the way, there was an issue with the millisecond part of the date being lost in transit. This is totally expectable. When moving to the custom date wrapper, the issue disappeared, but it still makes sense to me to not worry about milliseconds in an analytics app, so I make sure that the date is always rounded to the nearest second.
struct InternalDate: Codable, Equatable {
let value: Date
init(_ date: Date) {
self.value = Date(timeIntervalSinceReferenceDate: date.timeIntervalSinceReferenceDate.rounded())
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let dateDouble = try container.decode(Double.self)
self.value = Date(timeIntervalSinceReferenceDate: dateDouble.rounded())
}
func encode(to encoder: Encoder) throws {
try value.timeIntervalSinceReferenceDate.encode(to: encoder)
}
}
I then added this as a property on UserEvent
:
struct UserEvent: Content, Equatable {
let timestamp: InternalDate
init() {
self.timestamp = InternalDate(Date())
}
}
The tests then pass.
I'm really yada-yada-yada-ing this. It took a long time and much testing to get things to work as intended. I'm sure that there's a better way, but this works and I have confidence it won't conflict with anything else I plan to do later on. So I kept it.
adding other simple properties
Adding the id
and flag
properties was simple and had no effect on the tests:
struct UserEvent: Content, Equatable {
let id: UUID
let flag: Bool
let timestamp: InternalDate
init(flag: Bool = false) {
self.id = UUID()
self.flag = flag
}
}
though it requried one more test to be added just to be safe:
func test_post_responds_with_UserEvent_with_same_flag_as_what_was_passed_in() async throws {
let sent = UserEvent(flag: true)
try await testPOST(sent.toByteBuffer()) { response in
let received = try JSONDecoder().decode(UserEvent.self, from: response.body)
XCTAssert(received.flag)
}
}
Adding the userID
property required slightly more work since it required changing the init
:
struct UserEvent: Content, Equatable {
let id: UUID
let userID: UUID
let flag: Bool
let timestamp: InternalDate
init(userID: UUID, flag: Bool = false) {
self.id = UUID()
self.userID = userID
self.timestamp = InternalDate(Date())
self.flag = flag
self.action = action
}
}
which required altering the tests to include a userID:
private var exampleUserID: UUID { UUID() }
func test_post_responds_with_200() async throws {
try await testPOST(UserEvent(action: .start, userID: exampleUserID).toByteBuffer()) { response in
XCTAssertEqual(response.status, .ok)
}
}
...
Adding the Action property
The action is slightly more involved. I want to only accept a certain subset of actions, yet I want the action to be sent to the endpoint as a string. So I create an Action
enum inside the namespace of the UserEvent
struct.
enum Action: String, Codable {
case start, pause, stop
}
and add a property and a parameter to the init
:
struct UserEvent: Content, Equatable {
let id: UUID
let userID: UUID
let flag: Bool
let timestamp: InternalDate
enum Action: String, Codable {
case start, pause, stop
}
let action: Action
init(action: Action, userID: UUID, flag: Bool = false) {
self.id = UUID()
self.userID = userID
self.timestamp = InternalDate(Date())
self.flag = flag
self.action = action
}
}
I of course update the tests to make sure that the action is being sent:
func test_post_responds_with_UserEvent_with_same_action_as_what_was_passed_in() async throws {
let sent = UserEvent(action: .pause, userID: exampleUserID, flag: true)
try await testPOST(sent.toByteBuffer()) { response in
let received = try JSONDecoder().decode(UserEvent.self, from: response.body)
XCTAssertEqual(received.action, sent.action)
}
}
and update the other tests as needed:
func test_post_responds_with_200() async throws {
try await testPOST(UserEvent(action: .start, userID: exampleUserID).toByteBuffer()) { response in
XCTAssertEqual(response.status, .ok)
}
}
...
But what if an unexpected string is passed in for the action. In order to test this, I need to write a test that passes the raw json to see what happens.
I start by writing a test that I know will work:
private var exampleValidUserEventProperties: [String:Any] {
[
"id": UUID().uuidString,
"userID": UUID().uuidString,
"timestamp": Date().timeIntervalSinceReferenceDate.rounded(),
"flag": true,
"action": UserEvent.Action.start.rawValue
]
}
func test_post_responds_with_200_if_given_valid_json() async throws {
let data = try JSONSerialization.data(withJSONObject: exampleValidUserEventProperties)
try await testPOST(ByteBuffer(data: data)) { response in
XCTAssertEqual(response.status, .ok)
}
}
I then write a test that sends good data for all keys except for the action to verify that I get a 400 status code:
func test_post_responds_with_400_if_given_unexpected_action() async throws {
var invalidActionProperties = exampleValidUserEventProperties
invalidActionProperties["action"] = "something unexpected"
let data = try JSONSerialization.data(withJSONObject: invalidActionProperties)
try await testPOST(ByteBuffer(data: data)) { response in
XCTAssertEqual(response.status, .badRequest)
}
}
Testing Against Raw JSON
This made me wonder, though, what would happen if extra data were sent in the json:
func test_post_responds_with_200_if_given_unexpected_extra_data_in_payload() async throws {
var propertiesWithExtraValues = exampleValidUserEventProperties
propertiesWithExtraValues["some_other_key"] = "some_invalid_value"
let data = try JSONSerialization.data(withJSONObject: propertiesWithExtraValues)
try await testPOST(ByteBuffer(data: data)) { response in
XCTAssertEqual(response.status, .ok)
}
}
or if a needed key were left out of the json:
func test_post_responds_with_400_if_not_given_userID_in_payload() async throws {
var propertiesWithMissingValues = exampleValidUserEventProperties
propertiesWithMissingValues.removeValue(forKey: "userID")
let data = try JSONSerialization.data(withJSONObject: propertiesWithMissingValues)
try await testPOST(ByteBuffer(data: data)) { response in
XCTAssertEqual(response.status, .badRequest)
}
}
...
And with that the UserEvent
struct is done. I don't yet save the data to a database, but I know that it is read in and returned appropriately, and I know that if good data is provided, the server will respond with a 400-type status code.
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