From 4e1be161b1117d9d835e1a71d3192713a313c3d5 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Tue, 30 Dec 2025 10:12:45 -0500 Subject: [PATCH] feat: Begins vapor application, begins view controller. --- Package.resolved | 38 ++++- Package.swift | 43 +++++- Public/css/main.css | 0 Public/js/main.js | 0 Sources/App/BrowserSync.swift | 17 +++ .../App/Extensions/Request+extensions.swift | 7 + .../Extensions/ViewController+respond.swift | 60 ++++++++ .../Middleware/DependenciesMiddleware.swift | 41 ++++++ .../App/Middleware/URLRoutingMiddleware.swift | 100 +++++++++++++ Sources/App/configure.swift | 132 ++++++++++++++++++ Sources/App/entrypoint.swift | 34 +++++ Sources/ManualDCore/Routes/SiteRoute.swift | 9 ++ Sources/ManualDCore/Routes/ViewRoute.swift | 46 +++++- Sources/ViewController/Interface.swift | 43 ++++++ Sources/ViewController/Views/MainPage.swift | 25 ++++ 15 files changed, 586 insertions(+), 9 deletions(-) create mode 100644 Public/css/main.css create mode 100644 Public/js/main.js create mode 100644 Sources/App/BrowserSync.swift create mode 100644 Sources/App/Extensions/Request+extensions.swift create mode 100644 Sources/App/Extensions/ViewController+respond.swift create mode 100644 Sources/App/Middleware/DependenciesMiddleware.swift create mode 100644 Sources/App/Middleware/URLRoutingMiddleware.swift create mode 100644 Sources/App/configure.swift create mode 100644 Sources/App/entrypoint.swift create mode 100644 Sources/ViewController/Interface.swift create mode 100644 Sources/ViewController/Views/MainPage.swift diff --git a/Package.resolved b/Package.resolved index a4c6bec..5858eb1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f8ca659e4ec9041ea590b94c8dc267718be8941a4594eb74964b6396e987ca95", + "originHash" : "5d6dad57209ac74e3c47d8e8eb162768b81c9e63e15df87d29019d46a13cfec2", "pins" : [ { "identity" : "async-http-client", @@ -37,6 +37,24 @@ "version" : "4.15.2" } }, + { + "identity" : "elementary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/elementary-swift/elementary.git", + "state" : { + "revision" : "65803c4770b6c8b6452c6ce83ab570c1b7042528", + "version" : "0.6.1" + } + }, + { + "identity" : "elementary-htmx", + "kind" : "remoteSourceControl", + "location" : "https://github.com/elementary-swift/elementary-htmx.git", + "state" : { + "revision" : "f7ba147f2a076142a90a4f8300a89584164dba6d", + "version" : "0.5.1" + } + }, { "identity" : "fluent", "kind" : "remoteSourceControl", @@ -379,6 +397,24 @@ "version" : "4.120.0" } }, + { + "identity" : "vapor-elementary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor-community/vapor-elementary.git", + "state" : { + "revision" : "e1326d2439a9d4bd271c24b9765c195f7f793e77", + "version" : "0.2.2" + } + }, + { + "identity" : "vapor-routing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/vapor-routing.git", + "state" : { + "revision" : "ae1db2ec96fad88b00173a265313de2c447a9945", + "version" : "0.1.3" + } + }, { "identity" : "websocket-kit", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index b15ecd9..507bb4a 100644 --- a/Package.swift +++ b/Package.swift @@ -5,23 +5,41 @@ import PackageDescription let package = Package( name: "swift-manual-d", products: [ + .executable(name: "App", targets: ["App"]), + .library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "ManualDCore", targets: ["ManualDCore"]), .library(name: "ManualDClient", targets: ["ManualDClient"]), + .library(name: "ViewController", targets: ["ViewController"]), ], dependencies: [ - // 💧 A server-side Swift web framework. .package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"), - // 🗄 An ORM for SQL and NoSQL databases. .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"), - // ðŸŠķ Fluent driver for SQLite. .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"), - // ðŸ”ĩ Non-blocking, event-driven networking Swift. Used for, custom executors .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"), + .package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"), .package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"), + .package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"), + .package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"), + .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"), ], targets: [ + .executableTarget( + name: "App", + dependencies: [ + .target(name: "DatabaseClient"), + .target(name: "ViewController"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + .product(name: "Vapor", package: "vapor"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "VaporElementary", package: "vapor-elementary"), + .product(name: "VaporRouting", package: "vapor-routing"), + ] + ), .target( name: "DatabaseClient", dependencies: [ @@ -40,6 +58,12 @@ let package = Package( .product(name: "CasePaths", package: "swift-case-paths"), ] ), + .testTarget( + name: "ApiRouteTests", + dependencies: [ + .target(name: "ManualDCore") + ] + ), .target( name: "ManualDClient", dependencies: [ @@ -55,10 +79,15 @@ let package = Package( .product(name: "DependenciesTestSupport", package: "swift-dependencies"), ] ), - .testTarget( - name: "ApiRouteTests", + .target( + name: "ViewController", dependencies: [ - .target(name: "ManualDCore") + .target(name: "ManualDCore"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "Elementary", package: "elementary"), + .product(name: "ElementaryHTMX", package: "elementary-htmx"), + .product(name: "Vapor", package: "vapor"), ] ), ] diff --git a/Public/css/main.css b/Public/css/main.css new file mode 100644 index 0000000..e69de29 diff --git a/Public/js/main.js b/Public/js/main.js new file mode 100644 index 0000000..e69de29 diff --git a/Sources/App/BrowserSync.swift b/Sources/App/BrowserSync.swift new file mode 100644 index 0000000..df854ab --- /dev/null +++ b/Sources/App/BrowserSync.swift @@ -0,0 +1,17 @@ +import Foundation +import Vapor + +#if DEBUG + struct BrowserSyncHandler: LifecycleHandler { + func didBoot(_ application: Application) throws { + let process = Process() + process.executableURL = URL(filePath: "/bin/sh") + process.arguments = ["-c", "browser-sync reload"] + do { + try process.run() + } catch { + print("Could not auto-reload: \(error)") + } + } + } +#endif diff --git a/Sources/App/Extensions/Request+extensions.swift b/Sources/App/Extensions/Request+extensions.swift new file mode 100644 index 0000000..d82680f --- /dev/null +++ b/Sources/App/Extensions/Request+extensions.swift @@ -0,0 +1,7 @@ +import Vapor + +extension Request { + var isHtmxRequest: Bool { + headers.contains(name: "hx-request") + } +} diff --git a/Sources/App/Extensions/ViewController+respond.swift b/Sources/App/Extensions/ViewController+respond.swift new file mode 100644 index 0000000..6f0fa18 --- /dev/null +++ b/Sources/App/Extensions/ViewController+respond.swift @@ -0,0 +1,60 @@ +import Elementary +import ManualDCore +import Vapor +import VaporElementary +import ViewController + +extension ViewController { + func respond(route: SiteRoute.View, request: Vapor.Request) async throws + -> any AsyncResponseEncodable + { + let html = try await view( + .init( + route: route, + isHtmxRequest: request.isHtmxRequest, + logger: request.logger, + // authenticate: { request.session.authenticate($0) }, + // currentUser: { + // try request.auth.require(User.self) + // } + ) + ) + return AnyHTMLResponse(value: html) + } +} + +// Re-adapted from `HTMLResponse` in the VaporElementary package to work with any html types +// returned from the view controller. +struct AnyHTMLResponse: AsyncResponseEncodable { + + public var chunkSize: Int + public var headers: HTTPHeaders = ["Content-Type": "text/html; charset=utf-8"] + var value: _SendableAnyHTMLBox + + init(chunkSize: Int = 1024, additionalHeaders: HTTPHeaders = [:], value: AnySendableHTML) { + self.chunkSize = chunkSize + if additionalHeaders.contains(name: .contentType) { + self.headers = additionalHeaders + } else { + headers.add(contentsOf: additionalHeaders) + } + self.value = .init(value) + } + + func encodeResponse(for request: Request) async throws -> Response { + Response( + status: .ok, + headers: headers, + body: .init(asyncStream: { [value, chunkSize] writer in + guard let html = value.tryTake() else { + assertionFailure("Non-sendable HTML value consumed more than once") + request.logger.error("Non-sendable HTML value consumed more than once") + throw Abort(.internalServerError) + } + try await writer.writeHTML(html, chunkSize: chunkSize) + try await writer.write(.end) + + }) + ) + } +} diff --git a/Sources/App/Middleware/DependenciesMiddleware.swift b/Sources/App/Middleware/DependenciesMiddleware.swift new file mode 100644 index 0000000..b72ab93 --- /dev/null +++ b/Sources/App/Middleware/DependenciesMiddleware.swift @@ -0,0 +1,41 @@ +// import ApiControllerLive +import DatabaseClient +import Dependencies +import Vapor +import ViewController + +// Taken from discussions page on `swift-dependencies`. + +// FIX: Use live view controller. +struct DependenciesMiddleware: AsyncMiddleware { + + private let values: DependencyValues.Continuation + // private let apiController: ApiController + private let database: DatabaseClient + private let viewController: ViewController + + init( + database: DatabaseClient, + // apiController: ApiController = .liveValue, + viewController: ViewController = .testValue + ) { + self.values = withEscapedDependencies { $0 } + // self.apiController = apiController + self.database = database + self.viewController = viewController + } + + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + try await values.yield { + try await withDependencies { + // $0.apiController = apiController + $0.database = database + // $0.dateFormatter = .liveValue + $0.viewController = viewController + } operation: { + try await next.respond(to: request) + } + } + } + +} diff --git a/Sources/App/Middleware/URLRoutingMiddleware.swift b/Sources/App/Middleware/URLRoutingMiddleware.swift new file mode 100644 index 0000000..6ebb78d --- /dev/null +++ b/Sources/App/Middleware/URLRoutingMiddleware.swift @@ -0,0 +1,100 @@ +import URLRouting +import Vapor +import VaporRouting + +// Taken from github.com/nevillco/vapor-routing + +extension Application { + /// Mounts a router to the Vapor application. + /// + /// See ``VaporRouting`` for more information on usage. + /// + /// - Parameters: + /// - router: A parser-printer that works on inputs of `URLRequestData`. + /// - middleware: A closure for providing any per-route migrations to be run before processing the request. + /// - closure: A closure that takes a `Request` and the router's output as arguments. + public func mount( + _ router: R, + middleware: @escaping @Sendable (R.Output) -> [any Middleware]? = { _ in nil }, + use closure: @escaping @Sendable (Request, R.Output) async throws -> any AsyncResponseEncodable + ) where R.Input == URLRequestData, R: Sendable, R.Output: Sendable { + self.middleware.use( + AsyncRoutingMiddleware(router: router, middleware: middleware, respond: closure)) + } +} + +/// Serves requests using a router and response handler. +/// +/// You will not typically need to interact with this type directly. Instead you should use the +/// `mount` method on your Vapor application. +/// +/// See ``VaporRouting`` for more information on usage. +public struct AsyncRoutingMiddleware: AsyncMiddleware +where + Router.Input == URLRequestData, + Router: Sendable, + Router.Output: Sendable +{ + let router: Router + let middleware: @Sendable (Router.Output) -> [any Middleware]? + let respond: @Sendable (Request, Router.Output) async throws -> any AsyncResponseEncodable + + public func respond( + to request: Request, + chainingTo next: any AsyncResponder + ) async throws -> Response { + if request.body.data == nil { + try await _ = request.body.collect(max: request.application.routes.defaultMaxBodySize.value) + .get() + } + + guard let requestData = URLRequestData(request: request) + else { return try await next.respond(to: request) } + + let route: Router.Output + do { + route = try router.parse(requestData) + } catch let routingError { + do { + return try await next.respond(to: request) + } catch { + request.logger.info("\(routingError)") + + guard request.application.environment == .development + else { throw error } + + return Response(status: .notFound, body: .init(string: "Routing \(routingError)")) + } + } + + if let middleware = middleware(route) { + return try await middleware.makeResponder( + chainingTo: AsyncBasicResponder { request in + try await self.respond(request, route).encodeResponse(for: request) + } + ).respond(to: request).get() + + // return try await middleware.respond( + // to: request, + // chainingTo: AsyncBasicResponder { request in + // try await self.respond(request, route).encodeResponse(for: request) + // } + // ).get() + } else { + return try await respond(request, route).encodeResponse(for: request) + } + } +} + +// Usage: +// app.mount( +// router, +// middleware: { route in +// case .onboarding: return nil +// case .signIn: return BasicAuthMiddleware() +// default: return BearerAuthMiddleware() +// }, +// use: { request, route in +// // route handline +// } +// ) diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift new file mode 100644 index 0000000..54adf71 --- /dev/null +++ b/Sources/App/configure.swift @@ -0,0 +1,132 @@ +import DatabaseClient +import Dependencies +import Elementary +import Fluent +import FluentSQLiteDriver +import ManualDCore +import NIOSSL +import Vapor +import VaporElementary +@preconcurrency import VaporRouting +import ViewController + +// configures your application +public func configure( + _ app: Application, + makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) } +) async throws { + // Setup the database client. + let databaseClient = try await setupDatabase(on: app, factory: makeDatabaseClient) + // Add the global middlewares. + addMiddleware(to: app, database: databaseClient) + #if DEBUG + // Live reload of the application for development when launched with the `./swift-dev` command + app.lifecycle.use(BrowserSyncHandler()) + #endif + // Add our route handlers. + addRoutes(to: app) + if app.environment != .testing { + try await app.autoMigrate() + } + // Add our custom cli-commands to the application. + addCommands(to: app) +} + +private func addMiddleware(to app: Application, database databaseClient: DatabaseClient) { + // cors middleware should come before default error middleware using `at: .beginning` + let corsConfiguration = CORSMiddleware.Configuration( + allowedOrigin: .all, + allowedMethods: [.GET, .POST, .PUT, .OPTIONS, .DELETE, .PATCH], + allowedHeaders: [ + .accept, .authorization, .contentType, .origin, + .xRequestedWith, .userAgent, .accessControlAllowOrigin, + ] + ) + let cors = CORSMiddleware(configuration: corsConfiguration) + app.middleware.use(cors, at: .beginning) + + app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + app.middleware.use(app.sessions.middleware) + app.middleware.use(DependenciesMiddleware(database: databaseClient)) +} + +private func setupDatabase( + on app: Application, + factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient +) async throws -> DatabaseClient { + switch app.environment { + case .production, .development: + let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite" + app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite) + default: + app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite) + } + + let databaseClient = makeDatabaseClient(app.db) + + if app.environment != .testing { + try await app.migrations.add(databaseClient.migrations.run()) + } + + return databaseClient +} + +private func addRoutes(to app: Application) { + // Redirect the index path to purchase order route. + // app.get { req in + // req.redirect(to: SiteRoute.View.router.path(for: .purchaseOrder(.index))) + // } + + app.mount( + SiteRoute.router, + middleware: { + if app.environment == .testing { + return nil + } else { + return $0.middleware() + } + }, + use: siteHandler + ) +} + +private func addCommands(to app: Application) { + // #if DEBUG + // app.asyncCommands.use(SeedCommand(), as: "seed") + // #endif + // app.asyncCommands.use(GenerateAdminUserCommand(), as: "generate-admin") +} + +extension SiteRoute { + + fileprivate func middleware() -> [any Middleware]? { + return nil + // switch self { + // case .api(let route): + // return route.middleware + // // case .health: + // // return nil + // case .view(let route): + // return route.middleware + // } + } +} + +@Sendable +private func siteHandler( + request: Request, + route: SiteRoute +) async throws -> any AsyncResponseEncodable { + // @Dependency(\.apiController) var apiController + @Dependency(\.viewController) var viewController + + switch route { + case .api(let route): + return HTTPStatus.ok + // return try await apiController.respond(route, request: request) + case .health: + return HTTPStatus.ok + case .view(let route): + return try await viewController.respond(route: route, request: request) + } +} diff --git a/Sources/App/entrypoint.swift b/Sources/App/entrypoint.swift new file mode 100644 index 0000000..f85b7d8 --- /dev/null +++ b/Sources/App/entrypoint.swift @@ -0,0 +1,34 @@ +import DatabaseClient +import Dependencies +import Logging +import NIOCore +import NIOPosix +import Vapor + +@main +enum Entrypoint { + static func main() async throws { + var env = try Environment.detect() + try LoggingSystem.bootstrap(from: &env) + + let app = try await Application.make(env) + + // This attempts to install NIO as the Swift Concurrency global executor. + // You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency. + // Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down. + // If enabled, you should be careful about calling async functions before this point as it can cause assertion failures. + // let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() + // app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)]) + + do { + try await configure(app) + } catch { + app.logger.report(error: error) + try? await app.asyncShutdown() + throw error + } + + try await app.execute() + try await app.asyncShutdown() + } +} diff --git a/Sources/ManualDCore/Routes/SiteRoute.swift b/Sources/ManualDCore/Routes/SiteRoute.swift index 48d7a9d..99b6969 100644 --- a/Sources/ManualDCore/Routes/SiteRoute.swift +++ b/Sources/ManualDCore/Routes/SiteRoute.swift @@ -5,10 +5,19 @@ import Foundation public enum SiteRoute: Equatable, Sendable { case api(Self.Api) + case health + case view(Self.View) public static let router = OneOf { Route(.case(Self.api)) { SiteRoute.Api.router } + Route(.case(Self.health)) { + Path { "health" } + Method.get + } + Route(.case(Self.view)) { + SiteRoute.View.router + } } } diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index ebfb895..a1986c1 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -6,7 +6,51 @@ extension SiteRoute { /// Represents view routes. /// /// The routes return html. - public enum View { + public enum View: Equatable, Sendable { + case project(ProjectRoute) + static let router = OneOf { + Route(.case(Self.project)) { + SiteRoute.View.ProjectRoute.router + } + } + } +} + +extension SiteRoute.View { + public enum ProjectRoute: Equatable, Sendable { + case create(Project.Create) + case form + case index + + static let rootPath = "projects" + + public static let router = OneOf { + Route(.case(Self.create)) { + Path { rootPath } + Method.post + Body { + FormData { + Field("name", .string) + Field("streetAddress", .string) + Field("city", .string) + Field("state", .string) + Field("zipCode", .string) + } + .map(.memberwise(Project.Create.init)) + } + } + Route(.case(Self.form)) { + Path { + rootPath + "create" + } + Method.get + } + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + } } } diff --git a/Sources/ViewController/Interface.swift b/Sources/ViewController/Interface.swift new file mode 100644 index 0000000..2ef42b6 --- /dev/null +++ b/Sources/ViewController/Interface.swift @@ -0,0 +1,43 @@ +import Dependencies +import DependenciesMacros +import Elementary +import Logging +import ManualDCore + +extension DependencyValues { + public var viewController: ViewController { + get { self[ViewController.self] } + set { self[ViewController.self] = newValue } + } +} + +public typealias AnySendableHTML = (any HTML & Sendable) + +@DependencyClient +public struct ViewController: Sendable { + public var view: @Sendable (Request) async throws -> AnySendableHTML +} + +extension ViewController { + + public struct Request: Sendable { + + public let route: SiteRoute.View + public let isHtmxRequest: Bool + public let logger: Logger + + public init( + route: SiteRoute.View, + isHtmxRequest: Bool, + logger: Logger + ) { + self.route = route + self.isHtmxRequest = isHtmxRequest + self.logger = logger + } + } +} + +extension ViewController: TestDependencyKey { + public static let testValue = Self() +} diff --git a/Sources/ViewController/Views/MainPage.swift b/Sources/ViewController/Views/MainPage.swift new file mode 100644 index 0000000..41b7867 --- /dev/null +++ b/Sources/ViewController/Views/MainPage.swift @@ -0,0 +1,25 @@ +import Elementary + +public struct MainPage: SendableHTMLDocument where Inner: Sendable { + public var title: String { "Manual-D" } + public var lang: String { "en" } + let inner: Inner + + init(_ inner: () -> Inner) { + self.inner = inner() + } + + public var head: some HTML { + meta(.charset(.utf8)) + script(.src("https://unpkg.com/htmx.org@2.0.8")) {} + script(.src("/js/main.js")) {} + link(.rel(.stylesheet), .href("/css/main.css")) + link(.rel(.icon), .href("/images/favicon.ico"), .custom(name: "type", value: "image/x-icon")) + } + + public var body: some HTML { + inner + } +} + +public protocol SendableHTMLDocument: HTMLDocument, Sendable {}