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.

screen shot of push dialog with

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 UserEvents 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".

screen shot of Signing & Capabilities tab of XCode Project Settings

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 UserEvents, 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:

picture of client app's events tab showing a list of tabs

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.

screen shot of client app's events tab showing user count and event count are both 22

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.

screen shot of client app's events tab showing event count is greater than user count

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:

Tagged with: