How to build a Feather CMS module?

In this tutorial I'll show you how to create a custom user module with an admin interface for Feather using Swift 5 and Vapor 4.

Vapor

Swift template and module generation

There is an open source template based generator tool for Swift that I created, because I needed something to quickly set up both VIPER and Vapor modules. We are going to use this generator to start building our custom Feather module. You can install Swift template using the command line:

git clone https://github.com/BinaryBirds/swift-template.git
cd swift-template

make install

Now we just need a template, but fortunately there is one available on GitHub that you can use for generating Vapor modules that are compatible with Feather.

We are going to install this template globally available:

# install the vapor module template globally available
swift template install https://github.com/binarybirds/vapor-module-template -g

Now we can bootstrap our custom module via the following command:

swift template generate MyModule --use viper-module --output ~/

You can alter the the name of the module, use an other template (must be installed locally or globally) and specify the output directory where you want to save the module files.


Building a simple news module for Feather

In Feather CMS you can building a feature rich module in just a few minutes. That's right, I'll show you how to make one using Swift template and the Vapor module starter kit. First of all you'll need to grab Feather CMS from GitHub and generate a new module using the generator.

git clone https://github.com/BinaryBirds/feather.git
cd feather
swift template generate News -u vapor-module -o ./Sources/App/Modules
open Package.swift

Update your Swift package dependencies using Xcode 11 or above or the Swift Package Manager if you are building the backend server without Xcode. Now you just have make sure that you have created a local .env file that Feather can use to run the server and (if needed) you've also set a custom working directory in Xcode. You can read more about this process on GitHub.

Time to enable our newly created module, open to the configure.swift file and append the NewsModule() instance to the modules array. This enables the sample news module, but still we have to make some changes before we could run the backend.

Model definition

Let's start by creating a model definition for our news entries. This will allow us to store news objects in the persistent database using the underlying Fluent framework.

import Vapor
import Fluent
import ViperKit

final class NewsModel: ViperModel {
    typealias Module = NewsModule

    static let name = "news"

    struct FieldKeys {
        static var title: FieldKey { "title" }
        static var imageKey: FieldKey { "image_key" }
        static var excerpt: FieldKey { "excerpt" }
        static var content: FieldKey { "content" }
    }
    
    // MARK: - fields

    @ID() var id: UUID?
    @Field(key: FieldKeys.title) var title: String
    @Field(key: FieldKeys.imageKey) var imageKey: String
    @Field(key: FieldKeys.excerpt) var excerpt: String
    @Field(key: FieldKeys.content) var content: String

    init() { }
    
    init(id: UUID? = nil,
         title: String,
         imageKey: String,
         excerpt: String,
         content: String)
    {
        self.id = id
        self.title = title
        self.imageKey = imageKey
        self.excerpt = excerpt
        self.content = content
    }
}

We are going to keep things simple, we defined a database model in Swift using a feature called property wrappers (@ID, @Field). They will allow Fluent to read and write columns in the represented database table, so we don't have to write SQL queries, but we can access the entries through a much higher level (ORM) abstraction layer.

The id is a unique identifier, we're going to save the news title as a String, the imageKey is a special property for saving image URLs and the excerpt is going to be a short "sneak-peak" of the entire content. Now we just have to write a migration script, because in Vapor we have to create or update our database tables before we could use the model.

import Vapor
import Fluent

struct NewsMigration_v1_0_0: Migration {

    func prepare(on db: Database) -> EventLoopFuture<Void> {
        db.schema(NewsModel.schema)
            .id()
            .field(NewsModel.FieldKeys.title, .string, .required)
            .field(NewsModel.FieldKeys.imageKey, .string, .required)
            .field(NewsModel.FieldKeys.excerpt, .string, .required)
            .field(NewsModel.FieldKeys.content, .string, .required)
            .create()
    }
    
    func revert(on db: Database) -> EventLoopFuture<Void> {
        db.schema(NewsModel.schema).delete()
    }
}

This migration script will create the required fields inside the news table and if necessary we can revert the process by deleting the entire table.

Next, we have to alter the NewsModule.swift file just a little bit:

import Vapor
import Fluent
import ViperKit

final class NewsModule: ViperModule {

    static var name: String = "news"

    var router: ViperRouter? { NewsRouter() }

    func boot(_ app: Application) throws {
        app.databases.middleware.use(FrontendContentModelMiddleware<NewsModel>())
    }
    
    var migrations: [Migration] {
        [
            NewsMigration_v1_0_0(),
        ]
    }
}

We have a proper name for the module and the data structure is mostly ready to serve our news entries. We are using a special middleware to create a relationship between the frontend module and our news module. This is necesssary, because we want to be able to display news as standalone pages with distinct URLs (permalinks) on our website.

The associated FrontendContentModel object is going to be updated every time we change our news entry. We still have to write an extension on our news model to implement the delegate protocol. Create a NewsModel+Content.swift file next to the model file.

import Vapor
import Fluent
import ViperKit

extension NewsModel: FrontendContentModelDelegate {
    
    var slug: String { self.title.slugify() }
    
    func willUpdate(_ content: FrontendContentModel) {
        content.slug = self.slug
        content.title = self.title
        content.excerpt = self.excerpt
        content.imageKey = self.imageKey
    }
}

Now we should provide a dedicated space for this module inside the admin interface, so we can create, update and eventually delete some news, before we actually write our form, we are going to extend the model one more time.

import Vapor
import ViewKit

extension NewsModel: ViewContextRepresentable {

    struct ViewContext: Encodable {
        var id: String
        var title: String
        var imageKey: String
        var excerpt: String
        var content: String

        init(model: NewsModel) {
            self.id = model.id!.uuidString
            self.title = model.title
            self.imageKey = model.imageKey
            self.excerpt = model.excerpt
            self.content = model.content
        }
    }

    var viewContext: ViewContext { .init(model: self) }
}

The ViewContextRepresentable protocol is part of the ViewKit framework, it allows us to render models on the frontend by using an Encodable representation of the database model. This is going to help us a lot when we list and edit our entries.

Content management

In Feather CMS you can create a Form to provide an admin interface for a given model. This object can be rendered using the view layer (using Leaf in most of the cases) with the help of the ViewKit framework, also it is going to handle all the incoming submission events.

import Vapor
import ViewKit

final class NewsEditForm: Form {

    typealias Model = NewsModel

    struct Input: Decodable {
        var id: String
        var title: String
        var excerpt: String
        var content: String

        var image: File?
        var imageDelete: Bool?
    }

    var id: String? = nil
    var title = BasicFormField()
    var excerpt = BasicFormField()
    var content = BasicFormField()
    var image = FileFormField()
    
    var notification: String?
    var contentModel: FrontendContentModel.ViewContext?

    func initialize() {
    }

    init() {
        self.initialize()
    }
    
    init(req: Request) throws {
        self.initialize()

        let context = try req.content.decode(Input.self)
        self.id = context.id.emptyToNil

        self.title.value = context.title
        self.excerpt.value = context.excerpt
        self.content.value = context.content

        self.image.delete = context.imageDelete ?? false
        if let image = context.image {
            if let data = image.data.getData(at: 0, length: image.data.readableBytes), !data.isEmpty {
                self.image.data = data
            }
        }
    }

    func read(from model: Model)  {
        self.id = model.id?.uuidString
        self.title.value = model.title
        self.image.value = model.imageKey
        self.excerpt.value = model.excerpt
        self.content.value = model.content
    }
    
    func write(to model: Model) {
        model.title = self.title.value
        if !self.image.value.isEmpty {
            model.imageKey = self.image.value
        }
        model.excerpt = self.excerpt.value
        model.content = self.content.value
    }

    func validate(req: Request) -> EventLoopFuture<Bool> {
        var valid = true
        if self.title.value.isEmpty {
            self.title.error = "Title is required"
            valid = false
        }
        return req.eventLoop.future(valid)
    }
}

A Form has a nested helper object called Input which can be used to parse the required data from the incoming request. The fields are predefined objects in the ViewKit framework, a basic field can be something that encapsulates a string value and an optional error message, these fields are not necessary view representations, but more like data transfer objects. We are going to put the actual view into a separate Leaf template file later on.

A form can also read data from an existing model and write the values into another, the nice thing is that forms can validate fields asynchronously so you can perform additional database checks if you have special needs before you actually save a model.

The main reason why forms exists is that I wanted to separate responsibilities. A form can help the controller to display a screen inside the CMS using a model, this way our files will be smaller, cleaner and if needed they can be tested more easily.

We still need a controller and a router to provide the necessary URLs that we can use to hook-up the admin interface. We are going to need one URL that can be reached through a GET request for rendering the form, another one that can be used to POST (submit) the form. Also we need URLs for listing the available news, plus we can add support for deleting entries.

ViperKit can help us a lot with the necessary setup, create a new NewsAdminController.swift file and put the following code inside of it, I'll explain everything below:

import Vapor
import Fluent
import ViperKit

final class NewsAdminController: ViperAdminViewController {
    typealias Module = NewsModule
    typealias Model = NewsModel
    typealias EditForm = NewsEditForm
    
    // MARK: - api
    
    var listSortable: [FieldKey] {
        [
            Model.FieldKeys.title,
        ]
    }

    var listOrder: String { "asc" }

    func search(using qb: QueryBuilder<Model>, for searchTerm: String) {
        qb.filter(\.$title ~~ searchTerm)
    }
    
    private func path(_ model: Model) -> String {
        Model.path + model.id!.uuidString + ".jpg"
    }

    func beforeRender(req: Request, form: EditForm) -> EventLoopFuture<Void> {
        var future: EventLoopFuture<Void> = req.eventLoop.future()
        if let id = form.id, let uuid = UUID(uuidString: id) {
            future = FrontendContentModel.query(on: req.db)
                .filter(\.$module == Module.name)
                .filter(\.$model == Model.name)
                .filter(\.$reference == uuid)
                .first()
                .map { form.contentModel = $0?.viewContext }
        }
        return future
    }
 
    func beforeCreate(req: Request, model: Model, form: EditForm) -> EventLoopFuture<Model> {
        model.id = UUID()
        var future: EventLoopFuture<Model> = req.eventLoop.future(model)
        if let data = form.image.data {
            let key = self.path(model)
            future = req.fs.upload(key: key, data: data).map { url in
                model.imageKey = key
                return model
            }
        }
        return future
    }
    
    func beforeUpdate(req: Request, model: Model, form: EditForm) -> EventLoopFuture<Model> {
        let key = self.path(model)
        var future: EventLoopFuture<Model> = req.eventLoop.future(model)
        if
            (form.image.delete || form.image.data != nil),
            FileManager.default.fileExists(atPath: req.fs.resolve(key: key))
        {
            future = req.fs.delete(key: key).map { model }
        }
        if let data = form.image.data {
            return future.flatMap { model in
                return req.fs.upload(key: key, data: data).map { url in
                    model.imageKey = key
                    return model
                }
            }
        }
        return future
    }

    func beforeDelete(req: Request, model: Model) -> EventLoopFuture<Model> {
        req.fs.delete(key: self.path(model)).map { model }
    }
}

First we define some fields that are going to be used in the list view, this will allow us to order, and search by title.

The beforeRender method will look up for an associated FrontendContentModel object. This will allow us to present news on the web frontend. In Feather every user facing page is a frontend content, and it needs to have such an associated model. I'll tell you more about this later on.

Before we create, update or delete our model we want to save an image into the file storage, using the Liquid framework. Liquid allows us to save files into a local or cloud storage through the req.fs property, it's pretty simple to use, in this example I'm using the values from the edit form to check whether I need to upload or delete the picture of the news (I'll add some extension methods later on to make this a one-liner... or if you have time feel free to send me a PR. 😅).

That's it about the controller, the ViperAdminViewController protocol will give us all the required method implementation that we're going to hook-up using our news router.

import Vapor
import ViperKit

final class NewsRouter: ViperRouter {

    let adminController = NewsAdminController()

    func hook(name: String, routes: RoutesBuilder, app: Application) throws {
        switch name {
        case "protected-admin":
            let module = routes.grouped(NewsModule.pathComponent)
            self.adminController.setupRoutes(routes: module, on: NewsModule.pathComponent)
        default:
            break;
        }
    }
}

We are using an instance from the previously created controller to hook-up routes to the protected admin area. The setup routes method will add everything needed for a complete CMS experience.

Views for managing the content

We only have to provide two Leaf templates to support news administration. The first one is going to be the one responsible for listing available news entries. Inside the news module create a List.html file under the Views/Admin/News directory.

#extend("Admin/Table"):
    #export("title", "News")
    #export("toolbar-buttons", "")

    #export("th"):
        <tr>
            <th><a href="#sortQuery("image_key")">Image #sortIndicator("image_key")</a></th>
            <th><a href="#sortQuery("title")">Title #sortIndicator("title")</a></th>
            <th class="actions">Actions</th>
        </tr>
    #endexport

    #export("tb"):
        #for(item in items):
            <tr>
                <td class="image-large"><a href="#path()#(item.id)/"><img src="#resolve(item.imageKey)"></a></td>
                <td><a href="#path()#(item.id)/">#(item.title)</a></td>
                #extend("Admin/Actions"):#endextend
            </tr>
        #endfor
    #endexport
#endextend

The list derives from the Admin/Table template, you have to export a few things to make it work such as the title and additional toolbar-buttons if you need some. The tr and tb sections will represent your news items in the list view, you can use some special leaf tags to support sorting via query parameters, you can use the path tag to read the current URL and the resolve tag to display image links returned by the Liquid file storage library.

Create one more file called Edit.html next to the other Leaf template.

#extend("Admin/Form"):
    #export("title", "News")
    #export("toolbar-buttons"):
        #if(edit.contentModel != nil):
        <li><a href="/admin/frontend/contents/#(edit.contentModel.id)"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-feather"><path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"></path><line x1="16" y1="8" x2="2" y2="22"></line><line x1="17.5" y1="15" x2="9" y2="15"></line></svg></a></li>

        <li><a href="/#(edit.contentModel.slug)/" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-eye"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg></a></li>

        #endif
    #endexport
    #export("fields"):
        #extend("Admin/FormFields/Input"):
            #export("required", true)
            #export("name", "title")
            #export("label", "Title")
            #export("error", edit.title.error)
            #export("value", edit.title.value)
        #endextend
        #extend("Admin/FormFields/Textarea"):
            #export("size", "small")
            #export("name", "excerpt")
            #export("label", "Excerpt")
            #export("error", edit.excerpt.error)
            #export("value", edit.excerpt.value)
        #endextend
        #extend("Admin/FormFields/File"):
            #export("accept", "image/jpeg")
            #export("name", "image")
            #export("label", "Image")
            #export("error", edit.image.error)
            #export("value", edit.image.value)
        #endextend
        #extend("Admin/FormFields/Textarea"):
            #export("size", "large")
            #export("name", "content")
            #export("label", "Content")
            #export("error", edit.content.error)
            #export("value", edit.content.value)
        #endextend
    #endexport
#endextend

Inside the edit template we export a title and some toolbar buttons to help managing our linked content. We are also using predefined form fields to render the edit form. You can check all the available form field options under views folder inside the admin module.

Now we are ready to manage news using the admin interface, we just have to put a link to the dashboard to reach our module. Inside the Sources/App/Modules/Admin/Views folder open the Home.html file and add a new grid-item block similiar to this:

<div class="grid-item">
    <div>
        <span><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-smile"><circle cx="12" cy="12" r="10"></circle><path d="M8 14s1.5 2 4 2 4-2 4-2"></path><line x1="9" y1="9" x2="9.01" y2="9"></line><line x1="15" y1="9" x2="15.01" y2="9"></line></svg></span>
        <h3>News</h3>
        <ul>
            <li><a href="/admin/news/news/">News</a></li>
        </ul>
    </div>
</div>

Now build and run the server and you should be able to create some news.

The frontend-page hook

There is one more thing to do. If we want to display news as a dedicated page we have to implement a hook function in our module called frontend-page hook. This frontend-page hook makes it possible to respond to dynamic URLs (slugs) using the FrontendContentModel object. This way we can display a news item in a more or less SEO friendly way (at least it'll have a nice permalink).

import Vapor
import Fluent
import ViperKit
import ViewKit

final class NewsModule: ViperModule {

    //...
    
    func invoke(name: String, req: Request, params: [String : Any] = [:]) -> EventLoopFuture<Any?>? {
        switch name {
        case "frontend-page":
            return self.frontendPageHook(req: req)
        default:
            return nil
        }
    }

    private func frontendPageHook(req: Request) -> EventLoopFuture<Any?>? {
        return NewsModel.joinedFrontendContentQuery(on: req.db, path: req.url.path)
            .filter(FrontendContentModel.self, \.$status != .archived)
            .first()
            .flatMap { news -> EventLoopFuture<Response?> in
                guard let news = news, let content = try? news.joined(FrontendContentModel.self) else {
                    return req.eventLoop.future(nil)
                }
                var ctx = news.viewContext
                ctx.content = content.filter(ctx.content, req: req)
                return req.view.render("News/Frontend/News", HTMLContext(content.headContext, ctx))
                    .encodeResponse(for: req).map { $0 as Response? }
            }
            .map { $0 as Any }
    }
}

The very last step is to rename the Example.leaf file inside our News/Views/Frontend directory to News.html and make some layout for the entry that we would like to render on the web. In the page hook we've used the HTMLContext object to pass around the news item as a view context via the body.

#extend("Frontend/Index"):
    #export("body"):

        <article>
            <header>
                <section>
                    <h1 class="title">#(body.title)</h1>
                    <p class="excerpt">#(body.excerpt)</p>
                </section>
                <figure id="cover-image" style="background-image: url(#resolve(body.imageKey));"></figure>
            </header>

            #(body.content)

        </article>

    #endexport
#endextend

That's pretty much it, now if you click the preview icon on the toolbar you should be able to see your news on the frontend. As a gratis you should be able to apply content filters to your news item, so you can take advantage of the built-in markdown or the Swift syntax highlighter filters.


Summary

In this tutorial we've created a brand new module using a plenty of frameworks and tools. This can be hard at first sight, but I really love this approach because I can focus on defining my business model instead of taking care of smaller details such as registering the required routes for editing a database entry. Feather CMS will hide this complexity and provide dynamic extension points for building your admin interface. On the frontend side you can easily extend the dynamic routing system, apply content filters and even add your own extension points through hook functions.

There is so much more to talk about, but this time I'll stop right here, if you enjoyed this tutorial please follow me on twitter, subscribe to my newsletter or consider supporting me by purchasing my Practical Server Side Swift book on Gumroad.

Share this article on Twitter.
Thank you. 🙏

Picture of Tibor Bödecs

Tibor Bödecs

Creator of https://theswiftdev.com (weekly Swift articles), server side Swift enthusiast, full-time dad. -- Follow me & feel free to say hi. 🤘🏻 -- #iOSDev #SwiftLang

Twitter · GitHub


📬

100% Swift news, delivered right into your mailbox

Subscribe to my monthly newsletter. On the first Monday of every month, you'll get an update about the most important Swift community news, including my articles.