diff --git a/Package.resolved b/Package.resolved index b306153..270df38 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0e0c6ff85ba6eb953e3782bc08a8cfff3215a5047597ba294e6002c9aed58d54", + "originHash" : "c3dbd5e2af279911a3933b62aa8a390fa8076719ad56b55855d216e4b238ff87", "pins" : [ { "identity" : "async-http-client", @@ -28,6 +28,15 @@ "version" : "0.2.0" } }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", + "version" : "1.0.2" + } + }, { "identity" : "console-kit", "kind" : "remoteSourceControl", @@ -163,6 +172,15 @@ "version" : "1.2.0" } }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", + "version" : "1.0.5" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -172,6 +190,15 @@ "version" : "1.1.3" } }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", + "version" : "1.2.0" + } + }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", @@ -181,6 +208,15 @@ "version" : "3.7.0" } }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies.git", + "state" : { + "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", + "version" : "1.4.1" + } + }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", @@ -361,6 +397,15 @@ "version" : "2.15.0" } }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "27d767d643fa2cf083d0a73d74fa84cacb53e85c", + "version" : "1.4.1" + } + }, { "identity" : "yams", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 93ccfdb..1b475f9 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"), .package(url: "https://github.com/Zollerboy1/SwiftCommand.git", from: "1.4.0"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.0.0"), ], targets: [ .target( @@ -36,6 +37,7 @@ let package = Package( .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), .product(name: "SwiftCommand", package: "SwiftCommand"), .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "Dependencies", package: "swift-dependencies"), ], swiftSettings: swiftSettings, plugins: swiftLintPlugins diff --git a/Sources/CLIKit/CLI.swift b/Sources/CLIKit/CLI.swift index 0e51309..54cdcf0 100644 --- a/Sources/CLIKit/CLI.swift +++ b/Sources/CLIKit/CLI.swift @@ -1,7 +1,10 @@ import ConsoleKit +import Dependencies import Fluent import Foundation import Logging +import NIOCore +import NIOPosix /// `ConsoleKit`で作成した`AsyncCommand`を実行するサービス /// @@ -55,24 +58,41 @@ public struct CLI: Sendable { } /// CLIを実行する - public func run() async { + public func run() async throws { let context = CommandContext(console: console, input: input) + let eventLoopGroup = MultiThreadedEventLoopGroup.singleton + let threadPool = try await prepareThreadPool() + let logger = Logger(label: "CLIKit") + let databases = Databases(threadPool: threadPool, on: eventLoopGroup) + + if let sqliteURL { + databases.use(.sqlite(.file(sqliteURL.absoluteString)), as: .sqlite) + try await autoMigrate( + databases: databases, + on: eventLoopGroup.any(), + logger: logger, + migrations: migrations, + migrationLogLevel: migrationLogLevel + ) + } + do { - if let sqliteURL { - try await context.initDatabase( - sqliteURL: sqliteURL, - migrations: migrations, - migrationLogLevel: migrationLogLevel - ) + try await withDependencies { + $0.databases = databases + $0.eventLoopGroup = eventLoopGroup + $0.threadPool = threadPool + $0.logger = logger + $0.httpClient = .shared + } operation: { + try await console.run(asyncCommandGroup, with: context) } - try await console.run(asyncCommandGroup, with: context) } catch let error { console.error("\(error)") } + do { - try await context.shutdown() - } catch let error { - console.error("\(error)") + await databases.shutdownAsync() + try await threadPool.shutdownGracefully() } } diff --git a/Sources/CLIKit/CommandContext+Storage.swift b/Sources/CLIKit/CommandContext+Storage.swift deleted file mode 100644 index c60a1fb..0000000 --- a/Sources/CLIKit/CommandContext+Storage.swift +++ /dev/null @@ -1,48 +0,0 @@ -import ConsoleKit - -extension CommandContext { - /// 任意の型の値を保存できるストレージ - public actor Storage { - private var storage: [ObjectIdentifier: any Sendable] = [:] - - public init() {} - - /// Storageに値を格納する - /// - Parameters: - /// - key: キーとなる型 - /// - value: 格納する値 - public func set(key: K.Type, _ value: K) { - storage[ObjectIdentifier(K.self)] = value - } - - /// 型に対応する値を取得する - /// - Parameter key: キーとなる型 - /// - Returns: 対応する値。存在しない場合、nilを返す - public func get(_ key: K.Type) -> K? { - guard let value = storage[ObjectIdentifier(K.self)] else { return nil } - return value as? K - } - - /// 型に対応する値を取得する。ない場合には初期化しその値を返す - /// - Parameter initialize: 値を初期化するためのクロージャ - /// - Returns: 格納済みの値または新たに初期化し作成した値 - public func get(initialize: @Sendable () async throws -> K) async rethrows -> K { - try await get(K.self, initialize: initialize) - } - - /// 型に対応する値 - /// - Parameters: - /// - key: キーとなる型 - /// - initialize: 値を初期化するためのクロージャ - /// - Returns: 格納済みの値または新たに初期化し作成した値 - public func get(_ key: K.Type, initialize: @Sendable () async throws -> K) async rethrows -> K { - if let old = get(key) { return old } - let new = try await initialize() - set(key: K.self, new) - return new - } - } - - private static let storage = Storage() - public var storage: Storage { Self.storage } -} diff --git a/Sources/CLIKit/CommandContext+client.swift b/Sources/CLIKit/CommandContext+client.swift deleted file mode 100644 index d9ff351..0000000 --- a/Sources/CLIKit/CommandContext+client.swift +++ /dev/null @@ -1,13 +0,0 @@ -import AsyncHTTPClient -import ConsoleKit - -extension CommandContext { - /// HTTPクライアント - public var client: AsyncHTTPClient.HTTPClient { - get async { - await storage.get { - await .init(eventLoopGroup: eventLoopGroup.any()) - } - } - } -} diff --git a/Sources/CLIKit/CommandContext+initDatabase.swift b/Sources/CLIKit/CommandContext+initDatabase.swift deleted file mode 100644 index e793db2..0000000 --- a/Sources/CLIKit/CommandContext+initDatabase.swift +++ /dev/null @@ -1,102 +0,0 @@ -import ConsoleKit -import Fluent -import FluentSQLiteDriver -import Foundation -import Logging -import NIOCore -import NIOPosix - -extension CommandContext { - - /// Fluent Database - /// - /// データベースが設定されている場合のみ使用可能 - public var db: any Database { - get async { - guard - let databases = await storage.get(Databases.self), - let db = await databases.database(logger: logger, on: eventLoopGroup.any()) - else { - fatalError("データベースが初期化されていません。CLI(sqlitePath: \"...\")で初期化してください") - } - return db - } - } - - /// CLIKitのlogger - public var logger: Logger { - get async { - await storage.get { Logger(label: "CLIKit") } - } - } - - /// Fluent Databases - /// - /// データベースが設定されている場合のみ存在する - public var databases: Databases? { - get async { - await storage.get(Databases.self) - } - } - - public var eventLoopGroup: MultiThreadedEventLoopGroup { - get async { - await storage.get { .singleton } - } - } - - public var threadPool: NIOThreadPool? { - get async { - await storage.get(NIOThreadPool.self) - } - } - - func initDatabase(sqliteURL: URL, migrations: Migrations, migrationLogLevel: Logger.Level) async throws { - let eventLoopGroup = await eventLoopGroup - let threadPool: NIOThreadPool = .init(numberOfThreads: System.coreCount) - try await threadPool.shutdownGracefully() - threadPool.start() - let databases = Databases(threadPool: threadPool, on: eventLoopGroup) - databases.use(.sqlite(.file(sqliteURL.absoluteString)), as: .sqlite) - await storage.set(key: NIOThreadPool.self, threadPool) - await storage.set(key: Databases.self, databases) - - try await autoMigrate( - databases: databases, - migrations: migrations, - on: eventLoopGroup.any(), - migrationLogLevel: migrationLogLevel - ) - } - - func autoMigrate( - databases: Databases, - migrations: Migrations, - on eventLoop: EventLoop, - migrationLogLevel: Logger.Level - ) async throws { - let migrator = await Migrator( - databases: databases, - migrations: migrations, - logger: logger, - on: eventLoop, - migrationLogLevel: migrationLogLevel - ) - try await withCheckedThrowingContinuation { continuation in - let future = migrator - .setupIfNeeded() - .flatMap { migrator.prepareBatch() } - future.whenSuccess { continuation.resume(returning: ()) } - future.whenFailure { continuation.resume(throwing: $0) } - } - } - - func shutdown() async throws { - if let databases = await databases { - await databases.shutdownAsync() - } - if let threadPool = await threadPool { - try await threadPool.shutdownGracefully() - } - } -} diff --git a/Sources/CLIKit/DependencyValues+dependencies.swift b/Sources/CLIKit/DependencyValues+dependencies.swift new file mode 100644 index 0000000..9857b70 --- /dev/null +++ b/Sources/CLIKit/DependencyValues+dependencies.swift @@ -0,0 +1,64 @@ +import AsyncHTTPClient +import Dependencies +import Fluent +import NIOPosix + +extension DependencyValues { + /// Fluent Database + /// + /// データベースが設定されている場合のみ使用可能 + public var db: any Fluent.Database { + databases.database(logger: logger, on: eventLoopGroup.any())! + } + + /// CLIKitのlogger + public var logger: Logger { + get { self[LoggerKey.self] } + set { self[LoggerKey.self] = newValue } + } + private enum LoggerKey: DependencyKey { + static var liveValue: Logger { + fatalError("Value of type \(Value.self) is not registered in this context") + } + } + + public var databases: Databases { + get { self[DatabasesKey.self] } + set { self[DatabasesKey.self] = newValue } + } + private enum DatabasesKey: DependencyKey { + static var liveValue: Databases { + fatalError("Value of type \(Value.self) is not registered in this context") + } + } + + public var eventLoopGroup: MultiThreadedEventLoopGroup { + get { self[EventLoopGroupKey.self] } + set { self[EventLoopGroupKey.self] = newValue } + } + private enum EventLoopGroupKey: DependencyKey { + static var liveValue: MultiThreadedEventLoopGroup { + fatalError("Value of type \(Value.self) is not registered in this context") + } + } + + public var threadPool: NIOThreadPool { + get { self[ThreadPoolKey.self] } + set { self[ThreadPoolKey.self] = newValue } + } + private enum ThreadPoolKey: DependencyKey { + static var liveValue: NIOThreadPool { + fatalError("Value of type \(Value.self) is not registered in this context") + } + } + + public var httpClient: HTTPClient { + get { self[HTTPClientKey.self] } + set { self[HTTPClientKey.self] = newValue } + } + private enum HTTPClientKey: DependencyKey { + static var liveValue: HTTPClient { + fatalError("Value of type \(Value.self) is not registered in this context") + } + } +} diff --git a/Sources/CLIKit/Documentation.docc/Documentation.md b/Sources/CLIKit/Documentation.docc/Documentation.md new file mode 100644 index 0000000..a1e84e0 --- /dev/null +++ b/Sources/CLIKit/Documentation.docc/Documentation.md @@ -0,0 +1,82 @@ +# ``cli-kit`` + +SwiftのCLIに必要なライブラリの初期化を容易にするライブラリ + +## Overview + +以下のライブラリの初期化を行い容易に使用をすることができます。 + +- Fluent(SQLite) +- ConsoleKit +- SwiftCommand +- AsyncHTTPClient +- SwiftDependencies + +## Quick Start + +### 依存関係に追加 + +CLIKitをプロジェクトに追加するには、Swift Package Managerを使用します。Package.swiftに以下の依存関係を追加してください。 + +```swift +dependencies: [ + .package(url: "https://github.com/lemo-nade-room/cli-kit.git", branch: "main") +] +.target( + name: "YourApp", + dependencies: [ + .product(name: "CLIKit", package: "cli-kit"), + ] +), +``` + +### コマンドを作成・実行 + +```swift +import CLIKit +import ConsoleKit +import Dependencies +import Fluent +import Foundation + +struct SampleCommand: AsyncCommand { + var help = "sample" + + struct Signature: CommandSignature { + @Option(name: "message", short: "m") + var message: String? + } + + @Dependency(\.db) var db + @Dependency(\.httpClient) var httpClient + + func run(using context: CommandContext, signature: Signature) throws { + context.console.output("Hello, \(signature.message ?? "World")!") + } +} + +struct SampleMigration: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema("samples") + .id() + .field("message", .string, .required) + .create() + } + func revert(on database: any Database) async throws { + try await database.schema("samples").delete() + } +} + +let sqliteURL = FileManager() + .homeDirectoryForCurrentUser + .appending(path: "file.sqlite") + +var cli: CLI { + var cli = CLI(help: "サンプルCLI") + cli.use(SampleCommand(), as: "sample") + cli.migrations.add(SampleMigration()) + return cli +} + +try await cli.run() +``` diff --git a/Sources/CLIKit/prepareDatabase.swift b/Sources/CLIKit/prepareDatabase.swift new file mode 100644 index 0000000..c0fc16d --- /dev/null +++ b/Sources/CLIKit/prepareDatabase.swift @@ -0,0 +1,28 @@ +import Fluent +import FluentSQLiteDriver +import Foundation +import NIOCore +import NIOPosix + +func autoMigrate( + databases: Databases, + on eventLoop: some EventLoop, + logger: Logger, + migrations: Migrations, + migrationLogLevel: Logger.Level +) async throws { + let migrator = Migrator( + databases: databases, + migrations: migrations, + logger: logger, + on: eventLoop, + migrationLogLevel: .warning + ) + try await withCheckedThrowingContinuation { continuation in + let future = migrator + .setupIfNeeded() + .flatMap { migrator.prepareBatch() } + future.whenSuccess { continuation.resume(returning: ()) } + future.whenFailure { continuation.resume(throwing: $0) } + } +} diff --git a/Sources/CLIKit/prepareThreadPool.swift b/Sources/CLIKit/prepareThreadPool.swift new file mode 100644 index 0000000..cda0bed --- /dev/null +++ b/Sources/CLIKit/prepareThreadPool.swift @@ -0,0 +1,9 @@ +import NIOCore +import NIOPosix + +func prepareThreadPool() async throws -> NIOThreadPool { + let threadPool = NIOThreadPool(numberOfThreads: System.coreCount) + try await threadPool.shutdownGracefully() + threadPool.start() + return threadPool +} diff --git a/Tests/CLIKitTests/CLIKitDatabaseTests.swift b/Tests/CLIKitTests/CLIKitDatabaseTests.swift index 35b97c9..1762445 100644 --- a/Tests/CLIKitTests/CLIKitDatabaseTests.swift +++ b/Tests/CLIKitTests/CLIKitDatabaseTests.swift @@ -1,6 +1,7 @@ import CLIKit import CLITestKit import ConsoleKit +import Dependencies import Fluent import Foundation import Testing @@ -25,10 +26,11 @@ import Testing struct TestCommand: AsyncCommand { struct Signature: CommandSignature {} + @Dependency(\.db) var db let help = "save command" func run(using context: CommandContext, signature: Signature) async throws { - try await Store(key: "Hello", value: "World").create(on: context.db) - let all = try await Store.query(on: context.db).all() + try await Store(key: "Hello", value: "World").create(on: db) + let all = try await Store.query(on: db).all() context.console.output("\(all.map(\.description).joined())", newLine: true) } } @@ -49,7 +51,7 @@ import Testing sut.migrations.add(StoreMigration()) // Act - await sut.run() + try await sut.run() // Assert #expect(console.records == [ diff --git a/Tests/CLIKitTests/CLITests.swift b/Tests/CLIKitTests/CLITests.swift index 63a56d0..320cb1c 100644 --- a/Tests/CLIKitTests/CLITests.swift +++ b/Tests/CLIKitTests/CLITests.swift @@ -25,7 +25,7 @@ import Testing sut.use(TestCommand(), as: "commit") // Act - await sut.run() + try await sut.run() // Assert #expect(console.records == [