Moving the directory application online

In the next version of the directory app we are going to convert the app into an online app that stores its directory data on a server instead of in a local file. One advantage of doing this is that users who have multiple devices will be able to log in to the directory app on all of their devices and will be able to share their data across multiple devices.

Converting the directory app into an online app will also give us a convenient excuse to see how to build a full-featured online app that works with a back end web service.

The back end

I have written a Spring Boot server application that will serve as the back end for our app. This application runs on a server I have set up at home, and is accessible via the https://cmsc106.net/directory URL.

The back end server stores two kinds of objects, users and people. The user class stores information about each user's login name and password, as well as a unique key string that the server will assign to each different user. The person class stores directory entries. Each person has an id number, a name, an office, and a user id that identifies the user who posted that person to their directory list.

Here is a detailed list of all of the Method/URL pairs the server understands and what each pair does.

POST https://cmsc106.net/directory/users is used to post a new user to the server. The body of the post must contain a JSON object with two properties: name and password. In response the server will send back a text string containing the api key for that user to use when interacting with the server.

GET https://cmsc106.net/directory/users?user=<name>&password=<password> is used by existing users to log in to the system. If the login is successful the server will send back a text string containing the API key for that user to use.

GET https://cmsc106.net/directory/people/<key> is used to fetch the list of people in a user's directory list.

POST https://cmsc106.net/directory/people/<key> is used to post a new directory entry for the user. The body of the post must contain a JSON object with two properties: name and office.

DELETE https://cmsc106.net/directory/people/<key>/<id> will delete the person with the given id number from the directory list of the user whose key is specified in the URL.

Data classes

The first step in writing an online application is to construct classes that represent the data we are going to be sending back and forth to the server. This application has two data classes, a DirectoryUser class and a Person class. Here is the code for those classes:

class DirectoryUser : Codable {
    var id : Int
    var name : String
    var password : String
    var key : String

    init() {
        id = 0
        name = ""
        password = ""
        key = ""
    }
}

class Person : Codable {
    var id : Int
    var name : String
    var office : String
    var user : Int

    init(name n : String, office o : String) {
        id = 0
        name = n
        office = o
        user = 0
    }
}

Both classes implement the Codable protocol, since we are going to need to translate these objects back and forth from JSON form. Also, the DirectoryUser class is going to get saved in a property list on the user's device so that the app can log us in automatically at start up.

In setting up the data classes it is very important that we name all of their properties with exactly the names that back end server will use for those properties and also assign types to those properties that match the types used on the server.

The new Directory class

Once again our app is going to have a Directory class that stores a list of Person objects. The big difference this time around is that the Directory class is no longer going to store those Person objects locally in a property list. Instead, any Person objects we create in the app are going to be immediately posted to the server. Any time the list changes, we will reload the list of people from the server.

The Directory class now also has a user property that stores a DirectoryUser object. That object is going to be backed up locally to a property list so we can log the user in automatically at app startup time.

The following two methods load and save the DirectoryUser object:

init() {
    do {
        let data = try Data(contentsOf: archiveURL)
        let unarchiver = PropertyListDecoder()
        user = try unarchiver.decode(DirectoryUser.self, from: data)
    } catch {
        user = DirectoryUser()
    }
    people = []
}

func saveUser() {
    do {
        let encoder = PropertyListEncoder()
        let data = try encoder.encode(user)
        try data.write(to: archiveURL)
    } catch {
    }
}

Creating a new user

When a user starts up the new directory app for the first time the app will display a login screen where they can either log in to an existing account or create a new account.

Here is the code for the method in the Directory class that handles setting up a new user:

func newUser(_ callback : @escaping () -> Void) {
    let jsonData = try? JSONEncoder().encode(user)
    let urlStr = "https://cmsc106.net/directory/users"
    let url = URL(string: urlStr)
    var request = URLRequest(url: url!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = jsonData
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard error == nil else {
            return
        }
        self.user.key = String(decoding: data!, as: UTF8.self)
        DispatchQueue.main.async {
            self.saveUser()
        }
        self.people = []
        DispatchQueue.main.async {
            callback()
        }
    }
    task.resume()
}

At the end of the closure that will get called after the request has been sent and our app gets a response from the server we also reset the list of people back to being empty. Another unusual feature of this method is that it allows the user to pass in a callback closure that will get executed once the post succeeds. Typically this action will refresh the list of people displayed in the app.

The code for logging in an existing user is similar. The only difference this time around is that we will have to also fetch that user's directory list as soon as we log in and get a key for that user:

func getKey(_ callback : @escaping () -> Void) {
    if !user.name.isEmpty, !user.password.isEmpty {
        let urlStr = "https://cmsc106.net/directory/users?user=\(user.name)&password=\(user.password)"
        let url = URL(string: urlStr)
        let task = URLSession.shared.dataTask(with: url!) { data, response, error in
            self.user.key = String(decoding: data!, as: UTF8.self)
            DispatchQueue.main.async {
                self.saveUser()
            }
            self.refresh(callback)
        }
        task.resume()
    }
}

A separate refresh() method handles getting the directory list from the server:

func refresh(_ callback : @escaping () -> Void) {
    if !user.key.isEmpty {
        let urlStr = "https://cmsc106.net/directory/people/" + user.key
        let url = URL(string: urlStr)
        let task = URLSession.shared.dataTask(with: url!) { data, response, error in
            if error == nil {
            self.people = try! JSONDecoder().decode([Person].self, from: data!)
            DispatchQueue.main.async {
                    callback()
                }
            }
        }
        task.resume()
    }
}

The next method handles adding a new person to the directory. To add a new person we post the person to the server, and then call refresh() to download the updated list of people back from the server.

func addPerson(_ p : Person,_ callback : @escaping () -> Void) {
    let jsonData = try? JSONEncoder().encode(p)
    let urlStr = "https://cmsc106.net/directory/people/\(user.key)"
    let url = URL(string: urlStr)
    var request = URLRequest(url: url!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = jsonData
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard error == nil else {
            return
        }
        self.refresh(callback)
    }
    task.resume()
}

Finally, here is the method for deleting a person. When we download the list of people from the server, each Person object will have an associated id number. If we can provide our key and this id number to the server, it will be able to find and delete that person from its database. As always, after making any change to our list we have to call refresh() to rebuild the list.

func removePerson(at n : Int,_ callback : @escaping () -> Void) {
    let toRemove = people[n]
    let urlStr = "https://cmsc106.net/directory/people/\(user.key)/\(toRemove.id)"
    let url = URL(string: urlStr)
    var request = URLRequest(url: url!)
    request.httpMethod = "DELETE"
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard error == nil else {
            return
        }
        self.refresh(callback)
    }
    task.resume()
}

Managing the log in process

Since we are now interacting with a server and we need to make sure that the user is logged in before doing anything, I have now modified the app to use a log in screen as its initial view. Here is the code for the controller class for this new screen:

class LoginController: UIViewController {
    var directory : Directory = Directory()
    @IBOutlet var user : UITextField!
    @IBOutlet var password : UITextField!

    @IBAction func newUser(_ source : UIButton) {
        directory.user.name = user.text!
        directory.user.password = password.text!
        directory.newUser {
            self.goToMain()
        }
    }

    @IBAction func login(_ source : UIButton) {
        directory.user.name = user.text!
        directory.user.password = password.text!
        directory.getKey {
            self.goToMain()
        }
    }

    func goToMain() {
        let storyBoard: UIStoryboard = UIStoryboard(name: "Main", bundle: nil)
        let mainViewController = storyBoard.instantiateViewController(withIdentifier: "Directory") as! UINavigationController
        let listView = mainViewController.viewControllers.first as! ViewController
        listView.directory = directory
        mainViewController.modalPresentationStyle = .fullScreen
        self.present(mainViewController, animated: true, completion: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        if !directory.user.key.isEmpty {
            directory.refresh {
                self.goToMain()
            }
        }
    }
}

The two action methods for the new user and log in buttons will need to call the appropriate methods in the Directory class to handle these two cases. Both of these methods allow us to pass in a closure that specifies what to do after completing the server interaction. In both cases we will want the closure to call our goToMain() method, which programatically navigates us to the main view in the app.

Any view controller in an app can display a new view by calling its present() method. All we have to do is to get a reference to the view we want to display and maybe do some setup before showing the new view. Views that we set up in the storyboard have an optional identifier. I gave the NavigationController an identifier of "Directory", so that is the indentifier I need to pass to the StoryBoards's instantiateViewController() method. Also, I need to access the first view in the navigation controller (which displays the list of people) and pass it a reference to the Directory object that I hold.

Finally, this login view also contains logic that allows the app to automatically bypass the login view and go to the main view if the user is already logged in. This simple logic is implemented in the viewDidLoad() method. This logic looks at the user in the Directory to see if the user has a key already. If they do, we can safely bypass the login process and go directly to the main view. We will still need to call the Directory's refresh() method to load the user's data, but once that is done we can go on the main view.

Homework assignment

I have now given you most of the code that you will need to construct the online version of the directory app. Since I made some important changes to the Directory class we will need to modify our code for the other two view controllers in the app. Your assignment is to convert the version of the Directory app you ended up with at the end of assignment two into a fully online version of the app. You will need to add the classes I provided above to the app, and also make some changes to the storyboard. You will need to add a login view to the app and arrange for that view to be the intial view in the app.

For your convenience, here is the complete code for the new version of the Directory class:

class Directory {
    private var people : [Person]
    var user : DirectoryUser

    let archiveURL: URL = {
        let documentsDirectories =
            FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        let documentDirectory = documentsDirectories.first!
        return documentDirectory.appendingPathComponent("user.plist")
    }()

    init() {
        do {
            let data = try Data(contentsOf: archiveURL)
            let unarchiver = PropertyListDecoder()
            user = try unarchiver.decode(DirectoryUser.self, from: data)
        } catch {
            user = DirectoryUser()
        }
        people = []
    }

    func getCount() -> Int {
        return people.count
    }

    func getPerson(at n : Int) -> Person {
        return people[n]
    }

    func saveUser() {
        do {
            let encoder = PropertyListEncoder()
            let data = try encoder.encode(user)
            try data.write(to: archiveURL)
        } catch {
        }
    }

    func newUser(_ callback : @escaping () -> Void) {
        let jsonData = try? JSONEncoder().encode(user)
        let urlStr = "https://cmsc106.net/directory/users"
        let url = URL(string: urlStr)
        var request = URLRequest(url: url!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = jsonData
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard error == nil else {
                return
            }
            self.user.key = String(decoding: data!, as: UTF8.self)
            DispatchQueue.main.async {
                self.saveUser()
            }
            self.people = []
            DispatchQueue.main.async {
                    callback()
                }
        }
        task.resume()
    }

    func getKey(_ callback : @escaping () -> Void) {
        if !user.name.isEmpty, !user.password.isEmpty {
            let urlStr = "https://cmsc106.net/directory/users?user=\(user.name)&password=\(user.password)"
            let url = URL(string: urlStr)
            let task = URLSession.shared.dataTask(with: url!) { data, response, error in
                self.user.key = String(decoding: data!, as: UTF8.self)
                DispatchQueue.main.async {
                    self.saveUser()
                }
                self.refresh(callback)
            }
            task.resume()
        }
    }

    func refresh(_ callback : @escaping () -> Void) {
        if !user.key.isEmpty {
            let urlStr = "https://cmsc106.net/directory/people/" + user.key
            let url = URL(string: urlStr)
            let task = URLSession.shared.dataTask(with: url!) { data, response, error in
                if error == nil {
                self.people = try! JSONDecoder().decode([Person].self, from: data!)
                DispatchQueue.main.async {
                        callback()
                    }
                }
            }
            task.resume()
        }
    }

    func addPerson(_ p : Person,_ callback : @escaping () -> Void) {
        let jsonData = try? JSONEncoder().encode(p)
        let urlStr = "https://cmsc106.net/directory/people/\(user.key)"
        let url = URL(string: urlStr)
        var request = URLRequest(url: url!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = jsonData
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard error == nil else {
                return
            }
            self.refresh(callback)
        }
        task.resume()
    }

    func removePerson(at n : Int,_ callback : @escaping () -> Void) {
        let toRemove = people[n]
        let urlStr = "https://cmsc106.net/directory/people/\(user.key)/\(toRemove.id)"
        let url = URL(string: urlStr)
        var request = URLRequest(url: url!)
        request.httpMethod = "DELETE"
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard error == nil else {
                return
            }
            self.refresh(callback)
        }
        task.resume()
    }

}

After adding in the new view and other code I have provided, you will need to go back through the code you wrote for the ViewController and PersonController classes and update that code to make use of the newly restructured Directory class that I provided.