March 5, 2023
Writing an Analytics Server using Vapor Part 7 - Writing a Client
In the previous post, I finished up writing the routes that would let me retrieve info about user events that had been sent to my analytics server. You can find the project on github.
So far, I've only used this "server" for testing its logic. It's not actually served any files to anything. It's time to write a client so I can make sure that this server will actually serve requests.
Sharing UserEvent
Since I'll be sending UserEvent
instances to the server from my client app, it makes sense that the client app should have access to UserEvent
. To make sure that the server and the client always have the same version of UserEvent
, I want to create a Swift Package (SPM) that I can share between the two projects.
But before I do that, maybe I can make UserEvent
a little simpler. Do I really need the InternalDate
type, or can I just use TimeInterval
wherever I need a Date
?
I make the change to UserEvent
:
/// stored as timeIntervalSinceReferenceDate
public let timestamp: TimeInterval
and add a test
func test_init_takes_timeIntervalSinceReferenceDate_for_timestamp() {
let now = Date()
let sut = UserEvent(date: now, action: .start, userID: UUID())
XCTAssertEqual(sut.timestamp, now.timeIntervalSinceReferenceDate)
}
This change requires a change to UserEventRecord:
@Field(key: .timestamp)
var timestamp: TimeInterval
and some other changes as well, but in the end everything compiles and all tests pass.
Now there's the question of access control. If UserEvent
is in a separate package, then only methods and properties marked as public
will be readable to client code. In this case I mark all properties as public
, because I want client code to have access to all properties.
There's one other change I have to make. Currently, UserEvent
is defined as a Content
type, which is a protocol from Vapor. I don't want to have to import all of Vapor into my client app. Luckily, it's easy enough to conform UserEvent to Content in a separate module. So I create a new module called UserEvent+Vapor.swift
and give it the following line:
extension UserEvent: Content {}
But the compiler complains that it can't auto-generate a conformance to Content
if it's declared in a separate file. The reason for this is that Content
expects to be a Decodable
, and UserEvent
isn't explicitly declared to be a Decodable
. I'll need UserEvent
to conform to both Decodable
and Encodable
in my client app anyway. This is an easy enough fix.
Now my UserEvent.swift
looks like this:
public struct UserEvent: Equatable, Hashable, Codable {
public let userID: UUID
public let flag: Bool
/// stored as timeIntervalSinceReferenceDate
public let timestamp: TimeInterval
public enum Action: String, Codable, CaseIterable {
case start, pause, stop
}
public let action: Action
public init(date: Date, action: Action, userID: UUID, flag: Bool = false) {
self.userID = userID
self.timestamp = date.timeIntervalSinceReferenceDate
self.flag = flag
self.action = action
}
public init(action: Action, userID: UUID, flag: Bool = false) {
self.init(date: Date(), action: action, userID: userID, flag: flag)
}
}
and it's ready to be put into a separate package.
I create a new Swift Package and move UserEvent.swift
and my one test into the new package. I create a new remote on github and I add a tag to the repository.
I then try to add my package to my project and... XCode can't find it.
I eventually, after too long, remember that XCode doesn't push tags to github unless you explicitly check the checkbox to push tags. I've never understood why this is a thing, but after I push again with the checkbox checked, I can finally load the SimpleAnalyticsTypes
package into my server project.
So now my Package.swift
looks like this:
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"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"),
.package(url: "https://github.com/jaywardell/SimpleAnalyticsTypes", .upToNextMajor(from: "1.0.0")),
],
targets: [
.target(
name: "App",
dependencies: [
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "Vapor", package: "vapor"),
.product(name: "SimpleAnalyticsTypes", package: "SimpleAnalyticsTypes"),
],
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"),
])
]
)
and I remove the UserEvent.swift
file from my project and add import SimpleAnalyticsTypes
to the top of any file that requires UserEvent
.
The project compiles and all tests pass.
Creating the Client App
The client app is going to be a single-window macOS app written using SwiftUI. I want to start with 2 tabs:
- a view where I can post a
UserEvent
to the server - a view where I can show a list of
UserEvent
s that have been posted to the server.
Eventually I'll want more than this, but this is a start.
So I create a new SwiftUI macOS project.
I add a PostView
view:
struct PostView: View {
var body: some View {
Button(action: post) {
Text("Post a Random Event")
}
}
private func post() {
print(#function)
}
}
and a EventListView
view:
struct EventListView: View {
struct Event: Hashable {
let date: Date
let action: String
let flag: Bool
let userID: String
}
let events: [Event]
var body: some View {
List(events, id: \.self) { event in
HStack {
Image(systemName: event.flag ? "checkmark.circle" : "circle")
Text(event.date, style: .date)
Text(event.date, style: .time)
Text(event.action)
}
}
}
}
I add them to ContentView
in a TabView
:
struct ContentView: View {
var body: some View {
TabView {
PostView()
.tabItem {
Label("Create Event", systemImage: "square.and.arrow.up")
}
EventListView(events: [])
.tabItem {
Label("Events", systemImage: "list.bullet")
}
}
.padding()
}
}
and I update the the App to only show one Window (see my blog post on this):
@main
struct SimpleAnalyticsClientApp: App {
var body: some Scene {
Window("Simple Analytics Test Client", id: "Main") {
ContentView()
}
}
}
Preparing to Send Data to the Server
I'll use URLSession.data(for:) async
to send the requests to the server, which means that I'll need to build a URLRequest
each time. To make life slightly easier, I have a couple extensions that I use all the time for this kind of thing:
I have an extension on URLRequest
which lets me create a URLRequest
in one line out of an URLComponents
:
extension URLRequest {
enum Method: String {
case get
case post
case put
case delete
var asString: String { rawValue.uppercased() }
}
init?(_ components: URLComponents, method: Method = .get, body: Data? = nil, headers: [(String, String)] = []) {
guard let url = components.url else { return nil }
self.init(url: url)
self.httpMethod = method.asString
self.httpBody = body
for header in headers {
self.setValue(header.1, forHTTPHeaderField: header.0)
}
}
}
and I have an extension on URLComponents
which lets me build an URLComponents
in a declarative style:
extension URLComponents {
enum Scheme: String {
case http, https, ftp, mailto
}
init(scheme: Scheme = .https, host: String, port: Int?) {
self.init()
self.scheme = scheme.rawValue
self.host = host
self.port = port
}
func addingPathComponent(_ string: String) -> URLComponents {
var out = self
out.path += "/" + string
return out
}
func addingPathComponent(_ int: Int) -> URLComponents {
addingPathComponent(String(int))
}
...
func addingPathExtension(_ string: String) -> URLComponents {
var out = self
out.path += "." + string
return out
}
func addingQueryItem(_ string: String, value: String? = nil) -> URLComponents {
var out = self
var queryItems = out.queryItems ?? []
queryItems.append(URLQueryItem(name: string, value: value))
out.queryItems = queryItems
return out
}
func addingQueryItem(_ string: String, value: Int) -> URLComponents {
addingQueryItem(string, value: String(value))
}
...
These are both available in the github repository for this blog post.
Sending a UserEvent
By default, Vapor will run the server locally on port 8080, so I need to request URLs on that port while I'm building the client app.
I add an extension on URLComponents
:
extension URLComponents {
static func testServer() -> URLComponents {
URLComponents(scheme: .http, host: "localhost", port: 8080)
}
}
In PostView.swift
, I write the code to send a POST request to the 'userevent' endpoint. To start off, I just want to see if the request is sent and I get back a 200 status code.
private func post() {
Task {
let components = URLComponents.testServer()
.addingPathComponent("userevent")
let request = URLRequest(components, method: .post, body: nil)!
do {
let (_, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode
print("status code: \(String(describing: statusCode))")
}
catch {
print(error)
}
}
}
I run the project and hit the "Post a Random Event" button and get back a long list of errors in my log, the first of which is:
2023-03-05 10:17:30.396124-0500 SimpleAnalyticsClient[45221:1219798] [] networkd_settings_read_from_file Sandbox is preventing this process from reading networkd settings file at "/Library/Preferences/com.apple.networkd.plist", please add an exception.
I go to the "Signing & Capabilities" section of my Project Settings and turn on "Outgoing Connections".
I run the app again, hit the button, and get the following in my log:
status code: Optional(415)
I suspect that this is because I'm not actually sending a UserEvent
in the request, so I put a breakpoint on UserEventController.add(request:)
and hit the button again. The debugger stops at the line:
let event = try request.content.decode(UserEvent.self)
and when I try to step over, the console for the server project outputs:
[ WARNING ] Abort.415: Unsupported Media Type [request-id: 2573D67A-765C-4BFD-927A-756C4ADDA2AF]
Yup, I forgot to send the UserEvent in the body, so I add my SimpleAnalyticsTypes
package to my project, import it into PostView.swift
, and create a new UserEvent
in the post()
method.
private func post() {
Task {
do {
let event = UserEvent(action: .allCases.randomElement()!, userID: UUID(), flag: .random())
let payload = try JSONEncoder().encode(event)
let components = URLComponents.testServer()
.addingPathComponent("userevent")
let request = URLRequest(components, method: .post, body: payload)!
let (_, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode
print("status code: \(String(describing: statusCode))")
print("response: \"\(String(data: data, encoding: .utf8) ?? "no data")\"")
}
catch {
print(error)
}
}
}
I run the app again, hit the button, and get back in the console:
status code: Optional(400)
After some exploring, I realize that I didn't send the content-type
header in the URLRequest
, so I make that change:
let components = URLComponents.testServer()
.addingPathComponent("userevent")
let request = URLRequest(components, method: .post, body: payload, headers: [
("content-type", "application/json"),
] )!
Now I run the app again, and when I press the button this time, nothing seems to happen. The status code is never printed. I try adding the verbose
header and then everything seems to work:
let request = URLRequest(components, method: .post, body: payload, headers: [
("content-type", "application/json"),
("verbose", "true")
] )!
But why wasn't it working without that header? I thought that I had set things up so that an empty string and 200 code would be sent back if the verbose
header wasn't set. Instead, the response seems to disappear into the void.
I do some experimentation, and I realize that my middleware can't send back a different response from the one that it receives. So instead of creating a new response, I need to modify the body of the one I get back. I change HeaderCheckingMiddleware.respond(to:chainingTo:)
to the following.
func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
let response = try await next.respond(to: request)
let hasExpectedHeader = request.headers[key].first == self.value
response.body = hasExpectedHeader ? response.body : defaultBody
return response
}
I re-run the tests on the analytics server and they all pass.
I try running the client with and without the verbose header, and I get back the expected 200 status code with the expected data each time.
Retrieving UserEvents
To retrieve the UserEvent
s, I add a task to the EventListView
:
var body: some View {
List(events, id: \.self) { event in
HStack {
Image(systemName: event.flag ? "checkmark.circle" : "circle")
Text(event.date, style: .date)
Text(event.date, style: .time)
Text(event.action)
}
}
.task(retrieveEvents)
}
@Sendable private func retrieveEvents() async {}
and to start off, I just make a request to userevents/count
to see what it will give me.
@Sendable private func retrieveEvents() async {
do {
let components = URLComponents.testServer()
.addingPathComponent("userevents/count")
let request = URLRequest(components)!
let (data, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode
print("status code: \(String(describing: statusCode))")
print("response: \"\(String(data: data, encoding: .utf8) ?? "no data")\"")
}
catch {
print(error)
}
}
I run the app, hit the "Post a Random Event" button a few times, and then switch to the "Events" tab, and the following appears in my console:
status code: Optional(200)
response: "20"
But what I really want is to list the events in the view, so I have to request the userevents
endpoint and decode them from the response:
@Sendable private func retrieveEvents() async {
do {
let components = URLComponents.testServer()
.addingPathComponent("userevents")
let request = URLRequest(components)!
let (data, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode
print("status code: \(String(describing: statusCode))")
print("response: \"\(String(data: data, encoding: .utf8) ?? "no data")\"")
let userevents = try JSONDecoder().decode([UserEvent].self, from: data)
events = userevents.map {
Event(date: Date(timeIntervalSinceReferenceDate: $0.timestamp),
action: $0.action.rawValue.capitalized,
flag: $0.flag,
userID: $0.userID.uuidString)
}
}
catch {
print(error)
}
}
but of course I have to make events a @State
property and add a custom init:
@State private var events: [Event]
init(events: [Event]) {
self.events = events
}
and just like that the Events tab shows the events that I have created:
Adding User Count
It's easy enough to retrieve the user count from the database. Just send a request to users/count
:
@Sendable private func retrieveUserCount() async {
do {
let components = URLComponents.testServer()
.addingPathComponent("users/count")
let request = URLRequest(components)!
let (data, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode
print("status code: \(String(describing: statusCode))")
print("response: \"\(String(data: data, encoding: .utf8) ?? "no data")\"")
userCount = try JSONDecoder().decode(Int.self, from: data)
}
catch {
print(error)
}
}
And the same thing can be done for getting the number of events, just sending to userevent/count
.
Yes, I could get this from the list of events returned in retrieveEvents()
, but the purpose of this client right now is to test that the server works, so I'll do it this way.
To display these, I'll need to add @State
variable and display it in the UI. I'll do the same for showing the count of user events:
struct EventListView: View {
struct Event: Hashable {
let date: Date
let action: String
let flag: Bool
let userID: String
}
@State private var events: [Event]
@State private var userCount = 0
@State private var eventCount = 0
init(events: [Event]) {
self.events = events
}
var body: some View {
VStack {
List(events, id: \.self) { event in
HStack {
Image(systemName: event.flag ? "checkmark.circle" : "circle")
Text(event.date, style: .date)
Text(event.date, style: .time)
Text(event.action)
}
}
HStack {
Text("\(eventCount) events")
Spacer()
Text("\(userCount) users")
}
}
.task(retrieveEvents)
.task(retrieveUserCount)
.task(retrieveEventCount)
}
Now since every time I post to userevent
with a new event I create a new userID, I expect that the number of users and number of events should be the same, and they are.
So I change PostView to use a constant userID property:
struct PostView: View {
let userID = UUID()
...
private func post() {
Task {
do {
let event = UserEvent(action: .allCases.randomElement()!, userID: userID, flag: .random())
...
and now I get more events than users, which is what I would expect.
What's Next?
The server was written to allow for filtering of events and users (based on what events they have posted). The client app doesn't do any of that yet.
So the next step is to make the client send a query to filter events.
But that's for next time.
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
- client app on github