diff --git a/Dockerfile b/Dockerfile index f0bde23..cceff7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,9 +61,10 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ ca-certificates \ tzdata \ # If your app or its dependencies import FoundationNetworking, also install `libcurl4`. - # libcurl4 \ + libcurl4 \ # If your app or its dependencies import FoundationXML, also install `libxml2`. # libxml2 \ + sqlite3 \ && rm -r /var/lib/apt/lists/* # Create a vapor user and group with /app as its home directory diff --git a/Package.resolved b/Package.resolved index dd38eb2..a3a48a5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "aefc6edf3bfecf4e8b49731482d5e8f4fd78c1188ba5d90f5b78a36ab8106df6", + "originHash" : "62668360721c9ad13a7b961193242e083cbbf63a2bada2e8b1cc6bfeb4360fe6", "pins" : [ { "identity" : "async-http-client", @@ -154,6 +154,15 @@ "version" : "1.2.0" } }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "e7039aaa4d9cf386fa8324a89f258c3f2c54d751", + "version" : "1.6.0" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -289,6 +298,15 @@ "version" : "1.0.2" } }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b", + "version" : "0.14.1" + } + }, { "identity" : "swift-service-context", "kind" : "remoteSourceControl", @@ -316,6 +334,15 @@ "version" : "1.4.0" } }, + { + "identity" : "swift-url-routing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-url-routing.git", + "state" : { + "revision" : "1cfd564259ecb1d324bb718a8f03e513dab738d2", + "version" : "0.6.2" + } + }, { "identity" : "vapor", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 274d6c1..c5cdd6a 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,8 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.3"), .package(url: "https://github.com/sliemeobn/elementary.git", from: "0.3.2"), .package(url: "https://github.com/sliemeobn/elementary-htmx.git", from: "0.4.0"), - .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0") + .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"), + .package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2") ], targets: [ .executableTarget( @@ -82,7 +83,8 @@ let package = Package( .target( name: "SharedModels", dependencies: [ - .product(name: "Dependencies", package: "swift-dependencies") + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "URLRouting", package: "swift-url-routing") ], swiftSettings: swiftSettings ) diff --git a/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift b/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift index 91ad083..39ecc70 100644 --- a/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift +++ b/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift @@ -22,14 +22,12 @@ struct PurchaseOrderTable: HTML { var content: some HTML { table(.id(.purchaseOrders())) { - if page.items.count > 0 { - thead { - buttonRow - tableHeader - } - tbody(.id(.purchaseOrders(.table))) { - Rows(page: page) - } + thead { + buttonRow + tableHeader + } + tbody(.id(.purchaseOrders(.table))) { + Rows(page: page) } } } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index a991bea..690f281 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -2,7 +2,6 @@ import DatabaseClientLive import Dependencies import Fluent import FluentSQLiteDriver -import Leaf import NIOSSL import SharedModels import Vapor @@ -45,8 +44,11 @@ public func configure(_ app: Application) async throws { try routes(app) } - if app.environment != .production { - try await app.autoMigrate() + // if app.environment != .production { + try await app.autoMigrate() + // } + + #if DEBUG app.asyncCommands.use(SeedCommand(), as: "seed") - } + #endif } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 3eb2029..2651cef 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -35,6 +35,10 @@ func routes(_ app: Application) throws { } } + app.get("health") { _ in + HTTPStatus.ok + } + app.post("login") { req in @Dependency(\.database.users) var users let loginForm = try req.content.decode(User.Login.self) diff --git a/Sources/SharedModels/AppRoute.swift b/Sources/SharedModels/AppRoute.swift new file mode 100644 index 0000000..b5e43b7 --- /dev/null +++ b/Sources/SharedModels/AppRoute.swift @@ -0,0 +1,407 @@ +import CasePathsCore +import Foundation +@preconcurrency import URLRouting + +// TODO: Share view and api routes. + +public enum ViewRoute: Sendable { + case employee(EmployeeRoute) + case purchaseOrder(PurchaseOrderRoute) + case select(SelectRoute) + case user(UserRoute) + case vendor(VendorRoute) + + public static let router = OneOf { + Route(.case(Self.employee)) { EmployeeRoute.router } + Route(.case(Self.purchaseOrder)) { PurchaseOrderRoute.router } + Route(.case(Self.select)) { SelectRoute.router } + Route(.case(Self.user)) { UserRoute.router } + Route(.case(Self.vendor)) { VendorRoute.router } + } + + public enum EmployeeRoute: Sendable { + case create(Employee.Create) + case delete(id: Employee.ID) + case get(id: Employee.ID) + case form + case index + case update(id: Employee.ID, updates: Employee.Update) + + static let rootPath = "employees" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(Employee.Create.self)) + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.delete(id:))) { + Path { rootPath; UUID.parser() } + Method.delete + } + Route(.case(Self.get(id:))) { + Path { rootPath; UUID.parser() } + Method.get + } + Route(.case(Self.form)) { + Path { rootPath; "create" } + Method.get + } + Route(.case(Self.update(id:updates:))) { + Path { rootPath; UUID.parser() } + Method.put + Body(.json(Employee.Update.self)) + } + } + } + + // TODO: Add search. + public enum PurchaseOrderRoute: Sendable { + case create(PurchaseOrder.Create) + case form + case get(id: PurchaseOrder.ID) + case index + case next(page: Int, limit: Int) + + static let rootPath = "purchase-orders" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(PurchaseOrder.Create.self)) + } + Route(.case(Self.form)) { + Path { rootPath; "create" } + Method.get + } + Route(.case(Self.get(id:))) { + Path { rootPath; Digits() } + Method.get + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.next(page:limit:))) { + Path { rootPath; "next" } + Method.get + Query { + Field("page", default: 1) { Digits() } + Field("limit", default: 25) { Digits() } + } + } + } + } + + public enum SelectRoute: Sendable { + case employee(context: Context) + case vendorBranches(context: Context) + + public enum Context: String, Codable, Sendable, CaseIterable { + case purchaseOrderForm + case purchaseOrderSearch + } + + static let rootPath = "select" + + public static let router = OneOf { + Route(.case(Self.employee(context:))) { + Path { rootPath; "employee" } + Method.get + Query { + Field("context") { Context.parser() } + } + } + Route(.case(Self.vendorBranches(context:))) { + Path { rootPath; "vendor-branches" } + Method.get + Query { + Field("context") { Context.parser() } + } + } + } + } + + public enum UserRoute: Sendable { + case create(User.Create) + case delete(id: User.ID) + case form + case get(id: User.ID) + case index + case login(User.Login) + case update(id: User.ID, updates: User.Update) + + static let rootPath = "users" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(User.Create.self)) + } + Route(.case(Self.delete(id:))) { + Path { rootPath; User.ID.parser() } + Method.delete + } + Route(.case(Self.form)) { + Path { rootPath; "create" } + Method.get + } + Route(.case(Self.get(id:))) { + Path { rootPath; User.ID.parser() } + Method.get + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.login)) { + Path { rootPath } + Method.post + Body(.json(User.Login.self)) + } + Route(.case(Self.update(id:updates:))) { + Path { rootPath; User.ID.parser() } + // TODO: Use put or patch. + Method.post + Body(.json(User.Update.self)) + } + } + } + + public enum VendorRoute: Sendable { + case create(Vendor.Create) + case createBranch(VendorBranch.Create) + case form + case get(id: Vendor.ID) + case index + case update(id: Vendor.ID, updates: Vendor.Update) + + static let rootPath = "vendors" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(Vendor.Create.self)) + } + Route(.case(Self.createBranch)) { + Path { rootPath; "branches" } + Method.post + Body(.json(VendorBranch.Create.self)) + } + Route(.case(Self.form)) { + Path { rootPath; "create" } + Method.get + } + Route(.case(Self.get(id:))) { + Path { rootPath; Vendor.ID.parser() } + Method.get + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.update(id:updates:))) { + Path { rootPath; Vendor.ID.parser() } + Method.put + Body(.json(Vendor.Update.self)) + } + } + } +} + +public enum ApiRoute: Sendable { + + case employee(EmployeeApiRoute) + case purchaseOrder(PurchaseOrderApiRoute) + case user(UserApiRoute) + case vendor(VendorApiRoute) + case vendorBranch(VendorBranchApiRoute) + + public static let router = OneOf { + Route(.case(Self.employee)) { EmployeeApiRoute.router } + Route(.case(Self.purchaseOrder)) { PurchaseOrderApiRoute.router } + Route(.case(Self.user)) { UserApiRoute.router } + Route(.case(Self.vendor)) { VendorApiRoute.router } + Route(.case(Self.vendorBranch)) { VendorBranchApiRoute.router } + } + + public enum EmployeeApiRoute: Sendable { + case create(Employee.Create) + case delete(id: Employee.ID) + case get(id: Employee.ID) + case index + case update(id: Employee.ID, updates: Employee.Update) + + static let rootPath = "employees" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(Employee.Create.self)) + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.delete(id:))) { + Path { rootPath; UUID.parser() } + Method.delete + } + Route(.case(Self.get(id:))) { + Path { rootPath; UUID.parser() } + Method.get + } + Route(.case(Self.update(id:updates:))) { + Path { rootPath; UUID.parser() } + Method.put + Body(.json(Employee.Update.self)) + } + } + } + + public enum PurchaseOrderApiRoute: Sendable { + case create(PurchaseOrder.Create) + case delete(id: PurchaseOrder.ID) + case get(id: PurchaseOrder.ID) + case index + case page(page: Int, limit: Int) + + static let rootPath = "purchase-orders" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(PurchaseOrder.Create.self)) + } + Route(.case(Self.delete(id:))) { + Path { rootPath; Digits() } + Method.delete + } + Route(.case(Self.get(id:))) { + Path { rootPath; Digits() } + Method.get + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.page(page:limit:))) { + Path { rootPath; "next" } + Method.get + Query { + Field("page", default: 1) { Digits() } + Field("limit", default: 25) { Digits() } + } + } + } + } + + public enum UserApiRoute: Sendable { + case create(User.Create) + case delete(id: User.ID) + case get(id: User.ID) + case index + case login(User.Login) + case update(id: User.ID, updates: User.Update) + + static let rootPath = "users" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(User.Create.self)) + } + Route(.case(Self.delete(id:))) { + Path { rootPath; User.ID.parser() } + Method.delete + } + Route(.case(Self.get(id:))) { + Path { rootPath; User.ID.parser() } + Method.get + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.login)) { + Path { rootPath } + Method.post + Body(.json(User.Login.self)) + } + Route(.case(Self.update(id:updates:))) { + Path { rootPath; User.ID.parser() } + // TODO: Use put or patch. + Method.post + Body(.json(User.Update.self)) + } + } + } + + public enum VendorApiRoute: Sendable { + case create(Vendor.Create) + case get(id: Vendor.ID) + case index + case update(id: Vendor.ID, updates: Vendor.Update) + + static let rootPath = "vendors" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body(.json(Vendor.Create.self)) + } + Route(.case(Self.get(id:))) { + Path { rootPath; Vendor.ID.parser() } + Method.get + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.update(id:updates:))) { + Path { rootPath; Vendor.ID.parser() } + Method.put + Body(.json(Vendor.Update.self)) + } + } + } + + public enum VendorBranchApiRoute: Sendable { + case create(VendorBranch.Create) + case delete(id: VendorBranch.ID) + case get(id: VendorBranch.ID) + case update(id: VendorBranch.ID, updates: VendorBranch.Update) + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { "vendors"; "branches" } + Method.post + Body(.json(VendorBranch.Create.self)) + } + Route(.case(Self.delete(id:))) { + Path { "vendors"; "branches"; VendorBranch.ID.parser() } + Method.delete + } + Route(.case(Self.get(id:))) { + Path { "vendors"; "branches"; VendorBranch.ID.parser() } + Method.get + } + Route(.case(Self.update(id:updates:))) { + Path { "vendors"; "branches"; VendorBranch.ID.parser() } + Method.put + Body(.json(VendorBranch.Update.self)) + } + } + } +} diff --git a/Tests/DatabaseClientTests/DatabaseClientTests.swift b/Tests/DatabaseClientTests/DatabaseClientTests.swift index f4c44d6..45b05dd 100644 --- a/Tests/DatabaseClientTests/DatabaseClientTests.swift +++ b/Tests/DatabaseClientTests/DatabaseClientTests.swift @@ -17,6 +17,12 @@ struct DatabaseClientTests { self.logger = logger } + @Test + func testPath() { + let path = AppRoute.ViewRoute.router.path(for: .employee(.index)) + #expect(path == "/employees") + } + @Test func users() async throws { try await withDatabase(migrations: User.Migrate()) { diff --git a/docker-compose.yml b/docker-compose.yml index 266af81..97b3b5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ x-shared_environment: &shared_environment LOG_LEVEL: ${LOG_LEVEL:-debug} - + services: app: image: hhe-po:latest diff --git a/justfile b/justfile index 80c46ac..e3448b8 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,8 @@ +docker_image := "purchase_orders" +docker_tag := "latest" + +build-docker: + @docker build -t {{docker_image}}:{{docker_tag}} . seed: swift run App seed