feat: Begins vapor application, begins view controller.

This commit is contained in:
2025-12-30 10:12:45 -05:00
parent 6eedb7396d
commit 4e1be161b1
15 changed files with 586 additions and 9 deletions

View File

@@ -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",

View File

@@ -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"),
]
),
]

0
Public/css/main.css Normal file
View File

0
Public/js/main.js Normal file
View File

View File

@@ -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

View File

@@ -0,0 +1,7 @@
import Vapor
extension Request {
var isHtmxRequest: Bool {
headers.contains(name: "hx-request")
}
}

View File

@@ -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)
})
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<R: Parser>(
_ 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<Router: Parser>: 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
// }
// )

132
Sources/App/configure.swift Normal file
View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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()
}

View File

@@ -0,0 +1,25 @@
import Elementary
public struct MainPage<Inner: HTML>: 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 {}