Getting Started With Vapor 4 — Model Relationships | by Fernando Moya de Rivas | Mar, 2022

In a previous article, we introduced Vapor and started creating a TODO app.

For now, the app doesn’t do much. It just lets you create a list but you can’t yet add any reminders to it. We’ll fix that in a bit.

By the end of this article, we’ll have a working web app to store our list of to-dos. Along the way, we’ll keep learning about Vapor and how it works. You can find the full implementation in my repo:

https://github.com/fermoya/vapor-tutorial

As of now, we’re defining all the endpoints directly in routes.swift as show below:

app.get("todo-lists") { ... }
app.post("todo-lists") { ... }
...

This can get very messy as soon as we introduce new models. Imagine a real web app, how many different entities are kept in a database: users, collections, settings, collections, etc. We need a better strategy.

Also, think about any REST APIs you know. You’ll most likely have noticed that part of the path references an API version. For instance, https://<organization>.atlassian.net/api/3/<path> or https://api.appstoreconnect.apple.com/v1/<path> . How would this look in our app?

app.get("v1", "todo-lists") { ... }
app.post("v1", "todo-lists") { ... }
...

This doesn’t look very maintainable. Fortunately, Vapor allows you to “group” routes and also “register” controllers.

Let’s put this into action. Open routes.swift delete the contents of routes(_:)and paste this code instead:

let v1Routes = app.grouped("v1")let todoListsRoutes = v1Routes.grouped("todo-lists")
try todoListsRoutes.register(collection: TodoListController())

What’s happening here?

  • We’re grouping all endpoints under the v1 path. This means that every endpoint path defined against v1Routes will get its path prepended with v1 . For instance, /v1/todo-lists
  • We group yet again a set of paths containing todo-lists. This is great because all of our CRUD operations defined todo-lists as their path
  • Finally, TodoListController is defined as the class in charge of the subpath /v1/todo-lists

Let’s see what a TodoListController would look like:

import Vaporfinal class TodoListController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
// routing here
}
}

A Controller is a mere RouteCollection that, when booted, routes every operation we want to allow to a particular method. Let’s implement this method:

Let’s break it down:

  • On a get request to /v1/todo-lists invoke getAllEntities(req:)
  • On a post request to /v1/todo-lists invoke postEntity(req:)
  • Notice how both methods hold the code that was previously in routes.swift

This is neat, isn’t it? Much cleaner than before. Now we can continue grouping subpaths to continue building our app.

Note: Don’t forget that now your requests should be prefixed by v1 . That is, localhost:8080/todo-lists won’t work anymore, but rather, localhost:8080/v1/todo-lists .

Before moving on and continuing to develop the TodoApplet’s first take a look at how we can improve our GET operations. First of all, there is a way to retrieve a particular TodoList ? In REST, this would be accomplished by fetching from GET /todo-lists/<id> . That is, the path needs to be parametrized.

Furthermore, it is possible to retrieve not all(_:) lists but just a certain amount of them? That is, can we filter the lists to limit the number of results returned?

Routing parameters

Vapor lets us parametrized the path to an endpoint by passing a string prepended by : . For instance:

app.get("users", ":userID") { ... }

would resolve to /users/<userID> and would create a userID retrievable from the request object. Similarly, this can be done in our current routing structure, we just need to define a new route inside func routes(boot:) and provide an implementation for it:

Notice how we retrieve the parameter by calling req.parameters.get(_:as:) . If an TodoList didn’t exist, HTTP 404 would be returned.

Let’s test this new endpoint. First, let’s modify POST /todo-lists to return the created list:

private func postEntity(req: Request) throws -> EventLoopFuture<Response> {
let todoList = try req.content.decode(TodoList.self)
return todoList.save(on: req.db)
.map { todoList }
.encodeResponse(status: .created, for: req)
}

Finally, we can test this out:

$ curl -X POST localhost:8080/v1/todo-lists 
-H "Content-Type:application/json"
--data "{ "name": "Test my new endpoint" }" | jq
{
"id": "CF880521-A913-4977-8D84-4CC70EC2977D",
"name": "Test my new endpoint"
}
$ curl localhost:8080/v1/todo-lists/8359807A-FCE3-48AA-8AC6-EFF619913900 | jq
{
"id": "CF880521-A913-4977-8D84-4CC70EC2977D",
"name": "Test my new endpoint"
}

Querying

Before, GET /todo-lists would return all TodoList available. However, this would be very inefficient in the future. Imagine we end up with thousands of lists.

We want to support pagination for this endpoint and for that we need a way to access the query-items send in a request. lucky, Request comes with subscript methods that allow you to retrieve an expected query-item . Go to func getAllEntities(req:) and let’s rename it getEntities as we won’t be returning every instance anymore. Then, modify it as below:

Notice how we limit the range(_:) of results here and then retrieve all(_:) that match. Let’s try it out by creating 20 lists:

for i in $(seq 0 20); do
curl localhost:8080/v1/todo-lists
-X POST
-H "Content-Type:application/json"
--data "{ "name": "TODO List $i" }"
done

and fetching the lists 10 to 14:

$ curl "localhost:8080/v1/todo-lists?offset=10&limit=5" | jq
[
{
"id": "77CD7685-1105-4E81-8A35-F775CEDDA28F",
"name": "TODO List 10"
},
{...},
{...},
{...},
{
"id": "77CD7685-1105-4E81-8A35-F775CEDDA28F",
"name": "TODO List 14"
}
]

In case you’re wondering, going out of bounds (offset=21 in our example) will just turn out into an empty array.

Defining TODO

At this point, you should have a web app that can store and return TodoList . How about we add some Todo to the actual list? Let’s start by thinking of what we want to achieve. A TodoList can be viewed as a folder that contains Todo s:

├── my-todo-list
│ ├── todo-1
│ ├── todo-2
│ └── ...

Here you’ll see the followings:

  • A Todo can’t exist without a TodoList
  • A TodoList can have any number of Todo

This is what’s known as a one-to-many relationship. In order to tell Vapor or actually the ORM Fluent that these two models are related we need to make use of two new wrappers:

  • @Parent will relate a Todo to a TodoList
  • @Children will do the equivalent between TodoList and Todo

With all this in mind, we can define Todo as:

and update TodoList to reflect the established relationship between the two models:

@Children(for: .$list)
var todos: [Todo]

Notice that list is the property defined in Todo that holds a reference to a TodoList.

Next, we need to create the a new table in our database. If you remember from Part 1, this is done via a Migration:

Notice how the field description is defined as a String? and, therefore, it’s not marked as .required . We don’t need any extra migration for TodoList .

Finally, add the migration to configure.swift :

app.migrations.add(CreateTodoListMigration(), to: .sqlite)
app.migrations.add(CreateTodoMigration(), to: .sqlite)

Creating new TODOs

Our new model is ready and databases are created but we haven’t started routing yet. We want to define new endpoints that look like /todo-lists/<id>/todos . If you remember, we’ve already defined an endpoint /todo-lists/<id> inside TodoListsController and a group of routes singleListRoutes . We can keep building on top of that.

// TodoListController
func boot(routes: RoutesBuilder) throws {
...
let singleListRoutes = routes.grouped(":id")
singleListRoutes.get(use: getEntity)
let todosRoutes = singleListRoutes.grouped("todos")
try todosRoutes.register(collection: TodoController())
}

And similar to TodoListController we can define TodoController as:

Let’s start by implementing postEntity:

guard let listID = req.parameters.get("id", as: UUID.self) else {
throw Abort(.notFound)
}
let todo = try req.content.decode(Todo.self)
todo.$list.id = listID
return todo.save(on: req.db)
.map { todo }
.encodeResponse(status: .created, for: req)

and try it out:

$ curl localhost:8080/v1/todo-lists 
-X POST
-H "Content-Type:application/json"
--data "{ "name": "Foo" }"
{"id":"9F5D21D3-11F3-434D-907E-2DA083404C3F","name":"Foo"}$ curl -X POST
"localhost:8080/v1/todo-lists/9F5D21D3-11F3-434D-907E-2DA083404C3F/todos"
-H "Content-Type:application/json"
--data "{ "title": "My first TODO" }"
{"error":true,"reason":"Value of type 'TodoList' required for key 'list'."}%

Notice the error: Value of type TodoList required for key list. Unfortunately, Vapor won’t let you decode a Todo unless you pass a list. There are two ways to solve this problem:

  • Option 1. Remove guard/else , todo.$list.id = listID and pass the listID in the body of the request:
POST /v1/todo-lists/9F5D21D3-11F3-434D-907E-2DA083404C3F/todos
{
"title": ...
"list": {
"id": "9F5D21D3-11F3-434D-907E-2DA083404C3F"
}
}
  • Option 2. Create a struct to decode the information (title , description), create a new todo from scratch and populate its fields and save it:

Option 2 is more cumbersome than Option 1 so we’ll go for Option 1.

Now, if we retry the same operation again with the added field:

$ curl -X POST 
"localhost:8080/v1/todo-lists/9F5D21D3-11F3-434D-907E-2DA083404C3F/todos"
-H "Content-Type:application/json"
--data "{ "title": "My first TODO", "list": { "id": "9F5D21D3-11F3-434D-907E-2DA083404C3F" } }"
{"id":"1840A97A-631A-4B35-9381-BD2FC27A3FA5","title":"My First TODO","list":{"id":"9F5D21D3-11F3-434D-907E-2DA083404C3F"},"description":null}%

Our Todo is created! Let’s check by fetching the parent TodoList:

curl -X GET "localhost:8080/v1/todo-lists/9F5D21D3-11F3-434D-907E-2DA083404C3F" | jq{
"id": "9F5D21D3-11F3-434D-907E-2DA083404C3F",
"name":"Foo"
}

Whoops! It seems there aren’t any Todo in this list… or are there? Let’s check GET /v1/todo-lists/<id> implementation:

let uuid = req.parameters.get("id", as: UUID.self)
return TodoList.find(uuid, on: req.db)
.unwrap(orError: Abort(.notFound))
.encodeResponse(for: req)

Notice how we’re trying to find a TodoList but we’re not loading/fetting any children! The implementation needs to change slightly:

let uuid = req.parameters.get("id", as: UUID.self)
return TodoList.find(uuid, on: req.db)
.unwrap(orError: Abort(.notFound))
.flatMap { list in
list.$todos.load(on: req.db).map { list }
}
.encodeResponse(for: req)

If we try again now:

curl -X GET "localhost:8080/v1/todo-lists/9F5D21D3-11F3-434D-907E-2DA083404C3F" | jq{
"id": "9F5D21D3-11F3-434D-907E-2DA083404C3F",
"name":"Foo",
"todos": [
{
"id": "1840A97A-631A-4B35-9381-BD2FC27A3FA5",
"title": "My First TODO",
"list": {
"id": "9F5D21D3-11F3-434D-907E-2DA083404C3F"
},
"description": null
}
]
}

similarly, /v1/todo-lists needs to be updated. This time we can use with to create joined query:

return TodoList.query(on: req.db)
.range(offset..<(limit + offset))
.with(.$todos)
.all()
.encodeResponse(for: req)

Fetching all TODOs

How about we fetch all Todo‘s in a TodoList ? For that, we’d need to query all todos and filter by a listID. Let’s do that:

private func getEntities(req: Request) throws -> EventLoopFuture<Response> {
guard let listID = req.parameters.get("id", as: UUID.self) else {
throw Abort(.notFound)
}
return Todo.query(on: req.db)
.filter(.$list.$id == listID)
.all()
.encodeResponse(for: req)
}

Over the course of this article, we’ve built new features on top of a TodoApp and learned good practices for structuring your Vapor project. We also explored the idea of ​​model relationships to successfully relate a Todo to a TodoList.

At the end of this article, you should have a functional TodoApp. In a final article, I’ll explain even more advanced features of Vapor to add images to a TodoList.

Thanks for reading!

Stay tuned.

Leave a Comment