ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Vapor. Sharing the code between an iOS app and... the server?

By Vyacheslav Vorona, iOS developer at UniFa.

Being an iOS developer, have you ever been in a situation when you were thinking: "Alright, I want to write this kind of app. Now!" But then you instantly realize that your pet-project would also need some kind of backend and you are too busy/lazy to learn all that "server stuff"... so your enthusiasm disappears as quickly as it popped up five minutes ago. Happens to me all the time. 😅

Luckily, there are technology choices allowing you to write your backend code using the language you are used to. I looked through some of them and decided to give Vapor a try. IMHO, it is risky to use Vapor in any big and serious project right now, but for hobby programming... it is perfect. Today I am going to take a closer look at it and I hope you will join me (don't forget tea and cookies ☕️).

What are the goodies?

  • You use your favorite Swift for coding
  • You are doing it in your favorite Xcode with all it's debugging tools available
  • Cmd + R and your server is up locally
  • Type checking during the compilation
  • An ability to have code/models shared between your iOS app and the backend

The last point is especially intriguing and provides some opportunities you wouldn't have with a backend written with any commonly used server-side programming language. Just imagine: you change your server code and an iOS app is already aware of it. Depending on the case your changes are going to be handled automatically (and you may not even need to change anything in your client code) or your app's compilation is going to fail letting you know something is wrong. Sounds like an attractive opportunity to me. Unfortunately, there is almost no example code implementing such solutions to be found on the Internet. So the only way we have is to try to implement it by ourselves, which is what we are going to do today.

Sounds cool! What do I need?

  • Vapor requires you to have Xcode 9.3+ and Swift 4.1+ (for this particular project we will need Swift 5.1 though)
  • We will also need Homebrew to install Vapor

Optionally: I am going to use Postman client to test API calls, but you may use any tool you like (or not use any at all).

That's it. Let's dive into it!

Setting up the projects

Alright, first things first, since we are going to have two projects sharing some code, we need to create a workspace in Xcode. I am going to call it SchrodingerApp.workspace. Got it? Schrodinger's cat is both dead and alive at the same time, and our code will be in the iOS app and the server at once... ah, never mind. 🙄

Next, let's download and install Vapor. Let me copy and paste some console commands from it's 'Hello, world' page for you...

Installing Vapor using Homebrew:

brew tap vapor/tap
brew install vapor/tap/vapor

Easy, right? Then I will run the command below in the same folder where I've put my workspace file.

vapor new SchrodingerAppServer

It will create a new folder with the name we entered, but if you look inside, you will notice it doesn't have an .xcodeproj file we are used to. Don't worry, Vapor can generate it for you:

cd SchrodingerAppServer
vapor xcode

When everything is done, it will suggest you to open Xcode, but we still need to put our new project into the workspace, so let's refuse for now. Open SchrodingerApp.workspace in Xcode and just drag-and-drop SchrodingerAppServer.xcodeproj from Finder to Xcode.

You may notice that now you have a scheme called 'Run'. Also, make sure you have your Mac selected as a device to build. Go on and run it! (just Cmd + R, as promised)

f:id:unifa_tech:20191125183937p:plain

In the Xcode console we see the message which says that our brand new server is up and waiting.

Server starting on http://localhost:8080

Since iOS Simulator uses the same network as the Mac it's beeing ran at, I will use the simulator to check the localhost. So, does our server work?

f:id:unifa_tech:20191125184059p:plain

Great!

We will take a closer look at the contents of our server app in a moment. But first, let's set up our client app to finish the boring part.

I am creating a new Single View iOS project named SchrodingerAppClient and pulling it's .xcodeproj file into our workspace. Make sure you have two schemes: one for the server and one for the client.

For the next step let's designate a place to put our shared code to.

Sharing is Caring

I consider the server app as a primary one, which means the actual files containing the shared code are going to be stored inside of it's project.

To do so I'm going to create a folder called SharedAPI ("New Group" in the Xcode) in SchrodingerAppServer/Sources/App. It will contain the code common for both of our apps.

Now I will go to the client project and add a "New Group without Folder" named SharedAPI into SchrodingerAppClient. This group is going to hold a reference to the SharedAPI folder we previously created for the server app. To add the reference select the newly created group, in the "File Inspector" click a tiny folder icon and search for a SharedAPI folder in the SchrodingerAppServer.

f:id:unifa_tech:20191125185426p:plain

Finally, we are ready to write some code! Yay! 🚀

Schrodinger's Model

I feel like our SchrodingerApp is missing something... A Cat data model, of course! I am adding a Cat.swift file into the SchrodingerAppServer/Sources/App/SharedAPI and here is a code for it:

final class Cat {
    var id: Int?
    var name: String
    var age: Int
    private var breedString: String

    var breed: Breed? {
        return Breed(rawValue: breedString)
    }

    init(id: Int? = nil, name: String, age: Int = 0, breed: Breed) {
        self.id = id
        self.name = name
        self.age = age
        self.breedString = breed.rawValue
    }
}

enum  Breed: String {
    case  persian = "persian"
    case  russianBlue = "russian blue"
    case  britishShorthair = "british shorthair"
}

I guess it is pretty straightforward for now, just a couple of things to note:

  • var id: Int? is in fact a requirement of the SQLiteModel protocol which I'm going to talk about in a second
  • I've declared private var breedString: String to see how Vapor is handling private properties

Getting Fluent

Vapor provides an Object-relational mapping framework called Fluent to work with databases. It supports several database drivers:

  • PostgreSQL
  • MySQL
  • SQLite
  • MongoDB

For our project, I decided to try out SQLite as it is simple and is just nice for prototyping.

To make Cat a full-fledged server-side model we have to adopt several protocols:

  • SQLiteModel. Allows your class to represent a table in an SQLite database. Its only requirement is to have var id: Int? which we already implemented in our model.
  • Migration. Allows us to perform dynamic migrations of our model (or revert them).
  • Content. Makes a model convertible to content of an HTTP message.
  • Parameter. Makes a model capable of being used as a Restful route parameter.

Latter three protocols do not have any requirements by default, we just need to declare the conformance.

But here is a thing: to do so, we need to import Vapor and import FluentSQLite which we obviously can't do on the iOS side. To make Cat model accessible from both server and client code, I'm going to use a small trick added in Swift 5.1. - canImport(). Add the code below right after the enum Breed in Cat.swift:

#if  canImport(Vapor) && canImport(FluentSQLite)

import FluentSQLite
import Vapor

extension Cat: SQLiteModel {}
extension Cat: Migration {}
extension Cat: Content {}
extension Cat: Parameter {}

#endif

This way Cat will conform to these four protocols only in case when Vapor and FluentSQLite are able to be imported. This means our data model is going to be treated differently: as an SQLite model on the server-side and just as a simple object on the client-side.

To make sure everything works I'm going to add a reference to Cat.swift into the SchrodingerAppClient and try running both schemas ("Run" for the server and "SchrodingerAppClient" for the client). If everything is set up correctly, both should compile successfully.

Two things left to do:

  • Add the Cat model to a MigrationConfig which is a Fluent structure managing database migrations in Fluent. To do it I will add the following line into the configure function located in configure.swift, right before services.register(migrations).
migrations.add(model: Cat.self, database: .sqlite)
  • (Optional) Right now the project we created doesn't persist any data between two launches of the server. To make it do so, we need to replace the this line in configure.swift
let sqlite = try SQLiteDatabase(storage: .memory)

with this

let sqlite = try SQLiteDatabase(storage: .file(path: "db.sqlite"))

So that the database is stored in a file.

We are done with the model. Now let's teach our apps to do something with it.

POSTing Cats

Server

In this project, we will implement an endpoint for our iOS app to POST new Cat models to the server via REST API. With Vapor it is really simple. Just find a file named routes.swift and add the code below to the bottom of the routes function located there:

router.post("api", "cat") { request -> Future<Cat> in
    return try request.content.decode(Cat.self).flatMap(to: Cat.self) { cat in
        return cat.save(on: request)
    }
}

Let's take a closer look at this code and see what it actually does.

router.post("api", "cat") registers a new route at the path /api/cat using the POST method. When a request is received, it's JSON gets decoded into Cat using Content protocol. Since decode(_:) returns a Future we need flatMap(to:) to unwrap a Cat when it is decoded. Then a Cat gets saved into the database using Fluent.

Now I`m going to run the server app and try out our new endpoint using Postman:

  • The URL is: http://localhost:8080/api/cat
  • My request's JSON is:
{
    "name": "Murka",
    "age": 5,
    "breedString": "russian blue"
}
  • Make sure to set Content-Type header to application/json

And here is the server response:

{
    "age": 5,
    "id": 1,
    "breedString": "russian blue",
    "name": "Murka"
}

As you can the breedString was handled properly (we made it private in the model, remember?). Also, note that Fluent have set an id for our freshly POSTed Cat.

Client

To POST some Cats from the iOS app I'm adding the method below right to the default ViewController.swift in SchrodingerAppClient:

private func post(_ cat: Cat) {
    let jsonData = try? JSONSerialization.data(withJSONObject: cat.json)
    var request = URLRequest(url: URL(string: "http://localhost:8080/api/cat")!)
    request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
    request.httpMethod = "POST"
    request.httpBody = jsonData

    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data, error == nil else { return }
        let responseJSON = try? JSONSerialization.jsonObject(with: data, options: [])
        if let responseJSON = responseJSON as? [String: Any] {
            print(responseJSON)
        }
    }
    task.resume()
}

I will not describe what's going on here in detail as I believe you understand it if you are reading this article. 😉 Just a couple of things:

  • Note that I am setting application/json as a value for Content-Type
  • Cat.json is just a simple mapping I've added to the model. It is a computed property of type [String: Any] representing a Cat instance to use in an API call

GETing Cats

Server

Next, I am going to add a GET endpoint which is going to be called by an iOS app to present a list of the Cats we've put into our Schrodinger's box. Again, with Vapor it us quite straightforward:

router.get("api", "cat") { request -> Future<[Cat]> in
    return Cat.query(on: request).all()
}

When the server receives a GET request at the /api/cat, it just queries all the Cats from the database and provides them to the client.

Testing new endpoint using Postman, here is the response:

[
    {
        "age": 5,
        "id": 1,
        "breedString": "russian blue",
        "name": "Murka"
    }
]

As you can see, we received an array containing one element - the Cat we added using POST endpoint.

Client

Here is some ugly code from the client-side to get a list of the cats for presentation. I've added it as a separate method into the ViewController.swift:

private func getCats() {
    var request = URLRequest(url: URL(string: "http://localhost:8080/api/cat")!)
    request.httpMethod = "GET"
    let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error **in**
        guard let data = data, error == nil else { return }
        let responseJSON = try? JSONSerialization.jsonObject(with: data, options: [])
        if let responseJSON = responseJSON as? [[String: Any]] {
            self?.cats = []
            for catJson in responseJSON {
                guard let cat = Cat.make(from: catJson) else { continue }
                self?.cats.append(cat)
            }
        }
    }
    task.resume()
}

The only thing worth explaining here is Cat.make(from:) method. It is just a static function decorating the initialization of Cat and able to return nil if some necessary data is missing.

Final preparations

User Interface I am adding some simple UI to test the features we implemented:

  • Three UITextFields to add new Cats via POST (yeah, there should be pickers or something similar, but this project not going to the AppStore, alright? 😅)
  • UITableView to display the Cats the client recieves via GET

Info.plist Another important thing to do is to modify Info.plist in SchrodingerAppClient as follows:

f:id:unifa_tech:20191125185650p:plain

We need to set NSAllowsArbitraryLoads key under the NSAppTransportSecurity dictionary to YES. Otherwise your iOS app will not be able to connect to the localhost.

Note that this is an anti-pattern and you should never do it in a real project. Apple is strongly against doing it and your app may be rejected from the AppStore.

But it's ok now, since we are just playing around. 😎

Now let's run our app and look at the results, even though they don't look as exciting as some mechanisms we implemented under the hood:

We can input some data for the new Cats, POST it... f:id:unifa_tech:20191125185756p:plain

... and then GET the Cats to see all of them in the list: f:id:unifa_tech:20191125185806p:plain

And all of it is done using a single Cat data model! 🎉

Conclusion

Let's be honest, I doubt Vapor (and backend Swift overall) is going to become a somewhat serious competitor for all those languages used for server-side development any soon. Yes, Swift is incredibly fast, has strict types and a bunch of other advantages, but it needs something more than just support from its community (even though they are awesome guys) to become a "real thing" .

BUT! Does it mean you shouldn't use it for your cute little pet-project you are writing every weekend with a beer in your hand? No! You definitely should. Finally, you can write everything from A to Z without leaving your comfort zone.

Besides, today we've learned a bit about some opportunities which mobile platform developers don't usually have. With Vapor you don't have to feel that you are writing bad code just to fit some weird architecture on the server-side. Your backend code influences your iOS app directly. And if you need to add something to the client, the server code is already aware of it. It's a great feeling, especially if you don't have much experience with backend.

I hope I could inspire you to give Vapor a try.

Happy coding! 😉

Project repo (make sure to check README)