[SSS] Displaying posts and extending Queries
This is a bit tricky, since my routes for posts and pages are the same, and I differentiate between the two if I can create an Int out of the lastPathComponent. I know it's not the best approach, but since URLs should be permanent, I never moved to a /page/x structure. I also kind of dislike that structure ¯\_(ツ)_/¯.
In the first post in this series, I briefly presented the Droplet extension, with a very basic addRoutes method, just to present the methods in the extension itself. Let's give it a few routes:
extension Droplet {
func addRoutes() -> Droplet {
get("/feed", handler: FeedController.create)
get("/about", handler: AboutController.display)
// [...]
// Both these methods are resolved by the same method within `PageController`,
// but in the first case, a parameter named `id` can also be extracted.
get("/", ":id", handler: PageController.display)
get("/", handler: PageController.display)
return self
}
}The display(with:) method of our PageController looks like this:
import Vapor
import HTTP
import VaporPostgreSQL
struct PageController {
// This has the same signature as the `handler` in the Droplet's `get` method
static func display(with request: Request) throws -> ResponseRepresentable {
let params = request.parameters
// If any query params are present (who does that, even?),
// just redirect to an URL without them.
// If we can convert the `id` parameter to an `Int`, then we are asking for a page.
if let page = params["id"]?.int {
// If the page is 0, 1, or less than 0, we just redirect to `/`.
guard page > 1 else { return request.rootRedirect }
guard request.uri.query?.isEmpty != false else {
return Response(headers: request.headers, redirect: "/\(page)")
}
// Return the required page.
return try display(page: page, with: request)
}
// If we can not convert it to an `Int`, but we can convert it to a `String`, it's a post.
else if let id = params["id"]?.string {
guard request.uri.query?.isEmpty != false else {
return Response(headers: request.headers, redirect: "/\(id)")
}
// Let PostController take it from here.
return try PostController.display(with: request, link: id)
}
// Otherwise we asked for the root.
else if request.uri.path == "/" {
if request.uri.query?.isEmpty == false {
return request.rootRedirect
}
// Return the first page.
return try display(page: 1, with: request)
}
// Let NotFoundController take it from here, which simply displays the `404` leaf.
return try NotFoundController.display(with: request)
}
}The display(page:with:) method doesn't do much, it just creates a dictionary of parameters, passes everything to a ViewRenderer extension method, which, in turn, displays the article-list leaf:
struct PageController {
// [...]
private static func fetchPosts(for page: Int, with request: Request) -> [Post] {
let posts = try? Post.makeQuery().sorted().paginated(to: page).all()
return posts ?? []
}
private static func display(page: Int, with request: Request) throws -> ResponseRepresentable {
// If no posts our found, go to `404`.
guard case let posts = fetchPosts(for: page, with: request), !posts.isEmpty else {
return try NotFoundController.display(with: request)
}
// We will need the total number of posts, so we can calculate the total number of pages.
let totalPosts = try Post.query().count()
let params: [String: NodeRepresentable] = [
"title": "Roland Leth",
"metadata": "iOS, Ruby, Node and JS projects by Roland Leth.",
"root": "/",
"page": page
]
return try drop.view.showResults(with: params,
for: request,
posts: posts,
totalPosts: totalPosts)
}
}We proxy through the showResults method, because displaying search results makes use of the same leaf, and requires a set of common parameters, as we can see below:
extension ViewRenderer {
func showResults(with params: [String: NodeRepresentable], for request: Request, posts: [Post], totalPosts: Int) throws -> ResponseRepresentable {
let baseParams: [String: NodeRepresentable] = [
"gap": 2, // This and the one below are required for creating the pagination control.
"doubleGap": 4,
"posts": posts, // The required posts.
"pages": Int((Double(totalPosts) / Double(drop.postsPerPage)).rounded(.up)), // The number of pages.
"showPagination": totalPosts > drop.postsPerPage // Determines whether we show the navigation control or not.
]
let params = params + baseParams
return try make("article-list", with: params, for: request)
}
func make(_ path: String, with params: [String: NodeRepresentable], for request: Request) throws -> View {
let footerParams: [String: NodeRepresentable] = [
"quote": quote,
"emoji": emoji,
"fullRoot": request.domain,
"trackingId": drop.production ? "UA-40255117-4" : "UA-40255117-5",
"production": drop.production,
"year": Calendar.current.component(.year, from: Date())
]
let metadataParams: [String: NodeRepresentable] = [
"path": request.pathWithoutTrailingSlash,
"metadata": params["title"] as? String ?? "" // Will be overwritten if it exists in the next step
]
let params = footerParams + metadataParams + params
return try make(path, params, for: request)
}
}The make(_:with:for) method in the extension is used throughout the app instead of the default make(_:_:for:) so we can pass common parameters to all pages, required in the head and footer, for example.
The PostController is rather short, it just displays the post leaf:
import Vapor
import HTTP
import VaporPostgreSQL
struct PostController {
private static func fetchPost(with link: String) throws -> Post {
// Do a query to fetch all posts mathing the current `link` passed in (our URL's `lastPathComponent`, basically).
let query = try Post.makeQuery().filter("link", .equals, link)
guard
let result = try? query.first(),
let post = result
else { throw Abort.notFound }
return post
}
static func display(with request: Request, link: String) throws -> ResponseRepresentable {
do {
let post = try fetchPost(with: link)
let params: [String: NodeRepresentable] = [
"title": post.title,
"post": post,
"singlePost": true]
// Using the same extension method mentioned earlier.
return try drop.view.make("post", with: params, for: request)
}
catch {
// If anything goes wrong, display the `404`.
return try NotFoundController.display(with: request)
}
}
}Lastly, you probably wondered about:
private static func fetchPosts(for page: Int, with request: Request) -> [Post] {
// I do it like this, because I don't want to handle errors, I just care whether it succeeded or not.
let posts = try? Post.makeQuery().sorted().paginated(to: page).all()
return posts ?? []
}Those are just a couple of Query extensions, where Query is an abstract database query model, much friendlier to reason with than raw queries, and run() does just that, it runs the query:
import Fluent
extension Query {
func sorted(future: Bool = false) throws -> Query {
let q = try self
.sort("datetime", .descending)
.sort("title", .ascending)
// Equivalent to the raw `ORDER BY datetime DESC, title ASC`
// This is a flag which I use internally, for when I sometimes write posts with a future date, want to have them synced,
// but I don't want them to be displayed yet.
// I do want them in the sitemap.xml, though, thus this logic here.
if future { return q }
return try q.filteredPast()
}
func paginated(to page: Int) throws -> Query {
// Equivalent to the raw `LIMIT \(drop.postsPerPage) OFFSET \(drop.postsPerPage * (page - 1))`
return try limit(drop.postsPerPage, offset: drop.postsPerPage * (page - 1))
}
func filteredPast() throws -> Query {
// Equivalent to the raw `WHERE datetime <= '\(Post.datetime(from: Date())'`
return try filter("datetime", .lessThanOrEquals, Post.datetime(from: Date()))
}
}