Browse Source

Everywhere: Implement HTMX todo page

master
Riyyi 1 month ago
parent
commit
6b93045514
  1. 11
      Package.resolved
  2. 8
      Package.swift
  3. 4
      Public/css/style.css
  4. 0
      Public/js/site.js
  5. 10
      README.org
  6. 82
      Sources/App/Controllers/API/TodoAPIController.swift
  7. 81
      Sources/App/Controllers/TodoController.swift
  8. 12
      Sources/App/DTOs/TodoDTO.swift
  9. 24
      Sources/App/Extensions/Elementary.swift
  10. 35
      Sources/App/Views/Components/TodosFormComponent.swift
  11. 48
      Sources/App/Views/Components/TodosTableComponent.swift
  12. 82
      Sources/App/Views/Pages/IndexPage.swift
  13. 37
      Sources/App/Views/Shared/MainLayout.swift
  14. 5
      Sources/App/Views/Shared/NavMenu.swift
  15. 24
      Sources/App/routes.swift
  16. 26
      Tests/AppTests/AppTests.swift
  17. 10
      requests

11
Package.resolved

@ -1,5 +1,5 @@
{ {
"originHash" : "928a4b649897cc0b7c73d0862d9400289c0503386d6f44998334e327c5931856", "originHash" : "d39a0a797e191acb95e23a306e38d51e93c56adbbcfd76f4476a7deecf94537b",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@ -37,6 +37,15 @@
"version" : "0.4.1" "version" : "0.4.1"
} }
}, },
{
"identity" : "elementary-htmx",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sliemeobn/elementary-htmx.git",
"state" : {
"revision" : "3e09519a605be410e8332627b260cebaf74bb9fa",
"version" : "0.3.0"
}
},
{ {
"identity" : "fluent", "identity" : "fluent",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

8
Package.swift

@ -17,14 +17,16 @@ let package = Package(
.package(url: "https://github.com/apple/swift-nio.git", from: "2.76.1"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.76.1"),
// //
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.0"), .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.0"),
//
.package(url: "https://github.com/sliemeobn/elementary-htmx.git", from: "0.3.0"),
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "App", name: "App",
dependencies: [ dependencies: [
// .product(name: "ElementaryHTMX", package: "elementary-htmx"), .product(name: "ElementaryHTMX", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"), .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXWS", package: "elementary-htmx"), .product(name: "ElementaryHTMXWS", package: "elementary-htmx"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"), .product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"),
.product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"),

4
Public/css/style.css

@ -0,0 +1,4 @@
tr.htmx-swapping td {
opacity: 0;
transition: opacity 0.5s ease-out;
}

0
Public/js/site.js

10
README.org

@ -0,0 +1,10 @@
* Website
Website, written in Swift 6.
* Libraries
- [[https://github.com/vapor/vapor][Vapor]] HTTP web framework
- [[https://github.com/vapor/fluent.git][Fluent]] ORM
- [[https://github.com/sliemeobn/elementary][Elementary]] HTML templating
- [[https://github.com/bigskysoftware/htmx][htmx]] font-end JS

82
Sources/App/Controllers/API/TodoAPIController.swift

@ -0,0 +1,82 @@
import Fluent
import Vapor
struct TodoAPIController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos")
todos.get(use: index)
todos.post(use: create)
todos.group(":id") { todo in
todo.get(use: show)
todo.put(use: update)
todo.delete(use: delete)
}
// todos.delete(":id", use: delete)
}
@Sendable
func index(req: Request) async throws -> [TodoDTO] {
try await Todo.query(on: req.db).all().map { $0.toDTO() }
}
@Sendable
func show(req: Request) async throws -> TodoDTO {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
return todo.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 update(req: Request) async throws -> TodoDTO {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
let updatedTodo = try req.content.decode(Todo.self)
todo.title = updatedTodo.title
try await todo.save(on: req.db)
return todo.toDTO()
}
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
try await todo.delete(on: req.db)
return .noContent
}
// -------------------------------------
private func hexToUUID(hex: String) -> UUID? {
var uuid: String = hex.replacingOccurrences(of: "-", with: "")
guard uuid.count == 32 else { return nil }
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 8))
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 12 + 1))
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 16 + 2))
uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 20 + 3))
return UUID(uuidString: uuid)
}
}

81
Sources/App/Controllers/TodoController.swift

@ -1,60 +1,63 @@
import Elementary
import ElementaryHTMX
import ElementaryHTMXSSE
import ElementaryHTMXWS
import Fluent import Fluent
import Vapor import Vapor
import VaporElementary
struct TodoController: RouteCollection { struct TodoController: RouteCollection {
func boot(routes: RoutesBuilder) throws { func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos") routes.group("todos") { todos in
todos.get(use: index)
todos.get(use: index)
todos.post(use: create) todos.post(use: create)
todos.group(":id") { todo in
todo.get(use: show) todos.group(":id") { todo in
todo.put(use: update) todo.delete(use: delete)
todo.delete(use: delete) }
} }
// todos.delete(":todoID", use: delete)
} }
@Sendable @Sendable
func index(req: Request) async throws -> [TodoDTO] { func index(req: Request) async throws -> HTMLResponse {
try await Todo.query(on: req.db).all().map { $0.toDTO() } let todos = try await todos(db: req.db)
return HTMLResponse {
MainLayout(title: "Todos") {
TodosTableComponent(name: "todos", todos: todos)
TodosFormComponent(name: "todos-form", target: "todos")
}
}
} }
@Sendable @Sendable
func show(req: Request) async throws -> TodoDTO { func create(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), do {
let todo = try await Todo.find(uuid, on: req.db) else { try TodoDTO.validate(content: req)
throw Abort(.notFound) }
catch let error as ValidationsError {
return HTMLResponse {
TodosFormComponent(name: "todos-form", target: "todos", errors: ["title": error.description])
}
} }
return todo.toDTO()
}
@Sendable
func create(req: Request) async throws -> TodoDTO {
let todo = try req.content.decode(TodoDTO.self).toModel() let todo = try req.content.decode(TodoDTO.self).toModel()
try await todo.save(on: req.db) try await todo.save(on: req.db)
return todo.toDTO() let todos = try await todos(db: req.db)
}
@Sendable
func update(req: Request) async throws -> TodoDTO {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
let updatedTodo = try req.content.decode(Todo.self) return HTMLResponse {
todo.title = updatedTodo.title // Return the empty form
try await todo.save(on: req.db) TodosFormComponent(name: "todos-form", target: "todos")
return todo.toDTO() // Also update the todos table
TodosTableComponent(name: "todos", todos: todos, refresh: true) // TODO: Put component names inside variables
}
} }
@Sendable @Sendable
func delete(req: Request) async throws -> HTTPStatus { func delete(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else { let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound) throw Abort(.notFound)
@ -62,11 +65,16 @@ struct TodoController: RouteCollection {
try await todo.delete(on: req.db) try await todo.delete(on: req.db)
return .noContent return HTMLResponse {} // TODO: Return 204 No Content
} }
// ------------------------------------- // -------------------------------------
@Sendable
private func todos(db: any Database) async throws -> [TodoDTO] {
try await Todo.query(on: db).all().map { $0.toDTO() }
}
private func hexToUUID(hex: String) -> UUID? { private func hexToUUID(hex: String) -> UUID? {
var uuid: String = hex.replacingOccurrences(of: "-", with: "") var uuid: String = hex.replacingOccurrences(of: "-", with: "")
@ -79,4 +87,5 @@ struct TodoController: RouteCollection {
return UUID(uuidString: uuid) return UUID(uuidString: uuid)
} }
} }

12
Sources/App/DTOs/TodoDTO.swift

@ -4,10 +4,10 @@ import Vapor
struct TodoDTO: Content { struct TodoDTO: Content {
var id: UUID? var id: UUID?
var title: String? var title: String?
func toModel() -> Todo { func toModel() -> Todo {
let model = Todo() let model = Todo()
model.id = self.id model.id = self.id
if let title = self.title { if let title = self.title {
model.title = title model.title = title
@ -15,3 +15,11 @@ struct TodoDTO: Content {
return model return model
} }
} }
extension TodoDTO: Validatable {
static func validations(_ validations: inout Validations) {
validations.add(
"title", as: String.self, is: !.empty, required: true,
customFailureDescription: "Title is required")
}
}

24
Sources/App/Extensions/Elementary.swift

@ -0,0 +1,24 @@
import Elementary
import ElementaryHTMX
// -----------------------------------------
// Elementary
public extension HTMLAttribute {
static func empty() -> HTMLAttribute {
return HTMLAttribute(name: "", value: "")
}
}
// -----------------------------------------
// ElementaryHTMX
public extension HTMLAttribute.hx {
static func swap(_ value: HTMLAttributeValue.HTMX.ModifiedSwapTarget, _ spec: String? = nil) -> HTMLAttribute {
if let spec {
.init(name: "hx-swap", value: "\(value.rawValue) \(spec)")
} else {
.init(name: "hx-swap", value: value.rawValue)
}
}
}

35
Sources/App/Views/Components/TodosFormComponent.swift

@ -0,0 +1,35 @@
import Elementary
import Fluent
struct TodosFormComponent: HTML {
var name: String
var target: String
var errors: [String: String] = [:]
// -------------------------------------
var content: some HTML {
div(.id("cdiv_" + name)) {
form(
.id("\(name)-form"),
.hx.post("/\(target)"), .hx.target("#cdiv_" + name), .hx.swap(.outerHTML)
) {
div(.class("row")) {
div(.class("col-11")) {
input(
.class("form-control"), .type(.text), .id("\(name)-title"),
.name("title"), .placeholder("Title")) // .required
}
div(.class("col-1")) {
button(.class("btn btn-success"), .type(.submit)) { "Add" }
}
}
if let error = errors["title"] {
div(.class("form-text text-danger px-2")) { "\(error)." }
}
}
}
}
}

48
Sources/App/Views/Components/TodosTableComponent.swift

@ -0,0 +1,48 @@
import Elementary
import ElementaryHTMX
import Fluent
struct TodosTableComponent: HTML {
var name: String
var todos: [TodoDTO]
var refresh: Bool = false
// -------------------------------------
var content: some HTML {
div(.id("cdiv_" + name), refresh ? .hx.swapOOB(.outerHTML) : .empty()) {
table(.class("table")) {
thead {
tr {
th { "#" }
th { "ID" }
th { "Title" }
th { "Modifier" }
}
}
tbody(
.hx.confirm("Are you sure?"), .hx.target("closest tr"),
.hx.swap(.outerHTML, "swap:0.5s")
) {
for (index, todo) in todos.enumerated() {
tr {
td { "\(index)" }
td { todo.id?.uuidString ?? "" }
td { todo.title ?? "" }
td {
if let id = todo.id {
button(
.class("btn btn-danger"),
.hx.delete("/\(name)/\(id.uuidString)")
) { "Delete" }
}
}
}
}
}
}
}
}
}

82
Sources/App/Views/Pages/IndexPage.swift

@ -8,51 +8,51 @@ struct IndexPage: HTML {
} }
p { "The navigation bar will stay at the top of the page as you scroll down." } p { "The navigation bar will stay at the top of the page as you scroll down." }
p { p {
""" """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu
sit amet leo rutrum luctus. Sed metus mi, consectetur vitae dui at, sodales sit amet leo rutrum luctus. Sed metus mi, consectetur vitae dui at, sodales
dignissim odio. Curabitur a nisi eros. Suspendisse semper ac justo non gravida. dignissim odio. Curabitur a nisi eros. Suspendisse semper ac justo non gravida.
Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis
libero. Pellentesque habitant morbi tristique senectus et netus et malesuada libero. Pellentesque habitant morbi tristique senectus et netus et malesuada
fames ac turpis egestas. Integer congue, nisl in tempus ultricies, ligula est fames ac turpis egestas. Integer congue, nisl in tempus ultricies, ligula est
laoreet elit, sed elementum erat dui ac nisl. Aenean pulvinar arcu eget urna laoreet elit, sed elementum erat dui ac nisl. Aenean pulvinar arcu eget urna
venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis
lobortis, nisi sapien vestibulum arcu, ut mattis arcu elit ut arcu. Nulla vitae lobortis, nisi sapien vestibulum arcu, ut mattis arcu elit ut arcu. Nulla vitae
sem ac eros ullamcorper efficitur ut id arcu. Vestibulum euismod arcu eget sem ac eros ullamcorper efficitur ut id arcu. Vestibulum euismod arcu eget
aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum
mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula. mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula.
Quisque viverra diam velit, quis ultricies nisl lacinia vitae. Quisque viverra diam velit, quis ultricies nisl lacinia vitae.
Aliquam libero nibh, luctus vel augue at, congue feugiat risus. Mauris volutpat Aliquam libero nibh, luctus vel augue at, congue feugiat risus. Mauris volutpat
eget eros ac congue. Duis venenatis, arcu vel sodales accumsan, diam mi posuere eget eros ac congue. Duis venenatis, arcu vel sodales accumsan, diam mi posuere
mi, vitae rutrum ex mauris nec libero. Sed eleifend nulla magna, eu lobortis mi, vitae rutrum ex mauris nec libero. Sed eleifend nulla magna, eu lobortis
mauris fringilla nec. In posuere dignissim eros, ut hendrerit quam lacinia eu. mauris fringilla nec. In posuere dignissim eros, ut hendrerit quam lacinia eu.
Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc
vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in
viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet. viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet.
Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum, Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum,
sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris
magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu
vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam
consequat enim, sit amet sodales erat quam eget massa. consequat enim, sit amet sodales erat quam eget massa.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis
urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor. urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor.
Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae
ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper
fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla
nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla
facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae
neque venenatis porta. neque venenatis porta.
Vestibulum nunc diam, mattis eu bibendum at, sagittis vitae nunc. Duis lacinia Vestibulum nunc diam, mattis eu bibendum at, sagittis vitae nunc. Duis lacinia
sodales enim, et elementum libero posuere et. Donec ut fringilla orci. Donec at sodales enim, et elementum libero posuere et. Donec ut fringilla orci. Donec at
aliquet ipsum, quis mattis risus. Donec malesuada enim in egestas blandit. Proin aliquet ipsum, quis mattis risus. Donec malesuada enim in egestas blandit. Proin
sagittis mauris magna, elementum faucibus metus tempus eu. Sed in sem ut tellus sagittis mauris magna, elementum faucibus metus tempus eu. Sed in sem ut tellus
porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis
condimentum nulla. condimentum nulla.
""" """
} }
} }
} }

37
Sources/App/Views/Shared/MainLayout.swift

@ -1,11 +1,12 @@
import Elementary import Elementary
// https://www.srihash.org/
extension MainLayout: Sendable where Body: Sendable {} extension MainLayout: Sendable where Body: Sendable {}
struct MainLayout<Body: HTML>: HTMLDocument { struct MainLayout<Body: HTML>: HTMLDocument {
var title: String var title: String
@HTMLBuilder var pageContent: Body // This var name can't be changed! @HTMLBuilder var pageContent: Body // This var name can't be changed!
// https://www.srihash.org/
var head: some HTML { var head: some HTML {
meta(.charset(.utf8)) meta(.charset(.utf8))
meta(.name(.viewport), .content("width=device-width, initial-scale=1.0")) meta(.name(.viewport), .content("width=device-width, initial-scale=1.0"))
@ -18,14 +19,17 @@ struct MainLayout<Body: HTML>: HTMLDocument {
.href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"), .href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"),
.integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"), .integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"),
.crossorigin(.anonymous)) .crossorigin(.anonymous))
link(.rel(.stylesheet), .href("/style.css")) link(.rel(.stylesheet), .href("/css/style.css"))
// ---------------------------------
// Style
style { style {
""" """
body { body {
padding-top: 56px; padding-top: 56px;
} }
""" """
} }
} }
@ -38,10 +42,10 @@ struct MainLayout<Body: HTML>: HTMLDocument {
} }
// --------------------------------- // ---------------------------------
// Body // Content
main { main {
div(.class("cotainer mt-4")) { div(.class("container mt-4")) {
div(.class("content px-4 pb-4")) { div(.class("content px-4 pb-4")) {
pageContent pageContent
} }
@ -57,6 +61,21 @@ struct MainLayout<Body: HTML>: HTMLDocument {
.integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"), .integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"),
.crossorigin(.anonymous) .crossorigin(.anonymous)
) {} ) {}
script(
.src("https://cdnjs.cloudflare.com/ajax/libs/htmx/2.0.3/htmx.min.js"),
.integrity("sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq"),
.crossorigin(.anonymous)
) {}
script(
.src("https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2/sse.min.js"),
.integrity("sha384-yhS+rWHB2hwrHEg86hWiQV7XL6u+PH9X+3BlmS2+CNBaGYU8Nd7RZ2rZ9DWXgTdr"),
.crossorigin(.anonymous)
) {}
script(
.src("https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.1/ws.min.js"),
.integrity("sha384-yhWpPsq2os1hEnx1I8cH7Ius6rclwTm3G2fhXDLF6Pzv7UnSsXY7BAj4fB6PIgSz"),
.crossorigin(.anonymous)
) {}
script(.src("/js/site.js")) {} script(.src("/js/site.js")) {}
} }

5
Sources/App/Views/Shared/NavMenu.swift

@ -15,7 +15,10 @@ struct NavMenu: HTML {
div(.class("collapse navbar-collapse"), .id("navbarNav")) { div(.class("collapse navbar-collapse"), .id("navbarNav")) {
ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) { ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) {
li(.class("nav-item")) { li(.class("nav-item")) {
a(.class("nav-link active"), .href("#")) { "Home" } a(.class("nav-link active"), .href("/")) { "Home" }
}
li(.class("nav-item")) {
a(.class("nav-link active"), .href("/todos")) { "Todos" }
} }
li(.class("nav-item")) { li(.class("nav-item")) {
a(.class("nav-link"), .href("#")) { "About" } a(.class("nav-link"), .href("#")) { "About" }

24
Sources/App/routes.swift

@ -6,27 +6,23 @@ import VaporElementary
func routes(_ app: Application) throws { func routes(_ app: Application) throws {
app.routes.caseInsensitive = true app.routes.caseInsensitive = true
app.get { req async throws in app.get() { req async throws in
let todo = Todo(title: "Test Todo")
try await todo.save(on: req.db)
return "It works!"
}
app.get("hello") { req async -> String in
"Hello, world!"
}
app.get("test") { _ in
HTMLResponse { HTMLResponse {
MainLayout(title: "Test123") { MainLayout(title: "Homepage") {
IndexPage() IndexPage()
} }
} }
} }
app.get("hello") { req async -> String in
"Hello, world!"
}
try app.register(collection: TodoController()) try app.register(collection: TodoController())
try app.group("api") { api in
try api.register(collection: TodoAPIController())
}
} }
/* /*

26
Tests/AppTests/AppTests.swift

@ -9,9 +9,9 @@ struct AppTests {
let app = try await Application.make(.testing) let app = try await Application.make(.testing)
do { do {
try await configure(app) try await configure(app)
try await app.autoMigrate() try await app.autoMigrate()
try await test(app) try await test(app)
try await app.autoRevert() try await app.autoRevert()
} }
catch { catch {
try await app.asyncShutdown() try await app.asyncShutdown()
@ -19,7 +19,7 @@ struct AppTests {
} }
try await app.asyncShutdown() try await app.asyncShutdown()
} }
@Test("Test Hello World Route") @Test("Test Hello World Route")
func helloWorld() async throws { func helloWorld() async throws {
try await withApp { app in try await withApp { app in
@ -29,26 +29,26 @@ struct AppTests {
}) })
} }
} }
@Test("Getting all the Todos") @Test("Getting all the Todos")
func getAllTodos() async throws { func getAllTodos() async throws {
try await withApp { app in try await withApp { app in
let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")] let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")]
try await sampleTodos.create(on: app.db) try await sampleTodos.create(on: app.db)
try await app.test(.GET, "todos", afterResponse: { res async throws in try await app.test(.GET, "api/todos", afterResponse: { res async throws in
#expect(res.status == .ok) #expect(res.status == .ok)
#expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} ) #expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} )
}) })
} }
} }
@Test("Creating a Todo") @Test("Creating a Todo")
func createTodo() async throws { func createTodo() async throws {
let newDTO = TodoDTO(id: nil, title: "test") let newDTO = TodoDTO(id: nil, title: "test")
try await withApp { app in try await withApp { app in
try await app.test(.POST, "todos", beforeRequest: { req in try await app.test(.POST, "api/todos", beforeRequest: { req in
try req.content.encode(newDTO) try req.content.encode(newDTO)
}, afterResponse: { res async throws in }, afterResponse: { res async throws in
#expect(res.status == .ok) #expect(res.status == .ok)
@ -58,15 +58,15 @@ struct AppTests {
}) })
} }
} }
@Test("Deleting a Todo") @Test("Deleting a Todo")
func deleteTodo() async throws { func deleteTodo() async throws {
let testTodos = [Todo(title: "test1"), Todo(title: "test2")] let testTodos = [Todo(title: "test1"), Todo(title: "test2")]
try await withApp { app in try await withApp { app in
try await testTodos.create(on: app.db) try await testTodos.create(on: app.db)
try await app.test(.DELETE, "todos/\(testTodos[0].requireID())", afterResponse: { res async throws in try await app.test(.DELETE, "api/todos/\(testTodos[0].requireID())", afterResponse: { res async throws in
#expect(res.status == .noContent) #expect(res.status == .noContent)
let model = try await Todo.find(testTodos[0].id, on: app.db) let model = try await Todo.find(testTodos[0].id, on: app.db)
#expect(model == nil) #expect(model == nil)

10
requests

@ -14,7 +14,7 @@
# ------------------------------------------ # ------------------------------------------
GET http://localhost:8080/todos GET http://localhost:8080/api/todos
-> jq-set-var :id .[0].id -> jq-set-var :id .[0].id
Accept: application/json Accept: application/json
@ -22,12 +22,12 @@ Accept: application/json
# ------------------------------------------ # ------------------------------------------
GET http://localhost:8080/todos/:id GET http://localhost:8080/api/todos/:id
Accept: application/json Accept: application/json
# ------------------------------------------ # ------------------------------------------
POST http://localhost:8080/todos POST http://localhost:8080/api/todos
-> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read)))) -> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read))))
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
@ -38,7 +38,7 @@ Content-Type: application/json
# ------------------------------------------ # ------------------------------------------
PUT http://localhost:8080/todos/:id PUT http://localhost:8080/api/todos/:id
-> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read)))) -> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read))))
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
@ -55,7 +55,7 @@ Content-Type: application/json
# INSERT(INSERT(INSERT(INSERT(HEX(id), 9, 0, '-'), 14, 0, '-'), 19, 0, '-'), 24, 0, '-') AS id, # INSERT(INSERT(INSERT(INSERT(HEX(id), 9, 0, '-'), 14, 0, '-'), 19, 0, '-'), 24, 0, '-') AS id,
# title FROM riyyi.todos; # title FROM riyyi.todos;
DELETE http://localhost:8080/todos/:id DELETE http://localhost:8080/api/todos/:id
Accept: application/json Accept: application/json
# ------------------------------------------ # ------------------------------------------

Loading…
Cancel
Save