feat: Initial commit
This commit is contained in:
514
content/articles/2025-01-05-vapor-htmx-todo-app.md
Normal file
514
content/articles/2025-01-05-vapor-htmx-todo-app.md
Normal file
@@ -0,0 +1,514 @@
|
||||
---
|
||||
tags: general, software, programming
|
||||
summary: Build an example application using Vapor and HTMX.
|
||||
---
|
||||
|
||||
# Vapor + HTMX
|
||||
|
||||
## Introduction
|
||||
|
||||
This post is a quick example of creating a very basic todo web application using `Vapor` a swift web
|
||||
framework and `Htmx`, with no custom javascript required.
|
||||
|
||||
[Vapor](https://docs.vapor.codes)
|
||||
|
||||
[Htmx](https://htmx.org)
|
||||
|
||||
## Getting Started
|
||||
|
||||
To get started you must install the vapor command-line tool that will generate our project.
|
||||
|
||||
```bash
|
||||
brew install vapor
|
||||
```
|
||||
|
||||
Next, generate the project using the vapor command-line tool.
|
||||
|
||||

|
||||
|
||||
```bash
|
||||
vapor new todo-htmx --fluent.db sqlite --leaf
|
||||
```
|
||||
|
||||
The above command will generate a new project that uses an `SQLite` database along with vapor's
|
||||
`Leaf` templating engine. You can move into the project directory and browse around the files that
|
||||
are generated.
|
||||
|
||||
```bash
|
||||
cd todo-htmx
|
||||
```
|
||||
|
||||
## Update the Controller
|
||||
|
||||
Open the `Sources/App/Controllers/TodoController.swift` file. This file handles the api routes for
|
||||
our `Todo` database model. Personally I like to prefix these routes with `api`.
|
||||
|
||||
Update the first line in the `boot(routes: RoutesBuilder)` function to look like this.
|
||||
|
||||
```swift
|
||||
let todos = routes.grouped("api", "todos")
|
||||
```
|
||||
|
||||
Everything else can stay the same. This changes these routes to be exposed at
|
||||
`http://localhost:8080/api/todos`, which will allow our routes that return html views to be able to
|
||||
be exposed at `http://localhost:8080/todos`.
|
||||
|
||||
## Update the Todo Model
|
||||
|
||||
A todo is not very valuable without a way to tell if it needs to be completed or not. So, let's add
|
||||
a field to our database model (`Sources/App/Models/Todo.swift`).
|
||||
|
||||
Update the file to include the following:
|
||||
|
||||
```swift
|
||||
import Fluent
|
||||
import struct Foundation.UUID
|
||||
|
||||
/// Property wrappers interact poorly with `Sendable` checking, causing a warning for the `@ID` property
|
||||
/// It is recommended you write your model with sendability checking on and then suppress the warning
|
||||
/// afterwards with `@unchecked Sendable`.
|
||||
final class Todo: Model, @unchecked Sendable {
|
||||
static let schema = "todos"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "title")
|
||||
var title: String
|
||||
|
||||
@Field(key: "complete")
|
||||
var complete: Bool
|
||||
|
||||
init() {}
|
||||
|
||||
init(id: UUID? = nil, title: String, complete: Bool) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.complete = complete
|
||||
}
|
||||
|
||||
func toDTO() -> TodoDTO {
|
||||
.init(
|
||||
id: id,
|
||||
title: $title.value,
|
||||
complete: $complete.value
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since we added a field to our database model, we also need to update the migration file
|
||||
(`Sources/App/Migrations/CreateTodo.swift`).
|
||||
|
||||
```swift
|
||||
import Fluent
|
||||
|
||||
struct CreateTodo: AsyncMigration {
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema("todos")
|
||||
.id()
|
||||
.field("title", .string, .required)
|
||||
.field("complete", .bool, .required)
|
||||
.create()
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
try await database.schema("todos").delete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This just adds our new field to the database schema when we run the migrations, which we will do
|
||||
later on in the tutorial.
|
||||
|
||||
### Update the Data Transfer Object
|
||||
|
||||
We also need to add the `complete` field to our data transfer object (`DTO`). This model is used as
|
||||
an intermediate between our database and the user.
|
||||
|
||||
```swift
|
||||
import Fluent
|
||||
import Vapor
|
||||
|
||||
struct TodoDTO: Content {
|
||||
var id: UUID?
|
||||
var title: String?
|
||||
var complete: Bool?
|
||||
|
||||
func toModel() -> Todo {
|
||||
let model = Todo()
|
||||
|
||||
model.id = id
|
||||
model.complete = complete ?? false
|
||||
if let title = title {
|
||||
model.title = title
|
||||
}
|
||||
return model
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Generate the View Templates
|
||||
|
||||
Our index template was already generated at `Resources/Views/index.leaf`, open the file and edit the
|
||||
contents to match the following.
|
||||
|
||||
> Note: You can learn more about the
|
||||
> [leaf templating engine here.](https://docs.vapor.codes/leaf/getting-started/)
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>#(title)</title>
|
||||
<link rel="stylesheet" href="css/main.css" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>#(title)</h1>
|
||||
<div class="container">
|
||||
<form hx-post="/todos" hx-target="#todos">
|
||||
<label for="title">Todo</label>
|
||||
<input type="text" name="title" placeholder="Title" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<!-- Todos List -->
|
||||
<table id="todos" class="todos" hx-get="/todos" hx-trigger="load"></table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
The important parts here are the `<script>` tag in the head element which will include `Htmx` in our
|
||||
project.
|
||||
|
||||
The head element also contains a link to a custom `css` stylesheet that we will create shortly.
|
||||
|
||||
We add a `form` element that will be used to generate a new todo item in the database. This is a
|
||||
basic / standard html form, but we are using `Htmx` to post the form contents to the route
|
||||
`POST http://localhost:8080/todos`, which we will create shortly.
|
||||
|
||||
Then there's the `table` element that will contain the contents of our todos. When the page is
|
||||
loaded it will use `Htmx` to fetch the todos from `GET http://localhost:8080/todos` route, which we
|
||||
will create shortly.
|
||||
|
||||
### Todos Table Template
|
||||
|
||||
Create a new view template that will return our populated table of todos.
|
||||
|
||||
```bash
|
||||
touch Resources/Views/todos.leaf
|
||||
```
|
||||
|
||||
The contents of this file should be the following:
|
||||
|
||||
```html
|
||||
<!-- Template for a list of todo's -->
|
||||
<table id="todos"
|
||||
class="todos">
|
||||
<!-- The table header -->
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Completed</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
||||
#for(todo in todos):
|
||||
<tr>
|
||||
<!-- Make the title column take up 90% of the width -->
|
||||
<td style="width: 90%;">#(todo.title)</td>
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
id="todo_#(todo.id)"
|
||||
hx-put="/todos/#(todo.id)"
|
||||
hx-trigger="click"
|
||||
hx-target="#todos"
|
||||
#if(todo.complete): checked #endif>
|
||||
</input>
|
||||
</td>
|
||||
<td>
|
||||
<button hx-delete="/todos/#(todo.id)"
|
||||
hx-trigger="click"
|
||||
hx-target="#todos"
|
||||
class="btn-delete-todo">
|
||||
X
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
#endfor
|
||||
</table>
|
||||
```
|
||||
|
||||
Here, we just create a table that is 3 columns wide from a list of todos that we will pass in to the
|
||||
template. We use `Htmx` to handle updating a todo if a user clicks a checkbox to mark the todo as
|
||||
`complete`, we also add a button in the last column of the table that we use `Htmx` to handle
|
||||
deleting a todo from the database.
|
||||
|
||||
## Controllers
|
||||
|
||||
The controllers handle the routes that our website exposes. The project template creates a
|
||||
controller for us that handles `JSON` / `API` requests, but we do need to make a couple of changes
|
||||
to the file (`Sources/App/Controllers/TodoController.swift`).
|
||||
|
||||
```swift
|
||||
import Fluent
|
||||
import Vapor
|
||||
|
||||
struct TodoController: RouteCollection {
|
||||
func boot(routes: RoutesBuilder) throws {
|
||||
let todos = routes.grouped("api", "todos")
|
||||
|
||||
todos.get(use: index)
|
||||
todos.post(use: create)
|
||||
todos.group(":todoID") { todo in
|
||||
todo.delete(use: self.delete)
|
||||
todo.put(use: self.update)
|
||||
}
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func index(req: Request) async throws -> [TodoDTO] {
|
||||
try await Todo.query(on: req.db).all().map { $0.toDTO() }
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func create(req: Request) async throws -> TodoDTO {
|
||||
let todo = try req.content.decode(TodoDTO.self).toModel()
|
||||
|
||||
try await todo.save(on: req.db)
|
||||
return todo.toDTO()
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func delete(req: Request) async throws -> HTTPStatus {
|
||||
guard let todo = try await Todo.find(req.parameters.get("todoID"), on: req.db) else {
|
||||
throw Abort(.notFound)
|
||||
}
|
||||
|
||||
try await todo.delete(on: req.db)
|
||||
return .noContent
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func update(req: Request) async throws -> TodoDTO {
|
||||
// let todo = try req.content.decode(TodoDTO.self).toModel()
|
||||
guard let todo = try await Todo.find(req.parameters.get("todoID"), on: req.db) else {
|
||||
throw Abort(.notFound)
|
||||
}
|
||||
todo.complete.toggle()
|
||||
try await todo.save(on: req.db)
|
||||
return todo.toDTO()
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
The primary changes here are to add the `update(req: Request)` function at the bottom, which handles
|
||||
updating a todo that has already been created. This will be used when a user clicks on the checkbox
|
||||
to mark a todo as complete or incomplete.
|
||||
|
||||
We also change the route in the `boot(routes: RoutesBuilder)` method to make all these routes
|
||||
accessible at `/api/todos` instead of the original `/todos` as we will use the `/todos` routes for
|
||||
returning our views from our view controller.
|
||||
|
||||
### Todo View Controller
|
||||
|
||||
Next we need to create our view controller, it is what will be used to handle routes that should
|
||||
return `html` content for our website. This controller will actually use the api controller to do
|
||||
the majority of it's work.
|
||||
|
||||
The easiest thing is to make a copy of the current api controller:
|
||||
|
||||
```bash
|
||||
cp Sources/App/Controllers/TodoController.swift Sources/App/Controllers/TodoViewController.swift
|
||||
```
|
||||
|
||||
Then update the file to the following:
|
||||
|
||||
```swift
|
||||
import Fluent
|
||||
import Vapor
|
||||
|
||||
struct TodoViewController: RouteCollection {
|
||||
|
||||
private let api = TodoController()
|
||||
|
||||
func boot(routes: RoutesBuilder) throws {
|
||||
let todos = routes.grouped("todos")
|
||||
|
||||
todos.get(use: index)
|
||||
todos.post(use: create)
|
||||
todos.group(":todoID") { todo in
|
||||
todo.delete(use: self.delete)
|
||||
todo.put(use: self.update)
|
||||
}
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func index(req: Request) async throws -> View {
|
||||
let todos = try await api.index(req: req)
|
||||
return try await req.view.render("todos", ["todos": todos])
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func create(req: Request) async throws -> View {
|
||||
_ = try await api.create(req: req)
|
||||
return try await index(req: req)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func delete(req: Request) async throws -> View {
|
||||
_ = try await api.delete(req: req)
|
||||
return try await index(req: req)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func update(req: Request) async throws -> View {
|
||||
_ = try await api.update(req: req)
|
||||
return try await index(req: req)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Here we use the api controller to do the heavy lifting of communicating with the database, then we
|
||||
just always return / render the `todos.leaf` template that we created earlier, which will update our
|
||||
web page with the list of todos retreived from the database.
|
||||
|
||||
> Note: There are better ways to handle this, however this is just a simple example.
|
||||
|
||||
### Update our routes
|
||||
|
||||
Next, we need to tell vapor to use our new view controller (`Sources/App/routes.swift`)
|
||||
|
||||
```swift
|
||||
import Fluent
|
||||
import Vapor
|
||||
|
||||
func routes(_ app: Application) throws {
|
||||
app.get { req async throws in
|
||||
try await req.view.render("index", ["title": "Todos"])
|
||||
}
|
||||
|
||||
app.get("hello") { _ async -> String in
|
||||
"Hello, world!"
|
||||
}
|
||||
|
||||
try app.register(collection: TodoController())
|
||||
try app.register(collection: TodoViewController())
|
||||
}
|
||||
```
|
||||
|
||||
Here, we just add the `TodoViewController` at the bottom so vapor will be able to handle those
|
||||
routes and also update the title to be `Todos` (in the first `app.get` near the top).
|
||||
|
||||
## Build and Run
|
||||
|
||||
At this point we should be able to build and run the application.
|
||||
|
||||
First, let's make sure the project builds.
|
||||
|
||||
```bash
|
||||
swift build
|
||||
```
|
||||
|
||||
This may take a minute if it's the first time building the project as it has to fetch the
|
||||
dependencies. If you experience problems here then make sure you don't have typos in your files.
|
||||
|
||||
Next, we need to run the database migrations.
|
||||
|
||||
```bash
|
||||
swift run App migrate
|
||||
```
|
||||
|
||||
Finally, we can run the application.
|
||||
|
||||
```bash
|
||||
swift run App
|
||||
```
|
||||
|
||||
You should be able to open your browser and type in the url: `http://localhost:8080` to view the
|
||||
application. You can experiment with adding a new todo using the form.
|
||||
|
||||
> Note: To stop the application use `Ctrl-c`
|
||||
|
||||
## Bonus Styles
|
||||
|
||||
Hopefully you weren't blinded the first time you opened the application. You can add custom styles
|
||||
by creating a `css` file (`Public/css/main.css`).
|
||||
|
||||
```bash
|
||||
mkdir Public/css
|
||||
touch Public/css/main.css
|
||||
```
|
||||
|
||||
Update the file to the following:
|
||||
|
||||
```css
|
||||
body {
|
||||
background-color: #1e1e2e;
|
||||
color: #ff66ff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid grey;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
td {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.todos {
|
||||
transition: all ease-in 1s;
|
||||
}
|
||||
|
||||
.btn-delete-todo {
|
||||
color: red;
|
||||
margin-left: 20px;
|
||||
}
|
||||
```
|
||||
|
||||
Currently vapor does not know to serve files from the `Public` directory, so we need to update the
|
||||
`Sources/App/configure.swift` file, by uncommenting the line near the top.
|
||||
|
||||
```swift
|
||||
import Fluent
|
||||
import FluentSQLiteDriver
|
||||
import Leaf
|
||||
import NIOSSL
|
||||
import Vapor
|
||||
|
||||
// configures your application
|
||||
public func configure(_ app: Application) async throws {
|
||||
// uncomment to serve files from /Public folder
|
||||
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
|
||||
|
||||
app.databases.use(DatabaseConfigurationFactory.sqlite(.file("db.sqlite")), as: .sqlite)
|
||||
|
||||
app.migrations.add(CreateTodo())
|
||||
|
||||
app.views.use(.leaf)
|
||||
|
||||
// register routes
|
||||
try routes(app)
|
||||
}
|
||||
```
|
||||
|
||||
Now you can stop and restart to see the styled website.
|
||||
|
||||
```bash
|
||||
swift run App
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
I hope you enjoyed this quick example of using `Htmx` with `Vapor`. You can view the source files at
|
||||
[here](https://github.com/m-housh/todo-htmx).
|
||||
Reference in New Issue
Block a user