From 0fad02435010cf8846e64991746482eebce554c2 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sat, 25 Jan 2025 15:54:02 -0500 Subject: [PATCH] feat: Moves api controller to it's own module. --- Package.swift | 43 +++++---- Sources/ApiController/ApiController.swift | 34 +++++++ .../ApiController+live.swift} | 96 +++++++++++-------- .../GenerateAdminUserCommand.swift | 0 Sources/App/{ => Commands}/SeedCommand.swift | 0 Sources/App/Controllers/.gitkeep | 0 .../Extensions/ApiController+respond.swift | 35 +++++++ .../App/Middleware/ApiRoute+middleware.swift | 20 ++++ .../Middleware/DependenciesMiddleware.swift | 5 + Sources/App/configure.swift | 47 ++++++--- Sources/DatabaseClientLive/Users+live.swift | 22 +++-- Sources/SharedModels/Routes/ApiRoute.swift | 6 ++ Sources/SharedModels/Routes/ViewRoute.swift | 5 - Sources/ViewController/ViewController.swift | 1 + 14 files changed, 234 insertions(+), 80 deletions(-) create mode 100644 Sources/ApiController/ApiController.swift rename Sources/{App/Controllers/ApiController.swift => ApiControllerLive/ApiController+live.swift} (59%) rename Sources/App/{ => Commands}/GenerateAdminUserCommand.swift (100%) rename Sources/App/{ => Commands}/SeedCommand.swift (100%) delete mode 100644 Sources/App/Controllers/.gitkeep create mode 100644 Sources/App/Extensions/ApiController+respond.swift create mode 100644 Sources/App/Middleware/ApiRoute+middleware.swift diff --git a/Package.swift b/Package.swift index 7ed7b87..9578736 100644 --- a/Package.swift +++ b/Package.swift @@ -8,6 +8,8 @@ let package = Package( ], products: [ .executable(name: "App", targets: ["App"]), + .library(name: "ApiController", targets: ["ApiController"]), + .library(name: "ApiControllerLive", targets: ["ApiControllerLive"]), .library(name: "SharedModels", targets: ["SharedModels"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]), @@ -37,6 +39,7 @@ let package = Package( .executableTarget( name: "App", dependencies: [ + .target(name: "ApiControllerLive"), .target(name: "DatabaseClientLive"), .target(name: "ViewControllerLive"), .product(name: "Fluent", package: "fluent"), @@ -54,27 +57,24 @@ let package = Package( ], swiftSettings: swiftSettings ), - // .testTarget( - // name: "AppTests", - // dependencies: [ - // .target(name: "App"), - // .target(name: "HtmlSnapshotTesting"), - // .product(name: "XCTVapor", package: "vapor") - // ], - // resources: [ - // .copy("__Snapshots__") - // ], - // swiftSettings: swiftSettings - // ), - .testTarget( - name: "ViewRouteTests", + .target( + name: "ApiController", dependencies: [ - .target(name: "App"), - .product(name: "VaporTesting", package: "vapor") + .target(name: "SharedModels"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies") ], - swiftSettings: swiftSettings ), + .target( + name: "ApiControllerLive", + dependencies: [ + .target(name: "ApiController"), + .target(name: "DatabaseClient") + ], + swiftSettings: swiftSettings + ), + .testTarget( name: "ApiRouteTests", dependencies: [ @@ -153,6 +153,15 @@ let package = Package( resources: [ .copy("__Snapshots__") ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "ViewRouteTests", + dependencies: [ + .target(name: "App"), + .product(name: "VaporTesting", package: "vapor") + ], + swiftSettings: swiftSettings ) ], diff --git a/Sources/ApiController/ApiController.swift b/Sources/ApiController/ApiController.swift new file mode 100644 index 0000000..4cac80a --- /dev/null +++ b/Sources/ApiController/ApiController.swift @@ -0,0 +1,34 @@ +import Dependencies +import DependenciesMacros +import Logging +import SharedModels + +public extension DependencyValues { + var apiController: ApiController { + get { self[ApiController.self] } + set { self[ApiController.self] = newValue } + } +} + +@DependencyClient +public struct ApiController: Sendable { + public var json: @Sendable (Request) async throws -> (any Encodable)? + + public func json(_ route: ApiRoute, logger: Logger) async throws -> (any Encodable)? { + try await json(.init(route, logger: logger)) + } + + public struct Request: Sendable { + public let route: ApiRoute + public let logger: Logger + + public init(_ route: ApiRoute, logger: Logger) { + self.route = route + self.logger = logger + } + } +} + +extension ApiController: TestDependencyKey { + public static let testValue: ApiController = Self() +} diff --git a/Sources/App/Controllers/ApiController.swift b/Sources/ApiControllerLive/ApiController+live.swift similarity index 59% rename from Sources/App/Controllers/ApiController.swift rename to Sources/ApiControllerLive/ApiController+live.swift index 6af4a3e..b99e582 100644 --- a/Sources/App/Controllers/ApiController.swift +++ b/Sources/ApiControllerLive/ApiController+live.swift @@ -1,51 +1,63 @@ -import DatabaseClientLive +@_exported import ApiController +import DatabaseClient import Dependencies -import Fluent +import Logging import SharedModels -import Vapor -private let apiMiddleware: [any Middleware] = [ - UserPasswordAuthenticator(), - UserTokenAuthenticator(), - UserSessionAuthenticator(), - User.guardMiddleware() -] +extension ApiController: DependencyKey { + public static var liveValue: ApiController { + .init(json: { try await $0.respond() }) + } +} -extension ApiRoute { - var middleware: [any Middleware]? { apiMiddleware } +private extension ApiController.Request { - func respond(request: Request) async throws -> any AsyncResponseEncodable { - switch self { + func respond() async throws -> (any Encodable)? { + @Dependency(\.database) var database + + switch route { case let .employee(route): - return try await route.handleApiRequest(request: request) + return try await route.handleApiRequest(logger: logger) + case let .login(login): + return try await TokenResponse(token: database.users.login(login)) case let .purchaseOrder(route): - return try await route.handleApiRequest(request: request) + return try await route.handleApiRequest(logger: logger) case let .user(route): - return try await route.handleApiRequest(request: request) + return try await route.handleApiRequest(logger: logger) case let .vendor(route): - return try await route.handleApiRequest(request: request) + return try await route.handleApiRequest(logger: logger) case let .vendorBranch(route): - return try await route.handleApiRequest(request: request) + return try await route.handleApiRequest(logger: logger) } } } -extension ApiRoute.EmployeeRoute { +private struct TokenResponse: Encodable { + let token: String - func handleApiRequest(request: Request) async throws -> any AsyncResponseEncodable { + init(token: User.Token) { + self.token = token.value + } + +} + +private extension ApiRoute.EmployeeRoute { + + func handleApiRequest(logger: Logger) async throws -> (any Encodable)? { @Dependency(\.database) var database switch self { case let .delete(id: id): try await database.employees.delete(id) - return HTTPStatus.ok + return nil case let .create(employee): return try await database.employees.create(employee) case .index: return try await database.employees.fetchAll() case let .get(id: id): guard let employee = try await database.employees.get(id) else { - throw Abort(.badRequest, reason: "Employee not found") + logger.error("Employee not found for id: \(id)") + throw ApiError(reason: "Employee not found") } return employee case let .update(id: id, updates: updates): @@ -54,21 +66,22 @@ extension ApiRoute.EmployeeRoute { } } -extension ApiRoute.PurchaseOrderRoute { +private extension ApiRoute.PurchaseOrderRoute { - func handleApiRequest(request: Request) async throws -> any AsyncResponseEncodable { + func handleApiRequest(logger: Logger) async throws -> (any Encodable)? { @Dependency(\.database.purchaseOrders) var purchaseOrders switch self { case let .delete(id: id): try await purchaseOrders.delete(id) - return HTTPStatus.ok + return nil case .index: return try await purchaseOrders.fetchAll() case let .create(purchaseOrder): return try await purchaseOrders.create(purchaseOrder) case let .get(id: id): guard let output = try await purchaseOrders.get(id) else { - throw Abort(.badRequest, reason: "Purchase order not found.") + logger.error("Purchase Order not found for id: \(id)") + throw ApiError(reason: "Purchase order not found.") } return output case let .page(page: page, limit: limit): @@ -78,21 +91,22 @@ extension ApiRoute.PurchaseOrderRoute { } // TODO: Add Login. -extension ApiRoute.UserRoute { +private extension ApiRoute.UserRoute { - func handleApiRequest(request: Request) async throws -> any AsyncResponseEncodable { + func handleApiRequest(logger: Logger) async throws -> (any Encodable)? { @Dependency(\.database.users) var users switch self { case let .delete(id: id): try await users.delete(id) - return HTTPStatus.ok + return nil case let .create(user): return try await users.create(user) case .index: return try await users.fetchAll() case let .get(id: id): + logger.error("User not found for id: \(id)") guard let user = try await users.get(id) else { - throw Abort(.badRequest, reason: "Employee not found") + throw ApiError(reason: "Employee not found") } return user // case let .login(user): @@ -103,18 +117,19 @@ extension ApiRoute.UserRoute { } } -extension ApiRoute.VendorRoute { - func handleApiRequest(request: Request) async throws -> any AsyncResponseEncodable { +private extension ApiRoute.VendorRoute { + func handleApiRequest(logger: Logger) async throws -> (any Encodable)? { @Dependency(\.database.vendors) var vendors switch self { case let .delete(id: id): try await vendors.delete(id) - return HTTPStatus.ok + return nil case let .create(vendor): return try await vendors.create(vendor) case let .get(id: id): guard let vendor = try await vendors.get(id) else { - throw Abort(.badRequest, reason: "Employee not found") + logger.error("Vendor not found for id: \(id)") + throw ApiError(reason: "Vendor not found") } return vendor case let .update(id: id, updates: updates): @@ -128,13 +143,13 @@ extension ApiRoute.VendorRoute { } } -extension ApiRoute.VendorBranchRoute { - func handleApiRequest(request: Request) async throws -> any AsyncResponseEncodable { +private extension ApiRoute.VendorBranchRoute { + func handleApiRequest(logger: Logger) async throws -> (any Encodable)? { @Dependency(\.database.vendorBranches) var vendorBranches switch self { case let .delete(id: id): try await vendorBranches.delete(id) - return HTTPStatus.ok + return nil case let .create(branch): return try await vendorBranches.create(branch) case let .index(for: optionalVendorID): @@ -144,7 +159,8 @@ extension ApiRoute.VendorBranchRoute { return try await vendorBranches.fetchAll(.for(vendorID: vendorID)) case let .get(id: id): guard let branch = try await vendorBranches.get(id) else { - throw Abort(.badRequest, reason: "Employee not found") + logger.error("Vendor branch not found for id: \(id)") + throw ApiError(reason: "Vendor branch not found") } return branch case let .update(id: id, updates: updates): @@ -152,3 +168,7 @@ extension ApiRoute.VendorBranchRoute { } } } + +struct ApiError: Error { + let reason: String +} diff --git a/Sources/App/GenerateAdminUserCommand.swift b/Sources/App/Commands/GenerateAdminUserCommand.swift similarity index 100% rename from Sources/App/GenerateAdminUserCommand.swift rename to Sources/App/Commands/GenerateAdminUserCommand.swift diff --git a/Sources/App/SeedCommand.swift b/Sources/App/Commands/SeedCommand.swift similarity index 100% rename from Sources/App/SeedCommand.swift rename to Sources/App/Commands/SeedCommand.swift diff --git a/Sources/App/Controllers/.gitkeep b/Sources/App/Controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Sources/App/Extensions/ApiController+respond.swift b/Sources/App/Extensions/ApiController+respond.swift new file mode 100644 index 0000000..a69bb48 --- /dev/null +++ b/Sources/App/Extensions/ApiController+respond.swift @@ -0,0 +1,35 @@ +import ApiController +import SharedModels +import Vapor + +extension ApiController { + + func respond(_ route: ApiRoute, request: Vapor.Request) async throws -> any AsyncResponseEncodable { + guard let encodable = try await json(route, logger: request.logger) else { + return HTTPStatus.ok + } + return AnyJSONResponse(value: encodable) + } +} + +struct AnyJSONResponse: AsyncResponseEncodable { + public var headers: HTTPHeaders = ["Content-Type": "application/json"] + let value: any Encodable + + init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) { + if additionalHeaders.contains(name: .contentType) { + self.headers = additionalHeaders + } else { + headers.add(contentsOf: additionalHeaders) + } + self.value = value + } + + func encodeResponse(for request: Request) async throws -> Response { + try Response( + status: .ok, + headers: headers, + body: .init(data: JSONEncoder().encode(value)) + ) + } +} diff --git a/Sources/App/Middleware/ApiRoute+middleware.swift b/Sources/App/Middleware/ApiRoute+middleware.swift new file mode 100644 index 0000000..670d1c6 --- /dev/null +++ b/Sources/App/Middleware/ApiRoute+middleware.swift @@ -0,0 +1,20 @@ +import DatabaseClientLive +import SharedModels +import Vapor + +private let apiMiddleware: [any Middleware] = [ + UserPasswordAuthenticator(), + UserTokenAuthenticator(), + UserSessionAuthenticator(), + User.guardMiddleware() +] + +extension ApiRoute { + var middleware: [any Middleware]? { + switch self { + case .login: return nil + default: + return apiMiddleware + } + } +} diff --git a/Sources/App/Middleware/DependenciesMiddleware.swift b/Sources/App/Middleware/DependenciesMiddleware.swift index 46a66e0..3b4003f 100644 --- a/Sources/App/Middleware/DependenciesMiddleware.swift +++ b/Sources/App/Middleware/DependenciesMiddleware.swift @@ -1,3 +1,4 @@ +import ApiControllerLive import DatabaseClientLive import Dependencies import Vapor @@ -8,14 +9,17 @@ import ViewControllerLive 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 = .liveValue ) { self.values = withEscapedDependencies { $0 } + self.apiController = apiController self.database = database self.viewController = viewController } @@ -23,6 +27,7 @@ struct DependenciesMiddleware: AsyncMiddleware { 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 diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 57d12cb..130c100 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,3 +1,4 @@ +import ApiController import DatabaseClientLive import Dependencies import Elementary @@ -15,6 +16,24 @@ 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, @@ -27,11 +46,13 @@ public func configure( app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(app.sessions.middleware) + app.middleware.use(DependenciesMiddleware(database: databaseClient)) +} - #if DEBUG - app.lifecycle.use(BrowserSyncHandler()) - #endif - +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" @@ -46,8 +67,10 @@ public func configure( try await app.migrations.add(databaseClient.migrations()) } - app.middleware.use(DependenciesMiddleware(database: databaseClient)) + return databaseClient +} +private func addRoutes(to app: Application) { // Redirect the index path to purchase order route. app.get { req in req.redirect(to: ViewRoute.router.path(for: .purchaseOrder(.index))) @@ -64,18 +87,16 @@ public func configure( }, use: siteHandler ) +} - if app.environment != .testing { - try await app.autoMigrate() - } - +private func addCommands(to app: Application) { #if DEBUG app.asyncCommands.use(SeedCommand(), as: "seed") #endif app.asyncCommands.use(GenerateAdminUserCommand(), as: "generate-admin") } -extension SiteRoute { +private extension SiteRoute { func middleware() -> [any Middleware]? { switch self { @@ -90,14 +111,16 @@ extension SiteRoute { } @Sendable -func siteHandler( +private func siteHandler( request: Request, route: SiteRoute ) async throws -> any AsyncResponseEncodable { + @Dependency(\.apiController) var apiController @Dependency(\.viewController) var viewController + switch route { case let .api(route): - return try await route.respond(request: request) + return try await apiController.respond(route, request: request) case .health: return HTTPStatus.ok case let .view(route): diff --git a/Sources/DatabaseClientLive/Users+live.swift b/Sources/DatabaseClientLive/Users+live.swift index b4ec8bf..b354c24 100644 --- a/Sources/DatabaseClientLive/Users+live.swift +++ b/Sources/DatabaseClientLive/Users+live.swift @@ -25,7 +25,9 @@ public extension DatabaseClient.Users { } login: { login in try login.validate() - var query = UserModel.query(on: database) + var query = UserModel + .query(on: database) + .with(\.$token) if let username = login.username { query = query.filter(\UserModel.$username == username) @@ -37,15 +39,19 @@ public extension DatabaseClient.Users { throw NotFoundError() } - let token = try user.generateToken() + let token: User.Token - try await token.save(on: database) + // Check if the user already has a token. + if let userToken = user.token { + token = try userToken.toDTO() + } else { + // Generate a new token for the user if they didn't have one. + let tokenModel = try user.generateToken() + try await tokenModel.save(on: database) + token = try tokenModel.toDTO() + } - return try User.Token( - id: token.requireID(), - userID: user.requireID(), - value: token.value - ) + return token } logout: { id in guard let token = try await UserTokenModel.find(id, on: database) diff --git a/Sources/SharedModels/Routes/ApiRoute.swift b/Sources/SharedModels/Routes/ApiRoute.swift index 4003bcb..6899f16 100644 --- a/Sources/SharedModels/Routes/ApiRoute.swift +++ b/Sources/SharedModels/Routes/ApiRoute.swift @@ -5,6 +5,7 @@ import Foundation public enum ApiRoute: Sendable, Equatable { case employee(EmployeeRoute) + case login(User.Login) case purchaseOrder(PurchaseOrderRoute) case user(UserRoute) case vendor(VendorRoute) @@ -17,6 +18,11 @@ public enum ApiRoute: Sendable, Equatable { rootPath EmployeeRoute.router } + Route(.case(Self.login)) { + Path { "api"; "v1"; "login" } + Method.post + Body(.json(User.Login.self)) + } Route(.case(Self.purchaseOrder)) { rootPath PurchaseOrderRoute.router diff --git a/Sources/SharedModels/Routes/ViewRoute.swift b/Sources/SharedModels/Routes/ViewRoute.swift index 9b08add..6c46428 100644 --- a/Sources/SharedModels/Routes/ViewRoute.swift +++ b/Sources/SharedModels/Routes/ViewRoute.swift @@ -4,7 +4,6 @@ import Foundation public enum ViewRoute: Sendable, Equatable { - // case index case employee(EmployeeRoute) case login(LoginRoute) case purchaseOrder(PurchaseOrderRoute) @@ -13,9 +12,6 @@ public enum ViewRoute: Sendable, Equatable { case vendorBranch(VendorBranchRoute) public static let router = OneOf { - // Route(.case(Self.index)) { - // Method.get - // } Route(.case(Self.employee)) { EmployeeRoute.router } Route(.case(Self.login)) { LoginRoute.router } Route(.case(Self.purchaseOrder)) { PurchaseOrderRoute.router } @@ -23,7 +19,6 @@ public enum ViewRoute: Sendable, Equatable { Route(.case(Self.vendor)) { VendorRoute.router } Route(.case(Self.vendorBranch)) { VendorBranchRoute.router } } - } public extension ViewRoute { diff --git a/Sources/ViewController/ViewController.swift b/Sources/ViewController/ViewController.swift index 9a28512..226e3c9 100644 --- a/Sources/ViewController/ViewController.swift +++ b/Sources/ViewController/ViewController.swift @@ -47,6 +47,7 @@ public struct ViewController: Sendable { self.logger = logger } } + } extension ViewController: TestDependencyKey {