-
Notifications
You must be signed in to change notification settings - Fork 55
Core concepts
The main components of Feather CMS are distributed as standalone packages using the Swift Package Manager.
Feather CMS was created using a modular event-driven design in mind. This means that the core system has a very small footprint and mainly focuses on the basic components rather than higher level business logic.
This package contains all the shared code that is required to build a module for Feather CMS.
The core system is built on top of Vapor 4 and some other packages that allows developers to build modular backends.
The FeatherCore package exposes all the necessary package requirements, so you only have to import FeatherCore
when building a new module, one exception is the Fluent ORM package, when you have to work with query operators you might have to writeimport Fluent
as well.
The core system provides following components:
- Database Abstraction Layer: Fluent ORM
- Abstract File Storage: Liquid FS
- Hook system
- Dynamic routing
- Frontend metadata interface
- Module API - ViperKit
- REST API support - ContentApi
- Template system - ViewKit
- Leaf extensions - Leaf Foundation
- JWT support (as part of Vapor 4)
Feather also comes with modules, there are just a few that are pretty much required for every project. We call them core modules and they provide basic functions such as the admin interface or system functions.
- System - Basic system management functions (installer, variables)
- User - User management and access control system
- Admin - Basic admin interface components and dynamic route support
- Api - A simple REST API interface layer with dynamic route support
- Frontend - The backbone of the web-based frontend interface
By default you should use these modules since they provide relatively basic functions. It is possible to run Feather without the core modules or only use the ones that you need for your project.
For example if you are planning to build a headless CMS (without the frontend) you can remove the frontend module and keep the functions you need (Api, Admin, User, System).
Another example is a public Api project based on JSON files located on the server. In this case you might want to remove both the System, User, Admin and Frontend modules and only go with the Api.
A custom Feather application very similar to a standard Swift package. First you need a manifest file that defines all your dependencies and build targets. Consider the following Package.swift
file:
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "feather",
platforms: [
.macOS(.v10_15)
],
products: [
.executable(name: "Feather", targets: ["Feather"]),
],
dependencies: [
/// drivers
.package(url: "https://github.com/vapor/fluent-sqlite-driver", from: "4.0.0"), .package(url: "https://github.com/binarybirds/liquid-local-driver", from: "1.2.0-beta"),
/// feather core
.package(url: "https://github.com/FeatherCMS/feather-core", from: "1.0.0-beta"),
/// core modules
.package(url: "https://github.com/FeatherCMS/system-module", from: "1.0.0-beta"),
.package(url: "https://github.com/FeatherCMS/user-module", from: "1.0.0-beta"),
.package(url: "https://github.com/FeatherCMS/api-module", from: "1.0.0-beta"),
.package(url: "https://github.com/FeatherCMS/admin-module", from: "1.0.0-beta"),
.package(url: "https://github.com/FeatherCMS/frontend-module", from: "1.0.0-beta"),
],
targets: [
.target(name: "Feather", dependencies: [
/// drivers
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "LiquidLocalDriver", package: "liquid-local-driver"),
/// feather
.product(name: "FeatherCore", package: "feather-core"),
/// core modules
.product(name: "SystemModule", package: "system-module"),
.product(name: "UserModule", package: "user-module"),
.product(name: "ApiModule", package: "api-module"),
.product(name: "AdminModule", package: "admin-module"),
.product(name: "FrontendModule", package: "frontend-module"),
]),
]
)
All the Swift source files should be located under the Sources
directory under a given target name. In this case we have an executable target with one simple file called main.swift
inside the Feather
folder.
import FeatherCore
import FluentSQLiteDriver
import LiquidLocalDriver
import SystemModule
import UserModule
import ApiModule
import AdminModule
import FrontendModule
Feather.metadataDelegate = FrontendMetadataDelegate()
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let feather = try Feather(env: env)
defer { feather.stop() }
/// configure Feather using the db driver, fs driver and the core module builders
try feather.configure(database: .sqlite(.file("db.sqlite")),
databaseId: .sqlite,
fileStorage: .local(publicUrl: Application.baseUrl, publicPath: Application.Paths.public, workDirectory: "assets"),
fileStorageId: .local,
modules: [
SystemBuilder(),
UserBuilder(),
ApiBuilder(),
AdminBuilder(),
FrontendBuilder(),
])
if feather.app.isDebug {
try feather.reset(resourcesOnly: true)
}
try feather.start()
Feather will copy all the required assets to the right place when you start the server at the first time.
Public files and resources are bundled within modules by default, so if you need custom templates, assets or style sheets feel free to alter the contents of the Public and Resources directories (don't forget to commit the changes into your git repository).
Recommended folder structure for your project:
-
Public - publicly available resources, such as images, CSS & JS files
- assets - default storage for the local file storage driver (uploaded file storage)
- css - public css files
- images - public images
- javascript - public javascript files
-
Resources - Location of the private resource files
- Templates - Leaf template files (used to render various outputs)
-
Sources - Swift source files for the server application
- Modules - place local modules into this folder
- Tests - Test targets for your application
If you want a fresh start, feel free to delete the Public and Resources folders, drop your database and simply restart Feather, the system will re-create everything that is needed to run the server.
Just like any Vapor application, Feather also supports multiple environments. Feather is a bit more strict about environments, this means that you always have to define two environmental variables.
- BASE_URL - The base URL of your app server (e.g. http://0.0.0.0:8080)
- BASE_PATH - The absolute path to the app (e.g. /Users/[me]/feather/)
The BASE_URL
is used because there is no easy way to resolve the current domain based on an incoming request, this variable is always available as a static property on the Application object.
The BASE_PATH
helps us with directory configuration. Feather also provides absolute path variables (always with a trailing slash) and locations (relative folder names with trailing slash) under the Application struct.
Application.baseUrl // shorthand for the "BASE_URL" env variable
Application.Paths.base // shorthand for the "BASE_PATH" env variable
In order to use Feather you always have to define these two environment variables. The most simple way is to create a dotenv file (.env
or .env.development
based on your needs) using the following format:
BASE_URL="http://0.0.0.0:8080"
BASE_PATH="/Users/[me]/feather/"
If you are using Xcode don't forget to set the custom working directory for your target.
You can read more about environments in the official Vapor docs.
Database and file storage drivers are NOT part of the core system, you have to add the required ones as a Swift package dependency when creating your project. The following divers are available to use:
Database (Fluent) drivers:
- PostgreSQL
- SQLite
- MySQL
- MariaDB
- MongoDB
File storage (Liquid) drivers:
- Local
- AWS S3
You should use the Feather
struct to create, configure and run a new application.
Here's how you can setup your own drivers using the Swift package manager:
/// project dependencies
.package(url: "https://github.com/vapor/fluent-sqlite-driver", from: "4.0.0"),
.package(url: "https://github.com/binarybirds/liquid-local-driver", from: "1.2.0-beta"),
.package(url: "https://github.com/FeatherCMS/feather-core", from: "1.0.0-beta"),
/// target dependencies
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "LiquidLocalDriver", package: "liquid-local-driver"),
.product(name: "FeatherCore", package: "feather-core"),
Then inside your main.swift
file:
import FeatherCore
import FluentSQLiteDriver
import LiquidLocalDriver
try feather.configure(database: .sqlite(.file("db.sqlite")),
databaseId: .sqlite,
fileStorage: .local(publicUrl: Application.baseUrl, publicPath: Application.Paths.public, workDirectory: "assets"),
fileStorageId: .local,
modules: [])
The hook system is a generic event processing system written in Swift for Feather CMS. This event-driven architecture pattern allows us to communicate with modules without forming actual dependencies.
Instead of utilizing dependencies, protocols and types the hook system allows us to create events and hook into those "extension points" later on.
Every module can register hook function handlers by using the boot method.
For example the system module provides various install events that you can use to create your own database the first time you run Feather in your browser.
func boot(_ app: Application) throws {
/// register the model install hoook
app.hooks.register("model-install", use: modelInstallHook)
}
func modelInstallHook(args: HookArguments) -> EventLoopFuture<Void> {
let req = args["req"] as! Request
/// install the necessary models using the req object and return a Void future
return req.eventLoop.future()
}
In this case the system module will call the "model-install" hook when it's ready to install Fluent models. The HookArguments
type is just a key-value dicitionary alias. Usually you can access the request under the req
key and the application object using the app
key.
Hooks are generic functions, the return type of the hook is specified during the invokation.
You can create your own hook event by invoking one or multiple registered hook functions.
/// invoke all hooks to return the model install futures
let modelInstallFutures: [EventLoopFuture<Void>] = req.invokeAll("model-install")
/// invoke all dynamic admin route hooks with a routes parameter
let _: [Void] = app.invokeAll("admin", args: ["routes": protectedAdmin])
/// invoke the variable-get hook (returns only the first hook) with a given key
let result: EventLoopFuture<String?>? = req.invoke("variable-get", args: ["key": key])
As you can see there are two methods available on the Request
and Application
objects.
- The
invoke
method only returns the very first instance of the registered hooks - The
invokeAll
method returns all the registered hooks as an array of objects
You always have to specify the return type of a given hook and you can also pass additional arguments using the arg
parameter. It is also possible to use the first result of an optional EventLoopFuture, there is a helper method called findFirstValue
if you need such functionality.
let futures: [EventLoopFuture<Response?>] = req.invokeAll("frontend-page")
return req.eventLoop.findFirstValue(futures).unwrap(or: Abort(.notFound))
This is useful when you depend on multiple database queries and you want to return the right object based on the circumstances, like the system handles the frontend page for a given slug.
Many hooks are available to use in Feather CMS and as you can see you can create your own extension points. You can read more about how these functions work under the hood if you read this blog post.
Vapor 4 has a static route system by default. This approach is great for many use cases, but Feather CMS enables us to register dynamic routes based on varius conditions using hook functions.
If you want to use the dynamic route system first you have to register a router hook function.
func boot(_ app: Application) throws {
/// base routes
app.hooks.register("routes", use: routesHook)
/// session protected admin routes hook
app.hooks.register("admin", use: adminRoutesHook)
/// private api routes hook
app.hooks.register("api", use: privateApiRoutesHook)
/// public api routes hook
app.hooks.register("public-api", use: publicApiRoutesHook)
}
There are multiple route hooks available the core modules provide the following routes:
- routes - dynamic base route
- admin - session protected /admin/ endpoints
- api - publicly available /api/ endpoints
- public-api - token protected /api/ endpoints
Inside the routes hook you can access the RoutesBuilder
object under the routes
key:
func routesHook(args: HookArguments) {
let app = args["app"] as! Application
let routes = args["routes"] as! RoutesBuilder
let middlewares: [[Middleware]] = app.invokeAll("admin-auth-middlewares")
/// groupd admin routes, first we use auth middlewares then the error middleware
let protectedAdmin = routes.grouped("admin").grouped(AdminErrorMiddleware()).grouped(middlewares.flatMap { $0 })
/// setup home view (dashboard)
protectedAdmin.get(use: adminController.homeView)
/// hook up other admin views that are protected by the authentication middleware
let _: [Void] = app.invokeAll("admin", args: ["routes": protectedAdmin])
}
As you can see you can use the standard Vapor methods on the builder to setup new endpoints or you can use the hook system to extend your routes with additional middlewares or provide further extension point for other modules.
A routes hook function has no return type, just simply register the required endpoints.
Of course if you don't need the dynamic route system, you can use the original boot method of the RouteCollection
object (part of Vapor) to register your static routes.
func boot(routes: RoutesBuilder) throws {
/// register sitemap and rss routes
routes.get("sitemap.xml", use: frontend.sitemap)
routes.get("rss.xml", use: frontend.rss)
routes.get("robots.txt", use: frontend.robots)
}
Route handlers are always standard functions with a Request
argument and a Response
return type.
The static route system is only good for the task when we work with a fixed set of URLs, but since Feather is a dynamic CMS we should be able to register new pages on the fly. The dynamic route system helps us to eliminate this issue so we can build frontends more easy.
When you render a HTML page you might want to provide some additional info alongside the contents of the body tags. Modern websites use stylesheets (CSS) and JavaScript snippets to provide rich functionality to the end user. Search Engine Bots might index your pages, they can also use sitemaps to get a basic understanding of your site structure. Many people prefer RSS readers to get updated from multiple sources at once, so you might want to include your content in a dynamically generated feed. Modern social media websites can also generate a preview of your content based on a title, short description, image and maybe some other details.
This is where the frontend metadata interface can help us. The frontend metadata interface is used to define publicly available content pages for your website. You can think of a Metadata
object as an additional information provider for a given page. The frontend core module provides a base template that you can use to render HTML page bodies inside a "main frame", but we still have to provide the necessary meta description, that's why we have an abstraction layer in the FeatherCore module, so we can use a module independent Metadata
object that's available for everyone to use.
For example if you want to build a news module, you can use the frontend module and provide dynamic news items with SEO friendly URLs, without the need of reimplementing the entire Metadata
structure. This way you can focus on your own model structure instead of thinking too much about the web. The metadata interface will provide all the necessary properties to safely render frontend pages.
struct Metadata: LeafDataRepresentable {
public enum Status: String, CaseIterable, Codable {
/// drafts are only visible through the admin interface
case draft
/// published articles can be indexed and they are visible for everyone
case published
/// archives are not visible nor indexed
case archived
}
var id: UUID? /// unique identifier of the metadata
var module: String? /// referenced module name
var model: String? /// referenced model name
var reference: UUID? /// referenced model identifier
var slug: String? /// slug without leading and trailing slashes
var status: Status? /// status of the metadata
var title: String? /// meta title of the content (SEO, social media, feeds, etc.)
var excerpt: String? /// meta description (SEO, social media, feeds, etc.)
var imageKey: String? /// preview image (SEO, social media, feeds, etc.)
var date: Date? /// publish date (SEO, social media, feeds, etc.)
var feedItem: Bool? /// if true content will be included in the RSS feed
var canonicalUrl: String? /// original content reference URL string (SEO)
var filters: [String]? /// enabled Feather content filters
var css: String? /// custom CSS for the content
var js: String? /// custom JS for the content
}
Imagine that you create a NewsModel
Fluent database object, if you want to use that as a base object for frontend pages you'll have to conform to the MetadataRepresentable
protocol.
extension NewsModel: MetadataRepresentable {
var metadata: Metadata {
.init(slug: title.slugify(), title: title, excerpt: excerpt, imageKey: imageKey)
}
}
Now if you modify a NewsModel
using the CMS or a code snippet, you might want to reflect those changes to the associated Metadata
model as well. This is why you have to add an new MetadataModelMiddleware
instance to the database middlewares with the given object type.
func boot(_ app: Application) throws {
app.databases.middleware.use(MetadataModelMiddleware<NewsModel>())
}
This database middleware will ensure that the associated metadata is created, updated or removed from the persistent storage if needed. Metadata objects are automatically managed by the frontend core module. Using the metadata API is highly recommended for creating dynamic pages.
Almost everything can be replaced in Feather CMS you can even create your own MetadataDelegate
object that can handle the entire metadata management process. By default the frontend module provides one implementation that fetches, saves, updates and removes metadata objects under the hood.
Feather.metadataDelegate = FrontendMetadataDelegate()
You should always set the metadataDelegate
property before you run Feather, otherwise you won't be able to use the metadata interface. You can also build your own metadata delegate if you want to replace the core implementation that's part of the frontend module.