diff --git a/Package.resolved b/Package.resolved index 34e6648..aa98d0d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "928a4b649897cc0b7c73d0862d9400289c0503386d6f44998334e327c5931856", + "originHash" : "d39a0a797e191acb95e23a306e38d51e93c56adbbcfd76f4476a7deecf94537b", "pins" : [ { "identity" : "async-http-client", @@ -37,6 +37,15 @@ "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", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index d5a4ce4..f6bd31e 100644 --- a/Package.swift +++ b/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/vapor-community/vapor-elementary.git", from: "0.2.0"), + // + .package(url: "https://github.com/sliemeobn/elementary-htmx.git", from: "0.3.0"), ], targets: [ .executableTarget( name: "App", dependencies: [ - // .product(name: "ElementaryHTMX", package: "elementary-htmx"), - // .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"), - // .product(name: "ElementaryHTMXWS", package: "elementary-htmx"), + .product(name: "ElementaryHTMX", package: "elementary-htmx"), + .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"), + .product(name: "ElementaryHTMXWS", package: "elementary-htmx"), .product(name: "Fluent", package: "fluent"), .product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"), .product(name: "NIOCore", package: "swift-nio"), diff --git a/Public/css/style.css b/Public/css/style.css new file mode 100644 index 0000000..c980546 --- /dev/null +++ b/Public/css/style.css @@ -0,0 +1,4 @@ +tr.htmx-swapping td { + opacity: 0; + transition: opacity 0.5s ease-out; +} diff --git a/Public/js/site.js b/Public/js/site.js new file mode 100644 index 0000000..e69de29 diff --git a/README.org b/README.org new file mode 100644 index 0000000..0191e58 --- /dev/null +++ b/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 diff --git a/Sources/App/Controllers/API/TodoAPIController.swift b/Sources/App/Controllers/API/TodoAPIController.swift new file mode 100644 index 0000000..f4ece67 --- /dev/null +++ b/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) + } +} diff --git a/Sources/App/Controllers/TodoController.swift b/Sources/App/Controllers/TodoController.swift index bed8baf..9f1370a 100644 --- a/Sources/App/Controllers/TodoController.swift +++ b/Sources/App/Controllers/TodoController.swift @@ -1,60 +1,63 @@ +import Elementary +import ElementaryHTMX +import ElementaryHTMXSSE +import ElementaryHTMXWS import Fluent import Vapor +import VaporElementary struct TodoController: 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) + routes.group("todos") { todos in + todos.get(use: index) + + todos.post(use: create) + + todos.group(":id") { todo in + todo.delete(use: delete) + } } - // todos.delete(":todoID", use: delete) } @Sendable - func index(req: Request) async throws -> [TodoDTO] { - try await Todo.query(on: req.db).all().map { $0.toDTO() } + func index(req: Request) async throws -> HTMLResponse { + 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 - 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) + func create(req: Request) async throws -> HTMLResponse { + do { + try TodoDTO.validate(content: req) + } + 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() - 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 todos = try await todos(db: req.db) - let updatedTodo = try req.content.decode(Todo.self) - todo.title = updatedTodo.title - try await todo.save(on: req.db) + return HTMLResponse { + // Return the empty form + 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 - func delete(req: Request) async throws -> HTTPStatus { + func delete(req: Request) async throws -> HTMLResponse { guard let uuid = hexToUUID(hex: req.parameters.get("id")!), let todo = try await Todo.find(uuid, on: req.db) else { throw Abort(.notFound) @@ -62,11 +65,16 @@ struct TodoController: RouteCollection { 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? { var uuid: String = hex.replacingOccurrences(of: "-", with: "") @@ -79,4 +87,5 @@ struct TodoController: RouteCollection { return UUID(uuidString: uuid) } + } diff --git a/Sources/App/DTOs/TodoDTO.swift b/Sources/App/DTOs/TodoDTO.swift index c35ba33..9b27a84 100644 --- a/Sources/App/DTOs/TodoDTO.swift +++ b/Sources/App/DTOs/TodoDTO.swift @@ -4,10 +4,10 @@ import Vapor struct TodoDTO: Content { var id: UUID? var title: String? - + func toModel() -> Todo { let model = Todo() - + model.id = self.id if let title = self.title { model.title = title @@ -15,3 +15,11 @@ struct TodoDTO: Content { 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") + } +} diff --git a/Sources/App/Extensions/Elementary.swift b/Sources/App/Extensions/Elementary.swift new file mode 100644 index 0000000..25352ab --- /dev/null +++ b/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) + } + } +} diff --git a/Sources/App/Views/Components/TodosFormComponent.swift b/Sources/App/Views/Components/TodosFormComponent.swift new file mode 100644 index 0000000..13138db --- /dev/null +++ b/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)." } + } + } + } + } + +} diff --git a/Sources/App/Views/Components/TodosTableComponent.swift b/Sources/App/Views/Components/TodosTableComponent.swift new file mode 100644 index 0000000..d583249 --- /dev/null +++ b/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" } + } + } + } + } + } + } + } + } + +} diff --git a/Sources/App/Views/Pages/IndexPage.swift b/Sources/App/Views/Pages/IndexPage.swift index 1a0db4d..6a3d6b0 100644 --- a/Sources/App/Views/Pages/IndexPage.swift +++ b/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 { - """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu - 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. - Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis - libero. Pellentesque habitant morbi tristique senectus et netus et malesuada - 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 - venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis - 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 - aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum - mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula. - Quisque viverra diam velit, quis ultricies nisl lacinia vitae. +""" +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu +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. +Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis +libero. Pellentesque habitant morbi tristique senectus et netus et malesuada +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 +venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis +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 +aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum +mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula. +Quisque viverra diam velit, quis ultricies nisl lacinia vitae. - 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 - 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. - Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc - vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in - viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet. +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 +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. +Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc +vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in +viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet. - Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum, - sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris - magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu - vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam - consequat enim, sit amet sodales erat quam eget massa. +Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum, +sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris +magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu +vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam +consequat enim, sit amet sodales erat quam eget massa. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis - urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor. - Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae - ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper - fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla - nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla - facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae - neque venenatis porta. +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis +urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor. +Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae +ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper +fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla +nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla +facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae +neque venenatis porta. - 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 - 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 - porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis - condimentum nulla. - """ +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 +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 +porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis +condimentum nulla. +""" } } } diff --git a/Sources/App/Views/Shared/MainLayout.swift b/Sources/App/Views/Shared/MainLayout.swift index 7aec4ac..5ae51f0 100644 --- a/Sources/App/Views/Shared/MainLayout.swift +++ b/Sources/App/Views/Shared/MainLayout.swift @@ -1,11 +1,12 @@ import Elementary +// https://www.srihash.org/ + extension MainLayout: Sendable where Body: Sendable {} struct MainLayout: HTMLDocument { var title: String @HTMLBuilder var pageContent: Body // This var name can't be changed! - // https://www.srihash.org/ var head: some HTML { meta(.charset(.utf8)) meta(.name(.viewport), .content("width=device-width, initial-scale=1.0")) @@ -18,14 +19,17 @@ struct MainLayout: HTMLDocument { .href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"), .integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"), .crossorigin(.anonymous)) - link(.rel(.stylesheet), .href("/style.css")) + link(.rel(.stylesheet), .href("/css/style.css")) + + // --------------------------------- + // Style style { - """ - body { - padding-top: 56px; - } - """ +""" +body { + padding-top: 56px; +} +""" } } @@ -38,10 +42,10 @@ struct MainLayout: HTMLDocument { } // --------------------------------- - // Body + // Content main { - div(.class("cotainer mt-4")) { + div(.class("container mt-4")) { div(.class("content px-4 pb-4")) { pageContent } @@ -57,6 +61,21 @@ struct MainLayout: HTMLDocument { .integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"), .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")) {} } diff --git a/Sources/App/Views/Shared/NavMenu.swift b/Sources/App/Views/Shared/NavMenu.swift index fc403d2..a4b2d44 100644 --- a/Sources/App/Views/Shared/NavMenu.swift +++ b/Sources/App/Views/Shared/NavMenu.swift @@ -15,7 +15,10 @@ struct NavMenu: HTML { div(.class("collapse navbar-collapse"), .id("navbarNav")) { ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) { 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")) { a(.class("nav-link"), .href("#")) { "About" } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 4b06e48..e842b0d 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -6,27 +6,23 @@ import VaporElementary func routes(_ app: Application) throws { app.routes.caseInsensitive = true - 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 + app.get() { req async throws in HTMLResponse { - MainLayout(title: "Test123") { + MainLayout(title: "Homepage") { IndexPage() } } } + app.get("hello") { req async -> String in + "Hello, world!" + } + try app.register(collection: TodoController()) + + try app.group("api") { api in + try api.register(collection: TodoAPIController()) + } } /* diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index c8bb718..9222e28 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -9,9 +9,9 @@ struct AppTests { let app = try await Application.make(.testing) do { try await configure(app) - try await app.autoMigrate() + try await app.autoMigrate() try await test(app) - try await app.autoRevert() + try await app.autoRevert() } catch { try await app.asyncShutdown() @@ -19,7 +19,7 @@ struct AppTests { } try await app.asyncShutdown() } - + @Test("Test Hello World Route") func helloWorld() async throws { try await withApp { app in @@ -29,26 +29,26 @@ struct AppTests { }) } } - + @Test("Getting all the Todos") func getAllTodos() async throws { try await withApp { app in let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")] 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(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} ) }) } } - + @Test("Creating a Todo") func createTodo() async throws { let newDTO = TodoDTO(id: nil, title: "test") - + 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) }, afterResponse: { res async throws in #expect(res.status == .ok) @@ -58,15 +58,15 @@ struct AppTests { }) } } - + @Test("Deleting a Todo") func deleteTodo() async throws { let testTodos = [Todo(title: "test1"), Todo(title: "test2")] - + try await withApp { app in 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) let model = try await Todo.find(testTodos[0].id, on: app.db) #expect(model == nil) diff --git a/requests b/requests index 035bb43..acfa4ad 100644 --- a/requests +++ b/requests @@ -14,7 +14,7 @@ # ------------------------------------------ -GET http://localhost:8080/todos +GET http://localhost:8080/api/todos -> jq-set-var :id .[0].id 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 # ------------------------------------------ -POST http://localhost:8080/todos +POST http://localhost:8080/api/todos -> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read)))) Accept: 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)))) Accept: 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, # title FROM riyyi.todos; -DELETE http://localhost:8080/todos/:id +DELETE http://localhost:8080/api/todos/:id Accept: application/json # ------------------------------------------