Home · Server-Side Swift Tutorials

Server-Side Swift with MongoDB: Getting Started

In this Server-Side Swift tutorial you will learn how to setup MongoDB and use MongoKitten to run basic queries, build Aggregate Pipelines and store files with GridFS.

4.8/5 5 Ratings

Version

  • Swift 5, macOS 10.15, Xcode 11

MongoDB is a document-oriented database server. It does not use the SQL syntax for queries and does not enforce a schema. For this reason, MongoDB classifies as a NoSQL database.

By design, MongoDB resembles how applications store information.

By the end of this tutorial, you’ll know how to set up MongoDB and run basic queries using MongoKitten in Server-Side Swift. You’ll also learn how to use GridFS and Aggregate Pipelines, two powerful features in MongoDB.

Getting Started

Use the Download Materials button at the top or the bottom of this tutorial to download the files you’ll need for this tutorial.

Note: You’ll need the following for this project:
  • Xcode 11 and Swift 5.2.
  • Docker: If you don’t have Docker yet, visit Docker install for Mac.
  • Access to a MongoDB server: You can either set one up on your machine or make use of a cloud service such as MongoDB Atlas.

The project in the starter folder uses the Swift Package Manager. It consists of a Vapor application with a Leaf website.

To begin, double-click Package.swift in the starter folder. Wait as Xcode opens the file and downloads all the project’s dependencies.

Next, expand the Sources/App folder to see the files you’ll modify for this project. Note that the project follows the standard Vapor hierarchy.

Setting Up MongoDB

As noted above, you’ll need to have access to a MongoDB server for this project. Once you do, open Terminal and navigate to the starter project’s directory. From within this directory, execute the following command to set up MongoDB.

docker-compose up

This command reads docker-compose.yaml and uses that configuration file to set up a Replica Set. A Replica Set is a group of servers that maintains the same data set. Each Replica Set member should be a different machine. The setup may take a few minutes.

The members of this replica set are three servers and one arbiter. The arbiter is necessary for the stability of a cluster, should one of the other members go down.

The three servers expose themselves at ports 27017, 27018 and 27019. The default port for MongoDB is 27017.

Connecting to MongoDB

Before creating a connection to a deployment, you need to create a connection string URI. Open another Terminal window, cd to your project folder and run the following commands:

cd # <Drag the 'starter' folder in here>
nano .env # This opens an editor

# Add the following line to this file:
MONGODB=mongodb://localhost:27017,localhost:27018,localhost:27019/socialbird

# save the file by pressing ctrl-o
# and exit using ctrl-x
Note: Be sure to include the leading dot in .env. Filenames with a leading dot may not be visible in Finder, but the Terminal command ls -a lists it.

You’ve created a file named .env to store your environment values and stored your connection string URI in the environment value MONGODB.

Piece by piece, here’s how you’ve constructed the URI:

  • To connect to the local cluster, you used a standard connection string. This format starts with mongodb://.
  • After this, you would put the relevant credentials formatted as <username>:<password>@. However, the cluster set up by Docker Compose does not use authentication.
  • Next, you added the hosts, separated by commas. All three servers expose themselves on localhost. By supplying all replica set hosts, MongoKitten can take advantage of high availability.
  • Finally, you added /socialbird to specify the selected database. A single deployment has many databases, each database serving a single application.
Note: There are two formats for a connection string. The Standard Format supports all deployments, unlike the DNS Seedlist format. DNS Seedlist formats start with mongodb+srv:// and connect to cloud hosted clusters.

Creating a Connection

Now that you’ve created the connection string, it’s time to connect your application.

First, close the project then reopen it by double-clicking Package.swift. Wait for Xcode to resolve all dependencies specified in Package.swift. After that, make sure that the selected scheme is SocialBird and the destination is My Mac.
Note the scheme and destination on the left side of the window

Next, Option + Click the scheme to edit it. In the Options tab, enable Use custom working directory. Click the folder icon to set this to the project folder of your project. This is necessary for Vapor to find your .env file and the Leaf templates used by this project.

Now open Sources/App/MongoKitten+Application.swift to see how the project uses MongoKitten with Vapor. The file contains the following code:

import Vapor
import MongoKitten

// 1
private struct MongoDBStorageKey: StorageKey {
  typealias Value = MongoDatabase
}

extension Application {
  // 2
  public var mongoDB: MongoDatabase {
    get {
      // Not having MongoDB would be a serious programming error
      // Without MongoDB, the application does not function
      // Therefore force unwrapping is used
      return storage[MongoDBStorageKey.self]!
    }
    set {
      storage[MongoDBStorageKey.self] = newValue
    }
  }
  
  // 3
  public func initializeMongoDB(connectionString: String) throws {
    self.mongoDB = try MongoDatabase.connect(connectionString, on: self.eventLoopGroup).wait()
  }
}

extension Request {
  // 4
  public var mongoDB: MongoDatabase {
    // 5
    return application.mongoDB.hopped(to: eventLoop)
  }
}

The code above:

  1. Defines a storage key associated with MongoDatabase.
  2. Adds a getter and setter on a Vapor Application to provide a MongoDB connection.
  3. Connects to MongoDB and stores the connection in the Application.
  4. Accesses the application’s MongoDB connection.
  5. Changes the connection handle to reply on the Request EventLoop. This is a critical step that, if omitted, will crash your application.

Configuring the Application

To finish setting up the connection, add the following code to App.swift above the return statement.

// 1
guard let connectionString = Environment.get("MONGODB") else {
  fatalError("No MongoDB connection string is available in .env")
}

// 2
try app.initializeMongoDB(connectionString: connectionString)

// 3
try createTestingUsers(inDatabase: app.mongoDB)

Here’s what this code does:

  1. Reads the connection string from the created .env file.
  2. Initializes the connection to MongoDB.
  3. Creates an initial dataset containing users and posts.

Your connection is now ready! Build and run and you’ll connect to MongoDB. You should see the following console output:

Server starting on http://127.0.0.1:8080

Visit the app in your web browser and you’ll see the login page.

Login page of the application. You can't login yet

Logging In

This application is already configured to handle password hashing and authorization using JWT. The entire application relies on Repository, in Repository.swift, for database operations.

Stop the app. Before you can enable logging in, you need to add the code for fetching users. Open User.swift and take note of the User type.

User has a static property containing the collection name for this model. This prevents you from mistyping the name.

_id holds the model’s identifier. MongoDB requires the use of this key. Unlike most databases, MongoDB does not support auto-incrementing for integers. The type of identifier used is ObjectId, which is both compact and scalable.

Next, you’ll see that MongoDB allows you to store information in the database exactly like your Swift structs. The profile and credentials fields contain grouped information.

Finally, the user stores an array of identifiers in following. Each identifier refers to another user that this user follows.

Fetching a User

To log into the application, the login route in Routes.swift makes a call to findUser(byUsername:inDatabase:) in Repository.swift. Within each of the repository’s methods, you have access to the MongoDB database.

First, select the collection containing users, using a subscript. In Repository.swift within Repository, add the following line to findUser(byUsername:inDatabase:), before the return statement:

let usersCollection = database[User.collection]

Here, you’re subscripting the database with a string to access a collection. Also, notice that you don’t need to create collections. MongoDB creates them for you when you need them.

To query the collection for users, you’ll use a MongoCollection find operation. And because the repository is looking for a specific user, use findOne(_:as:).

Find operations can accept an argument for the filters that the result has to match. In MongoKitten, you can represent the query as a Document or as a MongoKittenQuery. MongoKittenQuery is more readable but does not support all features.

To create a document query, replace the return statement below the usersCollection line with the following code:

return usersCollection.findOne(
  "credentials.username" == username,
  as: User.self
)

To query the username, you first refer to the value by the whole path separated by a dot ( . ). Next, you use this keypath with the == operator to create an equality filter. Finally, you provide the value to compare to.

If a document matches the provided filter, MongoKitten will attempt to decode it as a User. After this, you return the resulting User.

To check that it works, build and run the application again.

You already have an example user from the first time the application launched. The username is me and the password is opensesame.

Visit the app in your browser and log in using the credentials above.

If you see the following error, you’re logged in!

{"error":true,"reason":"unimplemented"}

The thrown error is formatted as json

Stop the app.

Loading the Feed

Now that you can log in, it’s time to to generate the feed for the current user.

To do so, find the users that this user is following. For this use case, you’ll use the Repository method findUser(byId:inDatabase:). The implementation is similar to what you did in findUser(byUsername:inDatabase:), so give this a try first.

How’d you do? Does your findUser(byId:inDatabase:) read like this?

let users = database[User.collection] // 1
return users
  .findOne("_id" == userId, as: User.self) // 2
  .flatMapThrowing { user in // 3
    guard let user = user else {
      throw Abort(.notFound)
    }
    return user
}

In the code above, you:

  1. Get users collection.
  2. Find user with id.
  3. Unwrap the user or throw an error if nil.

To build up the feed, you add this list of user identifiers. Next, you add the user’s own identifier so that users see their own posts.

Replace the return statement in getFeed(forUser:inDatabase:) with the following:

return findUser(byId: userId, inDatabase: database)
  .flatMap { user in
    // Users you're following and yourself
    var feedUserIds = user.following
    feedUserIds.append(userId)
    
    let followingUsersQuery: Document = [
      "creator": [
        "$in": feedUserIds
      ]
    ]
    
    // More code coming. Ignore error message about return.
}

The $in filter tests if the creator field exists in feedUserIds.

With a find query, you can retrieve a list of all posts. Because most users are only interested in recent posts, you need to set a limit. A simple find would be perfect for returning an array of TimelinePost objects. But in this function, you need an array of ResolvedTimelinePost objects.

The difference between both models is the creator key. The entire user model is present in ResolvedTimelinePost. Leaf uses this information to present the author of the post.

A lookup aggregate stage is a perfect fit for this.

Creating Aggregate Pipelines

An aggregate pipeline is one of the best features of MongoDB. It works like a Unix pipe, where each operation’s output becomes the input of the next. The entire collection functions as the initial dataset.

To create an aggregate query, add the following code to getFeed(forUser:inDatabase:) under the comment More code coming.:

return database[TimelinePost.collection].buildAggregate { // 1
  match(followingUsersQuery) // 2
  sort([
    "creationDate": .descending
  ]) // 3
  limit(10) // 4
  lookup(
    from: User.collection,
    localField: "creator",
    foreignField: "_id",
    as: "creator"
  ) // 5
  unwind(fieldPath: "$creator") // 6
}
  .decode(ResolvedTimelinePost.self) // 7
  .allResults() // 8

Here’s what you’re doing:

  1. First, you create an aggregate pipeline based on function builders.
  2. Then, you filter the timeline posts to match followingUsersQuery.
  3. Next, you sort the timeline posts by creation date, so that recent posts are on top.
  4. And you limit the results to the first 10 posts, leaving the 10 most recent posts.
  5. Now, you look up the creators of this post. localField refers to the field inside TimelinePost. And foreignField refers to the field inside User. This operation returns all users that match this filter. Finally, this puts an array with results inside creator.
  6. Next, you limit creator to one user. As an array, creator can contain zero, one or many users. But for this project, it must always contain exactly one user. To accomplish this, unwind(fieldPath:) outputs one timeline post for each value in creator and then replaces the contents of creator with a single entity.
  7. You decode each result as a ResolvedTimelinePost.
  8. Finally, you execute the query and return all results.

The homepage will not render without suggesting users to follow. To verify that the feed works, replace the return statement of findSuggestedUsers(forUser:inDatabase:) with the following:

return database.eventLoop.makeSucceededFuture([])

Build and run, load the website, and you’ll see the first feed!


Default data is displayedYour first feed

Suggesting Users

The homepage only shows the posts by Ray Wenderlich and yourself. That happens because you’re only following Ray Wenderlich. To discover other users, you’ll create another pipeline in findSuggestedUsers(forUser:inDatabase:).

This pipeline will fill the sidebar with users you’re not following yet. To do this, you’ll create a filter that only shows people you’re not following. You’ll use the same filter as above, but reversed. Replace the return statement in findSuggestedUsers(forUser:inDatabase:) with the following:

let users = database[User.collection] // 1
return findUser(byId: userId, inDatabase: database).flatMap { user in // 2
  
  // 3
  var feedUserIds = user.following
  feedUserIds.append(userId)
  
  // 4
  let otherUsersQuery: Document = [
    "_id": [
      "$nin": feedUserIds
    ]
  ]
  
  // Continue writing here
  
}

In the code above, you:

  1. Get users collection.
  2. Find user by userId.
  3. List the user identifiers whose profiles are not shown.
  4. Construct a filter that looks for users where their identifier is Not IN the array.

If you use a simple filter, users will always see the same suggestions. But you’ll do better than that! Instead of a simple filter, you’ll suggest users based on the people you’re following, using a Graph Lookup. This works in the same way as a lookup stage, but recursively.

While a graph lookup provides more relevant results, it doesn’t show suggestions when a user doesn’t follow anyone. Therefore, use a sample stage instead. sample(_:) creates a random set of entities from the input results.

Add this code below the Continue writing here comment:

return users.buildAggregate {
  match(otherUsersQuery) // 1
  sample(5) // 2
  sort([
    "profile.firstName": .ascending,
    "profile.lastName": .ascending
  ]) // 3
}.decode(User.self).allResults() // 4

In this code, you:

  1. Find all users excluding yourself and those you’re following.
  2. Select up to five random users.
  3. Sort them by firstName, then lastName.
  4. Decode the results as a User and return all results.

Build and run the application, reload the page and ta-da! Your sidebar now contains users that you can follow.

Sidebar has users you can follow.

Following Users

To make the follow button work, replace the return statement in followUser with the following:

return database[User.collection].updateOne( // 1
  where: "_id" == follower._id, // 2
  to: [
    "$push": [
      "following": account._id
    ]
  ] // 3
).map { _ in }

In this code, you’re:

  1. Getting the users collection.
  2. Specifying which user to update.
  3. Updating the user.

Creating Posts

Now that you’re able to see posts, it’s time to create some of your own!

To create a post, you’ll change createPost. In MongoDB, the insert operation creates new entities in a collection. MongoKitten even provides a helper for encodable types such as TimelinePost.

Replace the return statement in createPost(_:inDatabase:) with the following, to add the post to its collection:

return database[TimelinePost.collection].insertEncoded(post).map { _ in }

This line will insert the new, encoded post into the timelineposts collection.
Create a post and refresh the website. Your new post will be on the top! But you’re not done yet.

As you might have noticed, users can upload images as part of their posts. Any service should store the files on the disk. However, this can be a complex task if you want to do it right. You’ll need to take care of access control and high availability, to name a few.

MongoDB already supports high availability and replication. And the code that calls MongoDB already makes access checks. To take advantage of this, you’ll use GridFS. GridFS is a standard that can store small files into MongoDB. Preferably you’ll keep the files as small as possible. It’s great for storing images or PDF invoices.

To use GridFS, you’ll need to create a GridFSBucket. You can then upload or download files from this bucket. To allow uploading files, replace the return statement in uploadFile(_:inDatabase:) with the following:

let id = ObjectId() // 1
let gridFS = GridFSBucket(in: database) // 2
return gridFS.upload(file, id: id).map { // 3
  return id // 4
}

In the code above, you:

  1. Generate the file’s identifier.
  2. Open the GridFS bucket in the selected database.
  3. Upload the user’s file to GridFS.
  4. Return the generated identifier.

The first post!

Reading Uploaded Files

To share the file with users, the repository uses readFile(byId:inDatabase:). Like the example above, you’ll access file storage through a GridFS bucket. But instead of uploading a file, you’ll read the contents instead.

First, you must fetch the file from the GridFSBucket. The fetched GridFSFile does not contain the contents of the file data. Instead, it contains all metadata related to this file. This includes the filename, size and any custom data.

After fetching the GridFSFile, read the file’s contents using the file’s reader. Replace the return statement in readFile(byId:inDatabase:) with the following:

let gridFS = GridFSBucket(in: database) // 1

return gridFS.findFile(byId: id) // 2
  .flatMap { file in
    guard let file = file else { // 3
      return database.eventLoop.makeFailedFuture(Abort(.notFound))
    }
    
    return file.reader.readData() // 4
}

In the code above, you:

  1. Get the Bucket
  2. Find file by Id
  3. Unwrap file, else throw an error
  4. Read data and return

Build and restart the application. Then, create a new post and don’t forget to attach an image. You’ll see your new post in the timeline, including the image.

Congratulations! You’ve completed your first app with MongoDB and Server-Side Swift.

Where to Go From Here?

You can download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

MongoDB has a lot of powerful features to offer. If you’re looking to expand upon those, I recommend reading the official documentation at mongodb.com.

If you want to learn more about developing web services in Server-Side Swift, our Server-Side Swift With Vapor book is a good starting place.

MongoDB is a powerful database. Are you excited to explore it more? Feel free to share your thoughts on MongoDB and MongoKitten in the discussion below!

Average Rating

4.8/5

Add a rating for this content

5 ratings

More like this

Contributors

Comments