From 2b51325db90463f09612899c7af518fe33394f73 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 16 Jul 2024 12:07:15 -0700 Subject: [PATCH] Cleanup Lifecycle & ServiceLifecycle --- Alchemy/AlchemyX/Router+Resource.swift | 12 +- Alchemy/Application/Application.swift | 174 +++--------- Alchemy/Application/Lifecycle.swift | 86 +++++- Alchemy/Application/Plugins/Core.swift | 44 --- Alchemy/Command/Command.swift | 6 - Alchemy/Command/Commander.swift | 40 +-- Alchemy/Command/Plugins/Commands.swift | 2 - Alchemy/HTTP/Commands/Serve/Responder.swift | 113 ++++++++ .../HTTP/Commands/Serve/ServeCommand.swift | 97 +++++++ Alchemy/HTTP/Commands/ServeCommand.swift | 260 ------------------ .../HTTPFields+ContentInformation.swift | 14 +- Alchemy/Queue/Commands/WorkCommand.swift | 13 +- .../Scheduler/Commands/ScheduleCommand.swift | 4 +- AlchemyTest/TestCase/TestCase.swift | 7 +- .../Commands/MigrateCommandTests.swift | 2 +- .../Database/Commands/SeedCommandTests.swift | 2 +- Tests/HTTP/Commands/ServeCommandTests.swift | 4 +- Tests/Queues/Commands/WorkCommandTests.swift | 20 +- Tests/Routing/RouterTests.swift | 2 +- 19 files changed, 379 insertions(+), 523 deletions(-) create mode 100644 Alchemy/HTTP/Commands/Serve/Responder.swift create mode 100644 Alchemy/HTTP/Commands/Serve/ServeCommand.swift delete mode 100644 Alchemy/HTTP/Commands/ServeCommand.swift diff --git a/Alchemy/AlchemyX/Router+Resource.swift b/Alchemy/AlchemyX/Router+Resource.swift index 92cc8b51..038aff66 100644 --- a/Alchemy/AlchemyX/Router+Resource.swift +++ b/Alchemy/AlchemyX/Router+Resource.swift @@ -12,21 +12,15 @@ extension Application { ) -> Self where R.Identifier: SQLValueConvertible & LosslessStringConvertible { use(ResourceController(db: db, tableName: table)) if updateTable { - Container.main.lifecycleServices.append(ResourceMigrationService(db: db)) + Container.onStart { + try await db.updateSchema(R.self) + } } return self } } -struct ResourceMigrationService: ServiceLifecycle.Service, @unchecked Sendable { - let db: Database - - func run() async throws { - try await db.updateSchema(R.self) - } -} - extension Router { @discardableResult public func useResource( diff --git a/Alchemy/Application/Application.swift b/Alchemy/Application/Application.swift index eaacc1cd..b1a6c7d1 100644 --- a/Alchemy/Application/Application.swift +++ b/Alchemy/Application/Application.swift @@ -1,3 +1,5 @@ +import HummingbirdCore + /// The core type for an Alchemy application. /// /// @Application @@ -14,15 +16,16 @@ public protocol Application: Router { var container: Container { get } /// Any custom plugins of this application. var plugins: [Plugin] { get } - + /// Build the hummingbird server + var server: HTTPServerBuilder { get } + init() - /// Boots the app's dependencies. Don't override the default for this unless - /// you want to prevent default Alchemy services from loading. - func bootPlugins() async throws /// Setup your application here. Called after all services are registered. func boot() throws - + /// Optional shutdown logic here. + func shutdown() throws + // MARK: Default Plugin Configurations /// This application's HTTP configuration. @@ -47,151 +50,58 @@ public protocol Application: Router { // MARK: Defaults public extension Application { - var container: Container { .main } - var plugins: [Plugin] { [] } - - func bootPlugins() async throws { - let alchemyPlugins: [Plugin] = [ - Core(), - Schedules(), - EventStreams(), - http, - commands, - filesystems, - databases, - caches, - queues, - ] - - let allPlugins = alchemyPlugins + plugins - for plugin in alchemyPlugins + plugins { - plugin.registerServices(in: self) - } - - Container.onStart { - for plugin in allPlugins { - try await plugin.boot(app: self) - } - } - - Container.onShutdown { - for plugin in allPlugins.reversed() { - try await plugin.shutdownServices(in: self) - } - } - } - - func boot() throws { - // - } - - func bootRouter() { - (self as? Controller)?.route(self) - } - - // MARK: Plugin Defaults - - var http: HTTPConfiguration { HTTPConfiguration() } + var caches: Caches { Caches() } var commands: Commands { [] } + var container: Container { .main } var databases: Databases { Databases() } - var caches: Caches { Caches() } - var queues: Queues { Queues() } var filesystems: Filesystems { Filesystems() } + var http: HTTPConfiguration { HTTPConfiguration() } var loggers: Loggers { Loggers() } - - func schedule(on schedule: Scheduler) { - // - } -} + var plugins: [Plugin] { [] } + var queues: Queues { Queues() } + var server: HTTPServerBuilder { .http1() } -import ServiceLifecycle + func boot() throws {} + func shutdown() throws {} + func schedule(on schedule: Scheduler) {} +} // MARK: Running -public extension Application { - func run() async throws { +extension Application { + // @main support + public static func main() async throws { + let app = Self() do { - try await bootPlugins() - try boot() - bootRouter() - try await start() + try await app.willRun() + try await app.run() + try await app.didRun() } catch { - commander.exit(error: error) + app.commander.exit(error: error) } } - - /// Starts the application with the given arguments. - func start(_ args: String..., waitOrShutdown: Bool = true) async throws { - try await start(args: args.isEmpty ? nil : args, waitOrShutdown: waitOrShutdown) - } - /// Starts the application with the given arguments. - /// - /// @MainActor ensures that calls to `wait()` doesn't block an `EventLoop`. - @MainActor - func start(args: [String]? = nil, waitOrShutdown: Bool = true) async throws { - - // 0. Add service - - Container.main - .lifecycleServices - .append( - ApplicationService( - args: args, - waitOrShutdown: waitOrShutdown, - app: self - ) - ) - - // 1. Start the application lifecycle. - - try await serviceGroup.run() - } - - /// Stops the application. - func stop() async { - await serviceGroup.triggerGracefulShutdown() + /// Runs the application with the given arguments. + public func run(_ args: String...) async throws { + try await lifecycle.start(args: args.isEmpty ? nil : args) } - // @main support - static func main() async throws { - try await Self().run() - } -} - -private actor ApplicationService: ServiceLifecycle.Service { - var args: [String]? = nil - var waitOrShutdown: Bool = true - let app: Application - - init(args: [String]? = nil, waitOrShutdown: Bool, app: Application) { - self.args = args - self.waitOrShutdown = waitOrShutdown - self.app = app + /// Sets up the app for running. + public func willRun() async throws { + let lifecycle = Lifecycle(app: self) + try await lifecycle.start() + (self as? Controller)?.route(self) + try boot() } - func run() async throws { - - // 0. Parse and run a `Command` based on the application arguments. - - let command = try await app.commander.runCommand(args: args) - guard waitOrShutdown else { return } - - // 1. Wait for lifecycle or immediately shut down depending on if the - // command should run indefinitely. - - if !command.runUntilStopped { - await app.stop() - } - - // 2. this is required to not throw service lifecycle errors - - try await gracefulShutdown() + /// Any cleanup after the app finishes running. + public func didRun() async throws { + try shutdown() + try await lifecycle.shutdown() } -} -fileprivate extension ParsableCommand { - var runUntilStopped: Bool { - (Self.self as? Command.Type)?.runUntilStopped ?? false + /// Stops the application. + public func stop() async { + await lifecycle.stop() } } diff --git a/Alchemy/Application/Lifecycle.swift b/Alchemy/Application/Lifecycle.swift index b9ad6940..ef1382cd 100644 --- a/Alchemy/Application/Lifecycle.swift +++ b/Alchemy/Application/Lifecycle.swift @@ -1,32 +1,58 @@ import ServiceLifecycle -actor Lifecycle: ServiceLifecycle.Service { - fileprivate var startTasks: [() async throws -> Void] = [] - fileprivate var shutdownTasks: [() async throws -> Void] = [] +/// Manages the startup and shutdown of an Application as well as it's various +/// services and configurations. +actor Lifecycle { + typealias Action = () async throws -> Void - var didStart = false - var didStop = false + fileprivate var startTasks: [Action] = [] + fileprivate var shutdownTasks: [Action] = [] - func run() async throws { - try await start() - try await gracefulShutdown() - try await shutdown() + let app: Application + let plugins: [Plugin] + + private var group: ServiceGroup? + private var services: [ServiceLifecycle.Service] = [] + + init(app: Application) { + self.app = app + self.plugins = [ + Core(), + Schedules(), + EventStreams(), + app.http, + app.commands, + app.filesystems, + app.databases, + app.caches, + app.queues, + ] + app.plugins } func start() async throws { - guard !didStart else { return } - didStart = true + app.container.register(self).singleton() + + for plugin in plugins { + plugin.registerServices(in: app) + } + + for plugin in plugins { + try await plugin.boot(app: app) + } + for start in startTasks { try await start() } } func shutdown() async throws { - guard !didStop else { return } - didStop = true for shutdown in shutdownTasks.reversed() { try await shutdown() } + + for plugin in plugins.reversed() { + try await plugin.shutdownServices(in: app) + } } func onStart(action: @escaping () async throws -> Void) { @@ -36,6 +62,36 @@ actor Lifecycle: ServiceLifecycle.Service { func onShutdown(action: @escaping () async throws -> Void) { self.shutdownTasks.append(action) } + + func addService(_ service: ServiceLifecycle.Service) { + services.append(service) + } + + func start(args: [String]? = nil) async throws { + let commander = Container.require(Commander.self) + commander.setArgs(args) + let allServices = services + [commander] + let group = ServiceGroup( + configuration: ServiceGroupConfiguration( + services: allServices.map { + .init( + service: $0, + successTerminationBehavior: .gracefullyShutdownGroup, + failureTerminationBehavior: .gracefullyShutdownGroup + ) + }, + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Log + ) + ) + + self.group = group + try await group.run() + } + + func stop() async { + await group?.triggerGracefulShutdown() + } } extension Application { @@ -56,4 +112,8 @@ extension Container { public static func onShutdown(action: @escaping () async throws -> Void) { Task { await lifecycle.onShutdown(action: action) } } + + public static func addService(_ service: ServiceLifecycle.Service) { + Task { await lifecycle.addService(service) } + } } diff --git a/Alchemy/Application/Plugins/Core.swift b/Alchemy/Application/Plugins/Core.swift index 0ce19a2c..d8f81ddf 100644 --- a/Alchemy/Application/Plugins/Core.swift +++ b/Alchemy/Application/Plugins/Core.swift @@ -31,30 +31,6 @@ struct Core: Plugin { return current } - - let lifecycle = Lifecycle() - let lifecycleServices = LifecycleServices(services: [lifecycle]) - - app.container.register(lifecycle).singleton() - app.container.register(lifecycleServices).singleton() - - // 4. Register ServiceGroup - - app.container.register { container in - var logger: Logger = container.require() - - // ServiceLifecycle is pretty noisy. Let's default it to - // logging @ .notice or above, unless the user has set - // the default log level to .debug or below. - if logger.logLevel > .debug { - logger.logLevel = .notice - } - - return ServiceGroup( - services: container.lifecycleServices.services, - logger: logger - ) - }.singleton() } func boot(app: Application) { @@ -66,26 +42,10 @@ struct Core: Plugin { } } -public final class LifecycleServices { - fileprivate var services: [ServiceLifecycle.Service] - - init(services: [ServiceLifecycle.Service] = []) { - self.services = services - } - - func append(_ service: ServiceLifecycle.Service) { - services.append(service) - } -} - extension Application { public var env: Environment { container.require() } - - public var serviceGroup: ServiceGroup { - container.require() - } } extension Container { @@ -93,10 +53,6 @@ extension Container { require() } - var lifecycleServices: LifecycleServices { - require() - } - fileprivate var coreCount: Int { env.isTesting ? 1 : System.coreCount } diff --git a/Alchemy/Command/Command.swift b/Alchemy/Command/Command.swift index 13796717..9f635227 100644 --- a/Alchemy/Command/Command.swift +++ b/Alchemy/Command/Command.swift @@ -43,17 +43,11 @@ public protocol Command: AsyncParsableCommand { /// name as an argument. Defaults to the type name. static var name: String { get } - /// When running the app with this command, should the app - /// stay alive after `run` is finished. - static var runUntilStopped: Bool { get } - /// Run the command. func run() async throws } extension Command { - public static var runUntilStopped: Bool { false } - public static var name: String { Alchemy.name(of: Self.self) } diff --git a/Alchemy/Command/Commander.swift b/Alchemy/Command/Commander.swift index 6a59bf4e..f3da6f8a 100644 --- a/Alchemy/Command/Commander.swift +++ b/Alchemy/Command/Commander.swift @@ -1,4 +1,6 @@ -final class Commander { +import ServiceLifecycle + +final class Commander: ServiceLifecycle.Service, @unchecked Sendable { /// Command to launch a given application. private struct Launch: AsyncParsableCommand { static var configuration: CommandConfiguration { @@ -13,10 +15,11 @@ final class Commander { /// The environment file to load. Defaults to `env`. @Option(name: .shortAndLong) var env: String = "env" - /// The environment file to load. Defaults to `env`. + /// The default log level. @Option(name: .shortAndLong) var log: Logger.Level? = nil } + private var args: [String]? private var commands: [Command.Type] = [] private var defaultCommand: Command.Type = ServeCommand.self @@ -30,32 +33,31 @@ final class Commander { defaultCommand = command } + func setArgs(_ value: [String]?) { + args = value + } + + // MARK: Service + + func run() async throws { + try await runCommand(args: args) + } + // MARK: Running Commands /// Runs a command based on the given arguments. Returns the command that /// ran, after it is finished running. - func runCommand(args: [String]? = nil) async throws -> ParsableCommand { - - // 0. Parse the Command + func runCommand(args: [String]? = nil) async throws { // When running a command with no arguments during a test, send an empty // array of arguments to swift-argument-parser. Otherwise, it will - // try to parse the arguments of the test runner throw errors. + // try to parse the test runner arguments and throw errors. var command = try Launch.parseAsRoot(args ?? (Env.isTesting ? [] : nil)) - - // 1. Run the Command on an `EventLoop`. - - try await Loop.asyncSubmit { - guard var asyncCommand = command as? AsyncParsableCommand else { - try command.run() - return - } - - try await asyncCommand.run() + if var command = command as? AsyncParsableCommand { + try await command.run() + } else { + try command.run() } - .get() - - return command } func exit(error: Error) { diff --git a/Alchemy/Command/Plugins/Commands.swift b/Alchemy/Command/Plugins/Commands.swift index c6e3ad08..193362fc 100644 --- a/Alchemy/Command/Plugins/Commands.swift +++ b/Alchemy/Command/Plugins/Commands.swift @@ -21,8 +21,6 @@ public struct Commands: Plugin, ExpressibleByArrayLiteral { app.registerCommand(JobMakeCommand.self) app.registerCommand(ViewMakeCommand.self) app.registerCommand(ServeCommand.self) - - app.setDefaultCommand(ServeCommand.self) } } diff --git a/Alchemy/HTTP/Commands/Serve/Responder.swift b/Alchemy/HTTP/Commands/Serve/Responder.swift new file mode 100644 index 00000000..4fb4d326 --- /dev/null +++ b/Alchemy/HTTP/Commands/Serve/Responder.swift @@ -0,0 +1,113 @@ +import Hummingbird + +actor Responder: HTTPResponder { + struct Context: RequestContext { + public let localAddress: SocketAddress? + public let remoteAddress: SocketAddress? + public var coreContext: CoreRequestContextStorage + + public init(source: ApplicationRequestContextSource) { + self.localAddress = source.channel.localAddress + self.remoteAddress = source.channel.remoteAddress + self.coreContext = .init(source: source) + } + } + + @Inject var handler: HTTPHandler + + let logResponses: Bool + + init(logResponses: Bool) { + self.logResponses = logResponses + } + + func respond(to hbRequest: Hummingbird.Request, context: Context) async throws -> Hummingbird.Response { + let startedAt = Date() + let req = hbRequest.request(context: context) + let res = await handler.handle(request: req) + logResponse(req: req, res: res, startedAt: startedAt) + return res.hbResponse + } +} + +extension Hummingbird.Request { + fileprivate func request(context: Responder.Context) -> Request { + Request( + method: method, + uri: uri.string, + headers: headers, + body: .stream(sequence: body), + localAddress: context.localAddress, + remoteAddress: context.remoteAddress + ) + } +} + +extension Response { + fileprivate var hbResponse: Hummingbird.Response { + let responseBody: ResponseBody = switch body { + case .buffer(let buffer): + .init(byteBuffer: buffer) + case .stream(let stream): + .init(asyncSequence: stream) + case .none: + .init() + } + + return .init(status: status, headers: headers, body: responseBody) + } +} + +// MARK: Response Logging + +extension Responder { + private enum Formatters { + static let date: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + static let time: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + }() + } + + fileprivate func logResponse(req: Request, res: Response, startedAt: Date) { + guard logResponses else { return } + + let finishedAt = Date() + let dateString = Formatters.date.string(from: finishedAt) + let timeString = Formatters.time.string(from: finishedAt) + let left = "\(dateString) \(timeString) \(req.method) \(req.path)" + let right = "\(startedAt.elapsedString) \(res.status.code)" + let dots = Log.dots(left: left, right: right) + + if Env.isXcode { + let logString = "\(dateString.lightBlack) \(timeString) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(res.status.code)" + switch res.status.code { + case 500...599: + Log.critical(logString) + case 400...499: + Log.warning(logString) + default: + Log.comment(logString) + } + } else { + var code = "\(res.status.code)" + switch res.status.code { + case 200...299: + code = code.green + case 400...499: + code = code.yellow + case 500...599: + code = code.red + default: + code = code.white + } + + Log.comment("\(dateString.lightBlack) \(timeString) \(req.method) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(code)") + } + } +} diff --git a/Alchemy/HTTP/Commands/Serve/ServeCommand.swift b/Alchemy/HTTP/Commands/Serve/ServeCommand.swift new file mode 100644 index 00000000..34cc767a --- /dev/null +++ b/Alchemy/HTTP/Commands/Serve/ServeCommand.swift @@ -0,0 +1,97 @@ +import Hummingbird +import NIOCore + +struct ServeCommand: Command { + static let name = "serve" + + /// The host to serve at. Defaults to `127.0.0.1`. + @Option var host = HTTPConfiguration.defaultHost + + /// The port to serve at. Defaults to `3000`. + @Option var port = HTTPConfiguration.defaultPort + + /// The unix socket to serve at. If this is provided, the host and + /// port will be ignored. + @Option var socket: String? + + /// The number of Queue workers that should be kicked off in + /// this process. Defaults to `0`. + @Option var workers: Int = 0 + + /// Should the scheduler run in process, scheduling any recurring + /// work. Defaults to `false`. + @Flag var schedule: Bool = false + + /// Should migrations be run before booting. Defaults to `false`. + @Flag var migrate: Bool = false + + /// If enabled, handled requests won't be logged. + @Flag var quiet: Bool = false + + // MARK: Command + + func run() async throws { + + // 0. migrate if necessary + + if migrate { + try await DB.migrate() + Log.comment("") + } + + // 1. start scheduler if necessary + + if schedule { + Schedule.start() + } + + // 2. start any workers + + for _ in 0.. Hummingbird.Application { + let address: BindAddress = if let socket { + .unixDomainSocket(path: socket) + } else { + .hostname(host, port: port) + } + return .init( + responder: Responder( + logResponses: !quiet + ), + server: Container.require(Application.self).server, + configuration: ApplicationConfiguration( + address: address, + serverName: nil, + backlog: 256, + reuseAddress: false + ), + onServerRunning: onServerStart, + eventLoopGroupProvider: .shared(LoopGroup), + logger: Log + ) + } + + @Sendable private func onServerStart(channel: Channel) async { + if let unixSocket = socket { + Log.info("Server running on \(unixSocket).") + } else { + let link = "[http://\(host):\(port)]".bold + Log.info("Server running on \(link).") + } + + if Env.isXcode { + Log.comment("Press Cmd+Period to stop the server") + } else { + Log.comment("Press Ctrl+C to stop the server".yellow) + print() + } + } +} diff --git a/Alchemy/HTTP/Commands/ServeCommand.swift b/Alchemy/HTTP/Commands/ServeCommand.swift deleted file mode 100644 index 600e83f8..00000000 --- a/Alchemy/HTTP/Commands/ServeCommand.swift +++ /dev/null @@ -1,260 +0,0 @@ -import NIO -import NIOSSL -import NIOHTTP1 -import NIOHTTP2 -@preconcurrency import Hummingbird -import HummingbirdCore - -struct ServeCommand: Command { - static let name = "serve" - static var runUntilStopped: Bool = true - - /// The host to serve at. Defaults to `127.0.0.1`. - @Option var host = HTTPConfiguration.defaultHost - - /// The port to serve at. Defaults to `3000`. - @Option var port = HTTPConfiguration.defaultPort - - /// The unix socket to serve at. If this is provided, the host and - /// port will be ignored. - @Option var socket: String? - - /// The number of Queue workers that should be kicked off in - /// this process. Defaults to `0`. - @Option var workers: Int = 0 - - /// Should the scheduler run in process, scheduling any recurring - /// work. Defaults to `false`. - @Flag var schedule: Bool = false - - /// Should migrations be run before booting. Defaults to `false`. - @Flag var migrate: Bool = false - - /// If enabled, handled requests won't be logged. - @Flag var quiet: Bool = false - - init() {} - init(host: String = "127.0.0.1", port: Int = 3000, workers: Int = 0, schedule: Bool = false, migrate: Bool = false, quiet: Bool = true) { - self.host = host - self.port = port - self.socket = nil - self.workers = workers - self.schedule = schedule - self.migrate = migrate - self.quiet = true - } - - // MARK: Command - - func run() async throws { - @Inject var app: Application - if migrate { - try await DB.migrate() - Log.comment("") - } - - if schedule { - Schedule.start() - } - - for _ in 0.. HBResponse { - let startedAt = Date() - let req = Request( - method: request.method, - uri: request.uri.string, - headers: request.headers, - body: request.body.bytes(), - localAddress: context.source.channel.localAddress, - remoteAddress: context.source.channel.remoteAddress - ) - - let res = await handler.handle(request: req) - if logResponses { - logResponse(req: req, res: res, startedAt: startedAt) - } - - return HBResponse( - status: res.status, - headers: res.headers, - body: res.body.hbResponseBody - ) - } -} - -extension RequestBody { - fileprivate func bytes() -> Bytes? { - .stream( - AsyncStream { continuation in - Task { - for try await buffer in self { - continuation.yield(buffer) - } - - continuation.finish() - } - } - ) - } -} - -extension Bytes? { - fileprivate var hbResponseBody: ResponseBody { - switch self { - case .buffer(let buffer): - return .init(byteBuffer: buffer) - case .stream(let stream): - return .init(asyncSequence: stream) - case .none: - return .init() - } - } -} - -// MARK: Response Logging - -extension AlchemyResponder { - fileprivate func logResponse(req: Request, res: Response, startedAt: Date) { - enum Formatters { - static let date: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() - static let time: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter - }() - } - - enum Status { - case success - case warning - case error - case other - } - - let finishedAt = Date() - let dateString = Formatters.date.string(from: finishedAt) - let timeString = Formatters.time.string(from: finishedAt) - let left = "\(dateString) \(timeString) \(req.method) \(req.path)" - let right = "\(startedAt.elapsedString) \(res.status.code)" - let dots = Log.dots(left: left, right: right) - let status: Status = { - switch res.status.code { - case 200...299: - return .success - case 400...499: - return .warning - case 500...599: - return .error - default: - return .other - } - }() - - if Env.isXcode { - let logString = "\(dateString.lightBlack) \(timeString) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(res.status.code)" - switch status { - case .success, .other: - Log.comment(logString) - case .warning: - Log.warning(logString) - case .error: - Log.critical(logString) - } - } else { - var code = "\(res.status.code)" - switch status { - case .success: - code = code.green - case .warning: - code = code.yellow - case .error: - code = code.red - case .other: - code = code.white - } - - Log.comment("\(dateString.lightBlack) \(timeString) \(req.method) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(code)") - } - } -} diff --git a/Alchemy/HTTP/Headers/HTTPFields+ContentInformation.swift b/Alchemy/HTTP/Headers/HTTPFields+ContentInformation.swift index 22510f6c..247877ef 100644 --- a/Alchemy/HTTP/Headers/HTTPFields+ContentInformation.swift +++ b/Alchemy/HTTP/Headers/HTTPFields+ContentInformation.swift @@ -4,24 +4,16 @@ extension HTTPFields { self[.contentType].map(ContentType.init) } set { - if let contentType = newValue { - self[.contentType] = contentType.string - } else { - self[.contentType] = nil - } + self[.contentType] = newValue?.string } } public var contentLength: Int? { get { - self[.contentLength].map { Int($0) } ?? nil + Int(self[.contentLength] ?? "") } set { - if let contentLength = newValue { - self[.contentLength] = String(contentLength) - } else { - self[.contentLength] = nil - } + self[.contentLength] = newValue.map { String($0) } } } } diff --git a/Alchemy/Queue/Commands/WorkCommand.swift b/Alchemy/Queue/Commands/WorkCommand.swift index 4164acd4..2a33f283 100644 --- a/Alchemy/Queue/Commands/WorkCommand.swift +++ b/Alchemy/Queue/Commands/WorkCommand.swift @@ -1,7 +1,6 @@ /// Command to run queue workers. struct WorkCommand: Command { static var name = "queue:work" - static var runUntilStopped: Bool = true /// The name of the queue the workers should observe. If no name is given, /// workers will observe the default queue. @@ -17,17 +16,9 @@ struct WorkCommand: Command { /// Should the scheduler run in process, scheduling any recurring work. @Flag var schedule: Bool = false - init() {} - init(name: String?, channels: String = Queue.defaultChannel, workers: Int = 1, schedule: Bool = false) { - self.name = name - self.channels = channels - self.workers = workers - self.schedule = schedule - } - // MARK: Command - func run() throws { + func run() async throws { let queue: Queue = name.map { Container.require(id: $0) } ?? Q for _ in 0..: XCTestCase { open override func setUp() async throws { try await super.setUp() app = A() - try await app.bootPlugins() - try await app.lifecycle.start() - try app.boot() + try await app.willRun() } open override func tearDown() async throws { try await super.tearDown() - await app.stop() - try await app.lifecycle.shutdown() + try await app.didRun() app.container.reset() } } diff --git a/Tests/Database/Commands/MigrateCommandTests.swift b/Tests/Database/Commands/MigrateCommandTests.swift index 7d0f8941..1e5c3b82 100644 --- a/Tests/Database/Commands/MigrateCommandTests.swift +++ b/Tests/Database/Commands/MigrateCommandTests.swift @@ -13,7 +13,7 @@ final class MigrateCommandTests: TestCase { XCTAssertTrue(MigrationA.didUp) XCTAssertFalse(MigrationA.didDown) - try await app.start("migrate:rollback") + try await app.run("migrate:rollback") XCTAssertTrue(MigrationA.didDown) } } diff --git a/Tests/Database/Commands/SeedCommandTests.swift b/Tests/Database/Commands/SeedCommandTests.swift index 1b3be106..8d329bbd 100644 --- a/Tests/Database/Commands/SeedCommandTests.swift +++ b/Tests/Database/Commands/SeedCommandTests.swift @@ -16,7 +16,7 @@ final class SeedCommandTests: TestCase { let db = try await Database.fake("a", migrations: [SeedModel.Migrate()]) db.seeders = [Seeder3(), Seeder4()] - try await app.start("db:seed", "seeder3", "--db", "a") + try await app.run("db:seed", "seeder3", "--db", "a") XCTAssertTrue(Seeder3.didRun) XCTAssertFalse(Seeder4.didRun) } diff --git a/Tests/HTTP/Commands/ServeCommandTests.swift b/Tests/HTTP/Commands/ServeCommandTests.swift index f542fe20..15256398 100644 --- a/Tests/HTTP/Commands/ServeCommandTests.swift +++ b/Tests/HTTP/Commands/ServeCommandTests.swift @@ -11,7 +11,7 @@ final class ServeCommandTests: TestCase { func testServe() async throws { app.get("/foo", use: { _ in "hello" }) - Task { try await ServeCommand(port: 3000).run() } + Task { try await ServeCommand.parse(["--port", "3000"]).run() } try await Http.get("http://127.0.0.1:3000/foo") .assertBody("hello") @@ -21,7 +21,7 @@ final class ServeCommandTests: TestCase { func testServeWithSideEffects() async throws { app.get("/foo", use: { _ in "hello" }) - Task { try await ServeCommand(workers: 2, schedule: true, migrate: true).run() } + Task { try await ServeCommand.parse(["--workers", "2", "--schedule", "--migrate"]).run() } try await Http.get("http://127.0.0.1:3000/foo") .assertBody("hello") diff --git a/Tests/Queues/Commands/WorkCommandTests.swift b/Tests/Queues/Commands/WorkCommandTests.swift index cca0da5b..fd789168 100644 --- a/Tests/Queues/Commands/WorkCommandTests.swift +++ b/Tests/Queues/Commands/WorkCommandTests.swift @@ -8,22 +8,32 @@ final class WorkCommandTests: TestCase { Queue.fake() } - func testRun() throws { - try WorkCommand(name: nil, workers: 5, schedule: false).run() + func testRun() async throws { + Task { try await WorkCommand.parse(["--workers", "5"]).run() } + + // hack to wait for the queue to boot up - should find a way to hook + // into the command finishing. + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(Q.workers.count, 5) XCTAssertFalse(Schedule.isStarted) } - func testRunName() throws { + func testRunName() async throws { Queue.fake("a") - try WorkCommand(name: "a", workers: 5, schedule: false).run() + Task { try await WorkCommand.parse(["--name", "a", "--workers", "5"]).run() } + + // hack to wait for the queue to boot up - should find a way to hook + // into the command finishing. + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(Q.workers.count, 0) XCTAssertEqual(Q("a").workers.count, 5) XCTAssertFalse(Schedule.isStarted) } func testRunCLI() async throws { - Task { try await app.start("queue:work", "--workers", "3", "--schedule") } + Task { try await app.run("queue:work", "--workers", "3", "--schedule") } // hack to wait for the queue to boot up - should find a way to hook // into the command finishing. diff --git a/Tests/Routing/RouterTests.swift b/Tests/Routing/RouterTests.swift index 07ce3c23..1d960122 100644 --- a/Tests/Routing/RouterTests.swift +++ b/Tests/Routing/RouterTests.swift @@ -177,7 +177,7 @@ final class RouterTests: TestCase { } } - Task { try await app.start(waitOrShutdown: false) } + Task { try await app.run() } var expected = ["foo", "bar", "baz"] try await Http