[SSS] Displaying posts and extending Queries

:  ~ 5 min read

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()))
	}

}