Skip to content

Commit 2fdf64d

Browse files
authored
SWIFT-1129 Create full stack Swift example project (#746)
1 parent a19d4d3 commit 2fdf64d

28 files changed

+1311
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.build/
22
.swiftpm/
33
Package.resolved
4+
**/xcuserdata/

.swiftlint.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ disabled_rules:
55
- todo
66
- type_body_length
77
- type_name
8-
- inclusive_language # disabled until we complete work for the "remove offensive terminology" project.
98
- cyclomatic_complexity
109
- opening_brace # conflicts with SwiftFormat wrapMultilineStatementBraces
1110
- nesting
11+
- multiple_closures_with_trailing_closure
1212

1313
opt_in_rules:
1414
- array_init
@@ -51,6 +51,7 @@ excluded:
5151
- docs
5252
- Examples/*/build
5353
- Examples/*/.build
54+
- Examples/*/*/.build
5455
- Sources/libbson
5556
- Sources/libmongoc
5657
- Package.swift
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// swift-tools-version:5.5
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "Backend",
6+
platforms: [
7+
.macOS(.v12)
8+
],
9+
dependencies: [
10+
.package(url: "https://github.com/vapor/vapor", .upToNextMajor(from: "4.50.0")),
11+
.package(url: "https://github.com/mongodb/mongodb-vapor", .exact("1.1.0-beta.1")),
12+
.package(path: "../Models")
13+
],
14+
targets: [
15+
.target(
16+
name: "App",
17+
dependencies: [
18+
.product(name: "Vapor", package: "vapor"),
19+
.product(name: "MongoDBVapor", package: "mongodb-vapor"),
20+
.product(name: "Models", package: "models")
21+
],
22+
swiftSettings: [
23+
// Enable better optimizations when building in Release configuration. Despite the use of
24+
// the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release
25+
// builds. For details, see
26+
// <https://github.com/swift-server/guides/blob/main/docs/building.md#building-for-production>.
27+
.unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
28+
]
29+
),
30+
.executableTarget(name: "Run", dependencies: [
31+
.target(name: "App"),
32+
.product(name: "MongoDBVapor", package: "mongodb-vapor")
33+
])
34+
]
35+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# MongoDB + Vapor Backend
2+
3+
The backend server is built using [Vapor 4](vapor.codes) along with version 1 of the [MongoDB Swift driver](https://github.com/mongodb/mongo-swift-driver). It utilizes a small library we've written called [mongodb-vapor](https://github.com/mongodb/mongodb-vapor) which includes some helpful code for integrating the two.
4+
5+
The application contains a REST API which the iOS frontend uses to interact with it via HTTP requests.
6+
7+
## Application Endpoints
8+
The backend server supports the following API requests:
9+
1. A GET request at the URL `/` returns a list of kittens.
10+
1. A POST request at the URL `/` adds a new kitten.
11+
1. A GET request at the URL `/{ID}` returns information about the kitten with the specified ID.
12+
1. A PATCH request at the URL `/{ID}` edits the `favoriteFood` and `lastUpdateTime` properties for the kitten with the specified ID.
13+
1. A DELETE request at the URL `/{ID}` deletes the kitten with the specified ID.
14+
15+
### MongoDB Usage
16+
This application connects to the MongoDB server with the connection string specified by the environment variable `MONGODB_URI`, or if unspecified, attempts to connect to a MongoDB server running on the default host/port with connection string `mongodb://localhost:27017`.
17+
18+
The application uses the collection "kittens" in the database "home". This collection has a [unique index](https://docs.mongodb.com/manual/core/index-unique/) on the `_id` field, as is the default for MongoDB (more on that [here](https://docs.mongodb.com/manual/core/document/#the-_id-field)).
19+
20+
The call to `app.mongoDB.configure()` in `Sources/App/configure.swift` initializes a global `MongoClient` to back your application. `MongoClient` is implemented with that approach in mind: it is safe to use across threads, and is backed by a [connection pool](https://en.wikipedia.org/wiki/Connection_pool) which enables sharing resources throughout the application. You can find the API documentation for `MongoClient` [here](https://mongodb.github.io/mongodb-vapor/current/Classes/MongoClient.html).
21+
22+
Throughout the application, the global client is accessible via `app.mongoDB.client`.
23+
### Data Models
24+
The data model types used in the backend are shared with the frontend, and defined in the [Models](../Models) package. In `Sources/App/routes.swift`, we extend the `Kitten` type to conform to Vapor's [`Content`](https://api.vapor.codes/vapor/main/Vapor/Content/) protocol, which specifies types that can be initialized from HTTP requests and serialized to HTTP responses.
25+
26+
We are also able to use these model types directly with the database driver, which makes it straightforward to, for example, insert a new `Kitten` object that was sent to the backend via HTTP directly into a MongoDB collection. To support that, when creating a `MongoCollection`, we pass in the name of the corresponding model type:
27+
```swift
28+
extension Request {
29+
var kittenCollection: MongoCollection<Kitten> {
30+
self.application.mongoDB.client.db("home").collection("kittens", withType: Kitten.self)
31+
}
32+
}
33+
```
34+
35+
This will instantiate a `MongoCollection<Kitten>`. We can then use `Kitten` directly with many API methods -- for example, `insertOne` will directly accept a `Kitten` instance, and `findOne` will return a `Kitten?`. You can find the API documentation for `MongoCollection` [here](https://mongodb.github.io/mongodb-vapor/current/Structs/MongoCollection.html).
36+
37+
In `Sources/Run/main.swift`, we globally configure Vapor to use `swift-bson`'s `ExtendedJSONEncoder` and `ExtendedJSONDecoder` for encoding/decoding JSON data, rather than the default `JSONEncoder` and `JSONDecoder`:
38+
```swift
39+
ContentConfiguration.global.use(encoder: ExtendedJSONEncoder(), for: .json)
40+
ContentConfiguration.global.use(decoder: ExtendedJSONDecoder(), for: .json)
41+
```
42+
This is recommended as [extended JSON](https://docs.mongodb.com/manual/reference/mongodb-extended-json/) is a MongoDB-specific version of JSON which helps with preserving type information. The iOS application also uses extended JSON via `ExtendedJSONEncoder` and `ExtendedJSONDecoder`.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import MongoDBVapor
2+
import Vapor
3+
4+
/// Configures the application.
5+
public func configure(_ app: Application) throws {
6+
// Use `ExtendedJSONEncoder` and `ExtendedJSONDecoder` for encoding/decoding `Content`. We use extended JSON both
7+
// here and in the frontend to ensure all MongoDB type information is correctly preserved.
8+
// See: https://docs.mongodb.com/manual/reference/mongodb-extended-json
9+
ContentConfiguration.global.use(encoder: ExtendedJSONEncoder(), for: .json)
10+
ContentConfiguration.global.use(decoder: ExtendedJSONDecoder(), for: .json)
11+
12+
// register routes
13+
try routes(app)
14+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import Models
2+
import MongoDBVapor
3+
import Vapor
4+
5+
// Adds API routes to the application.
6+
func routes(_ app: Application) throws {
7+
/// Handles a request to load the list of kittens.
8+
app.get { req async throws -> [Kitten] in
9+
try await req.findKittens()
10+
}
11+
12+
/// Handles a request to add a new kitten.
13+
app.post { req async throws -> Response in
14+
try await req.addKitten()
15+
}
16+
17+
/// Handles a request to load info about a particular kitten.
18+
app.get(":_id") { req async throws -> Kitten in
19+
try await req.findKitten()
20+
}
21+
22+
app.delete(":_id") { req async throws -> Response in
23+
try await req.deleteKitten()
24+
}
25+
26+
app.patch(":_id") { req async throws -> Response in
27+
try await req.updateKitten()
28+
}
29+
}
30+
31+
/// Extend the `Kitten` model type to conform to Vapor's `Content` protocol so that it may be converted to and
32+
/// initialized from HTTP data.
33+
extension Kitten: Content {}
34+
35+
extension Request {
36+
/// Convenience extension for obtaining a collection.
37+
var kittenCollection: MongoCollection<Kitten> {
38+
self.application.mongoDB.client.db("home").collection("kittens", withType: Kitten.self)
39+
}
40+
41+
/// Constructs a document using the _id from this request which can be used a filter for MongoDB
42+
/// reads/updates/deletions.
43+
func getIDFilter() throws -> BSONDocument {
44+
// We only call this method from request handlers that have _id parameters so the value
45+
// should always be available.
46+
guard let idString = self.parameters.get("_id", as: String.self) else {
47+
throw Abort(.badRequest, reason: "Request missing _id for kitten")
48+
}
49+
guard let _id = try? BSONObjectID(idString) else {
50+
throw Abort(.badRequest, reason: "Invalid _id string \(idString)")
51+
}
52+
return ["_id": .objectID(_id)]
53+
}
54+
55+
func findKittens() async throws -> [Kitten] {
56+
do {
57+
return try await self.kittenCollection.find().toArray()
58+
} catch {
59+
throw Abort(.internalServerError, reason: "Failed to load kittens: \(error)")
60+
}
61+
}
62+
63+
func findKitten() async throws -> Kitten {
64+
let idFilter = try self.getIDFilter()
65+
guard let kitten = try await self.kittenCollection.findOne(idFilter) else {
66+
throw Abort(.notFound, reason: "No kitten with matching _id")
67+
}
68+
return kitten
69+
}
70+
71+
func addKitten() async throws -> Response {
72+
let newKitten = try self.content.decode(Kitten.self)
73+
do {
74+
try await self.kittenCollection.insertOne(newKitten)
75+
return Response(status: .created)
76+
} catch {
77+
throw Abort(.internalServerError, reason: "Failed to save new kitten: \(error)")
78+
}
79+
}
80+
81+
func deleteKitten() async throws -> Response {
82+
let idFilter = try self.getIDFilter()
83+
do {
84+
// since we aren't using an unacknowledged write concern we can expect deleteOne to return a non-nil result.
85+
guard let result = try await self.kittenCollection.deleteOne(idFilter) else {
86+
throw Abort(.internalServerError, reason: "Unexpectedly nil response from database")
87+
}
88+
guard result.deletedCount == 1 else {
89+
throw Abort(.notFound, reason: "No kitten with matching _id")
90+
}
91+
return Response(status: .ok)
92+
} catch {
93+
throw Abort(.internalServerError, reason: "Failed to delete kitten: \(error)")
94+
}
95+
}
96+
97+
func updateKitten() async throws -> Response {
98+
let idFilter = try self.getIDFilter()
99+
// Parse the update data from the request.
100+
let update = try self.content.decode(KittenUpdate.self)
101+
/// Create a document using MongoDB update syntax that specifies we want to set a field.
102+
let updateDocument: BSONDocument = ["$set": .document(try BSONEncoder().encode(update))]
103+
104+
do {
105+
// since we aren't using an unacknowledged write concern we can expect updateOne to return a non-nil result.
106+
guard let result = try await self.kittenCollection.updateOne(
107+
filter: idFilter,
108+
update: updateDocument
109+
) else {
110+
throw Abort(.internalServerError, reason: "Unexpectedly nil response from database")
111+
}
112+
guard result.matchedCount == 1 else {
113+
throw Abort(.notFound, reason: "No kitten with matching _id")
114+
}
115+
return Response(status: .ok)
116+
} catch {
117+
throw Abort(.internalServerError, reason: "Failed to update kitten: \(error)")
118+
}
119+
}
120+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import App
2+
import MongoDBVapor
3+
import Vapor
4+
5+
var env = try Environment.detect()
6+
try LoggingSystem.bootstrap(from: &env)
7+
8+
let app = Application(env)
9+
try configure(app)
10+
11+
// Configure the app for using a MongoDB server at the provided connection string.
12+
try app.mongoDB.configure(Environment.get("MONGODB_URI") ?? "mongodb://localhost:27017")
13+
14+
defer {
15+
// Cleanup the application's MongoDB data.
16+
app.mongoDB.cleanup()
17+
// Clean up the driver's global state. The driver will no longer be usable from this program after this method is
18+
// called.
19+
cleanupMongoSwift()
20+
app.shutdown()
21+
}
22+
23+
try app.run()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
command -v mongosh > /dev/null || { echo "Failed to locate mongosh; please follow instructions here to install it: https://docs.mongodb.com/mongodb-shell/install"; exit 1; }
2+
mongosh $MONGODB_URI --eval "db.getSiblingDB('home').kittens.insertMany([{name:\"Roscoe\",color:\"orange\", favoriteFood: \"salmon\", lastUpdateTime: new Date()},{name:\"Chester\",color:\"tan\", favoriteFood: \"turkey\", lastUpdateTime: new Date()}])"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// swift-tools-version:5.5
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "models",
7+
platforms: [
8+
.macOS(.v10_14),
9+
.iOS(.v13)
10+
],
11+
products: [
12+
.library(
13+
name: "Models",
14+
targets: ["Models"]
15+
)
16+
],
17+
dependencies: [
18+
.package(url: "https://github.com/mongodb/swift-bson", .upToNextMajor(from: "3.1.0"))
19+
],
20+
targets: [
21+
.target(
22+
name: "Models",
23+
dependencies: [
24+
.product(name: "SwiftBSON", package: "swift-bson")
25+
]
26+
)
27+
]
28+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Models
2+
3+
This is a small [SwiftPM](https://www.swift.org/package-manager/) library containing data model types, which is a dependency of both the frontend and backend. An advantage of building the full application in Swift is that we can share the definitions of these types and avoid having to keep duplicate logic in sync.
4+
5+
This package contains three model types. Two of them correspond to data types stored in the database: `Kitten` and `CatFood`. The third, `KittenUpdate` models the information used when an update is performed in the application.
6+
7+
All of these types are `Codable`, which allows them to be serialized to and deserialized from external data formats. For the purpose of this application, there are two external data formats used:
8+
9+
1) [`Extended JSON`](https://docs.mongodb.com/manual/reference/mongodb-extended-json/), a version of JSON with some MongoDB-specific extensions. Both the frontend and backend use [swift-bson](https://github.com/mongodb/swift-bson) and its [`ExtendedJSONEncoder`](https://mongodb.github.io/swift-bson/docs/current/SwiftBSON/Classes/ExtendedJSONEncoder.html) and [`ExtendedJSONDecoder`](https://mongodb.github.io/swift-bson/docs/current/SwiftBSON/Classes/ExtendedJSONDecoder.html) types to perform serialization and deserialization of the data transmitted via HTTP. This is helpful as extended JSON makes it straightforward to preserve type information. For example, in extended JSON dates are expressed as `{"$date": "<ISO-8601 Date/Time Format>"}`. Swift `Date` objects will automatically be serialized in this form by `ExtendedJSONEncoder`, and `ExtendedJSONDecoder` will decode such JSON input back into a Swift `Date`.
10+
11+
2) [`BSON`](https://docs.mongodb.com/manual/reference/bson-types/) is the binary serialization format MongoDB uses to store data. Serialization to and deserialization from BSON is handled automatically in the backend by the MongoDB driver -- the driver API accepts and returns `Codable` types, and under the hood handles serializing those types to and from BSON for storage in and retrieval from the database.
12+
13+
For an example of how all of this comes together, when a new kitten is added via the iOS application, the flow of data is as follows:
14+
1) The iOS app creates a new instance of `Kitten` containing the user-provided data
15+
2) The iOS app serializes the `Kitten` instance to extended JSON using `ExtendedJSONEncoder`
16+
3) The iOS app sends a POST request to the backend server containing the extended JSON data
17+
4) The backend deserializes the extended JSON data into a `Kitten` using `ExtendedJSONDecoder`
18+
5) The `Kitten` instance is passed to `MongoCollection.insertOne`
19+
6) The database driver uses `BSONEncoder` to convert the `Kitten` to BSON data
20+
7) The BSON data is sent to the database via the MongoDB wire protocol

0 commit comments

Comments
 (0)