From 87a4e244559e71b2eaa2fe6de8e0c9b2fc1e8048 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Fri, 29 Nov 2024 19:23:03 +1300 Subject: [PATCH] openrpc generation --- RPC/Sources/RPC/Handlers/AllHandlers.swift | 6 +- RPC/Sources/RPC/Handlers/ChainHandlers.swift | 21 +- RPC/Sources/RPC/Handlers/RPCHandlers.swift | 19 +- RPC/Sources/RPC/Handlers/SystemHandlers.swift | 40 ++- .../RPC/Handlers/TelemetryHandlers.swift | 31 ++- RPC/Sources/RPC/JSONRPC/FromJSON.swift | 18 +- RPC/Sources/RPC/JSONRPC/JSONRPC.swift | 28 +- RPC/Sources/RPC/JSONRPC/RPCHandler.swift | 18 +- .../RPC/JSONRPC/RequestParameter.swift | 94 +++++++ Tools/Package.swift | 5 + Tools/Sources/Tools/OpenRPC.swift | 257 ++++++++++++++++++ Tools/Sources/Tools/SpecDoc.swift | 49 ++++ Tools/Sources/Tools/Tools.swift | 5 +- 13 files changed, 515 insertions(+), 76 deletions(-) create mode 100644 RPC/Sources/RPC/JSONRPC/RequestParameter.swift create mode 100644 Tools/Sources/Tools/OpenRPC.swift create mode 100644 Tools/Sources/Tools/SpecDoc.swift diff --git a/RPC/Sources/RPC/Handlers/AllHandlers.swift b/RPC/Sources/RPC/Handlers/AllHandlers.swift index ddba8e69..55469ce0 100644 --- a/RPC/Sources/RPC/Handlers/AllHandlers.swift +++ b/RPC/Sources/RPC/Handlers/AllHandlers.swift @@ -1,11 +1,11 @@ -enum AllHandlers { - static let handlers: [any RPCHandler.Type] = +public enum AllHandlers { + public static let handlers: [any RPCHandler.Type] = ChainHandlers.handlers + SystemHandlers.handlers + TelemetryHandlers.handlers + RPCHandlers.handlers - static func getHandlers(source: ChainDataSource & SystemDataSource & TelemetryDataSource) -> [any RPCHandler] { + public static func getHandlers(source: ChainDataSource & SystemDataSource & TelemetryDataSource) -> [any RPCHandler] { ChainHandlers.getHandlers(source: source) + SystemHandlers.getHandlers(source: source) + TelemetryHandlers.getHandlers(source: source) + diff --git a/RPC/Sources/RPC/Handlers/ChainHandlers.swift b/RPC/Sources/RPC/Handlers/ChainHandlers.swift index 53c52056..81922a5e 100644 --- a/RPC/Sources/RPC/Handlers/ChainHandlers.swift +++ b/RPC/Sources/RPC/Handlers/ChainHandlers.swift @@ -2,23 +2,24 @@ import Blockchain import Foundation import Utils -enum ChainHandlers { - static let handlers: [any RPCHandler.Type] = [ +public enum ChainHandlers { + public static let handlers: [any RPCHandler.Type] = [ GetBlock.self, ] - static func getHandlers(source: ChainDataSource) -> [any RPCHandler] { + public static func getHandlers(source: ChainDataSource) -> [any RPCHandler] { [ GetBlock(source: source), ] } - struct GetBlock: RPCHandler { - typealias Request = Data32? - typealias Response = BlockRef? - typealias DataSource = ChainDataSource + public struct GetBlock: RPCHandler { + public typealias Request = Request1 + public typealias Response = BlockRef? + public typealias DataSource = ChainDataSource - static var method: String { "chain_getBlock" } + public static var method: String { "chain_getBlock" } + public static var summary: String? { "Get block by hash. If hash is not provided, returns the best block." } private let source: ChainDataSource @@ -26,8 +27,8 @@ enum ChainHandlers { self.source = source } - func handle(request: Request) async throws -> Response? { - if let hash = request { + public func handle(request: Request) async throws -> Response? { + if let hash = request.value { try await source.getBlock(hash: hash) } else { try await source.getBestBlock() diff --git a/RPC/Sources/RPC/Handlers/RPCHandlers.swift b/RPC/Sources/RPC/Handlers/RPCHandlers.swift index c12398ca..d63943af 100644 --- a/RPC/Sources/RPC/Handlers/RPCHandlers.swift +++ b/RPC/Sources/RPC/Handlers/RPCHandlers.swift @@ -1,20 +1,21 @@ import Utils -enum RPCHandlers { - static let handlers: [any RPCHandler.Type] = [ +public enum RPCHandlers { + public static let handlers: [any RPCHandler.Type] = [ Methods.self, ] - static func getHandlers(source: [any RPCHandler.Type]) -> [any RPCHandler] { + public static func getHandlers(source: [any RPCHandler.Type]) -> [any RPCHandler] { [Methods(source: source)] } - struct Methods: RPCHandler { - typealias Request = VoidRequest - typealias Response = [String] - typealias DataSource = [any RPCHandler.Type] + public struct Methods: RPCHandler { + public typealias Request = VoidRequest + public typealias Response = [String] + public typealias DataSource = [any RPCHandler.Type] - static var method: String { "rpc_methods" } + public static var method: String { "rpc_methods" } + public static var summary: String? { "Returns a list of available RPC methods." } private let methods: [String] @@ -22,7 +23,7 @@ enum RPCHandlers { methods = source.map { h in h.method } } - func handle(request _: Request) async throws -> Response? { + public func handle(request _: Request) async throws -> Response? { methods } } diff --git a/RPC/Sources/RPC/Handlers/SystemHandlers.swift b/RPC/Sources/RPC/Handlers/SystemHandlers.swift index 67a92e56..ee2ecdf8 100644 --- a/RPC/Sources/RPC/Handlers/SystemHandlers.swift +++ b/RPC/Sources/RPC/Handlers/SystemHandlers.swift @@ -1,36 +1,50 @@ import Utils -enum SystemHandlers { - static let handlers: [any RPCHandler.Type] = [ +public enum SystemHandlers { + public static let handlers: [any RPCHandler.Type] = [ Health.self, Version.self, ] - static func getHandlers(source _: SystemDataSource) -> [any RPCHandler] { + public static func getHandlers(source _: SystemDataSource) -> [any RPCHandler] { [ Health(), Version(), ] } - struct Health: RPCHandler { - typealias Request = VoidRequest - typealias Response = Bool + public struct Health: RPCHandler { + public typealias Request = VoidRequest + public typealias Response = Bool - static var method: String { "system_health" } + public static var method: String { "system_health" } + public static var summary: String? { "Returns true if the node is healthy." } - func handle(request _: Request) async throws -> Response? { + public func handle(request _: Request) async throws -> Response? { true } } - struct Version: RPCHandler { - typealias Request = VoidRequest - typealias Response = String + public struct Implementation: RPCHandler { + public typealias Request = VoidRequest + public typealias Response = String - static var method: String { "system_version" } + public static var method: String { "system_implementation" } + public static var summary: String? { "Returns the implementation name of the node." } - func handle(request _: Request) async throws -> Response? { + public func handle(request _: Request) async throws -> Response? { + "Boka" + } + } + + public struct Version: RPCHandler { + public typealias Request = VoidRequest + public typealias Response = String + + public static var method: String { "system_version" } + public static var summary: String? { "Returns the version of the node." } + + public func handle(request _: Request) async throws -> Response? { "0.0.1" } } diff --git a/RPC/Sources/RPC/Handlers/TelemetryHandlers.swift b/RPC/Sources/RPC/Handlers/TelemetryHandlers.swift index 5a1a8047..baf97d90 100644 --- a/RPC/Sources/RPC/Handlers/TelemetryHandlers.swift +++ b/RPC/Sources/RPC/Handlers/TelemetryHandlers.swift @@ -2,24 +2,25 @@ import Blockchain import Foundation import Utils -enum TelemetryHandlers { - static let handlers: [any RPCHandler.Type] = [ +public enum TelemetryHandlers { + public static let handlers: [any RPCHandler.Type] = [ GetUpdate.self, Name.self, ] - static func getHandlers(source: TelemetryDataSource & ChainDataSource) -> [any RPCHandler] { + public static func getHandlers(source: TelemetryDataSource & ChainDataSource) -> [any RPCHandler] { [ GetUpdate(source: source), Name(source: source), ] } - struct GetUpdate: RPCHandler { - typealias Request = VoidRequest - typealias Response = [String: String] + public struct GetUpdate: RPCHandler { + public typealias Request = VoidRequest + public typealias Response = [String: String] - static var method: String { "telemetry_getUpdate" } + public static var method: String { "telemetry_getUpdate" } + public static var summary: String? { "Returns the latest telemetry update." } private let source: TelemetryDataSource & ChainDataSource @@ -27,11 +28,10 @@ enum TelemetryHandlers { self.source = source } - func handle(request _: Request) async throws -> Response? { + public func handle(request _: Request) async throws -> Response? { let block = try await source.getBestBlock() let peerCount = try await source.getPeersCount() - return try await [ - "name": source.name(), + return [ "chainHead": block.header.timeslot.description, "blockHash": block.hash.description, "peerCount": peerCount.description, @@ -39,11 +39,12 @@ enum TelemetryHandlers { } } - struct Name: RPCHandler { - typealias Request = VoidRequest - typealias Response = String + public struct Name: RPCHandler { + public typealias Request = VoidRequest + public typealias Response = String - static var method: String { "telemetry_name" } + public static var method: String { "telemetry_name" } + public static var summary: String? { "Returns the name of the node." } private let source: TelemetryDataSource @@ -51,7 +52,7 @@ enum TelemetryHandlers { self.source = source } - func handle(request _: Request) async throws -> Response? { + public func handle(request _: Request) async throws -> Response? { try await source.name() } } diff --git a/RPC/Sources/RPC/JSONRPC/FromJSON.swift b/RPC/Sources/RPC/JSONRPC/FromJSON.swift index 95f32507..1d0caa14 100644 --- a/RPC/Sources/RPC/JSONRPC/FromJSON.swift +++ b/RPC/Sources/RPC/JSONRPC/FromJSON.swift @@ -6,21 +6,21 @@ enum FromJSONError: Error { case unexpectedJSON } -protocol FromJSON { +public protocol FromJSON { init(from: JSON?) throws } -enum VoidRequest: FromJSON { +public enum VoidRequest: FromJSON { case void - init(from _: JSON?) throws { + public init(from _: JSON?) throws { // ignore self = .void } } extension Optional: FromJSON where Wrapped: FromJSON { - init(from json: JSON?) throws { + public init(from json: JSON?) throws { guard let json else { self = .none return @@ -35,7 +35,7 @@ extension Optional: FromJSON where Wrapped: FromJSON { } extension BinaryInteger where Self: FromJSON { - init(from json: JSON?) throws { + public init(from json: JSON?) throws { guard let json else { throw FromJSONError.null } @@ -60,7 +60,7 @@ extension UInt64: FromJSON {} extension UInt: FromJSON {} extension Data: FromJSON { - init(from json: JSON?) throws { + public init(from json: JSON?) throws { guard let json else { throw FromJSONError.null } @@ -73,14 +73,14 @@ extension Data: FromJSON { } } -extension Data32: FromJSON { - init(from json: JSON?) throws { +extension FixedSizeData: FromJSON { + public init(from json: JSON?) throws { guard let json else { throw FromJSONError.null } switch json { case let .string(str): - self = try Data32(fromHexString: str).unwrap() + self = try FixedSizeData(fromHexString: str).unwrap() default: throw FromJSONError.unexpectedJSON } diff --git a/RPC/Sources/RPC/JSONRPC/JSONRPC.swift b/RPC/Sources/RPC/JSONRPC/JSONRPC.swift index 67f19de0..d3dd5333 100644 --- a/RPC/Sources/RPC/JSONRPC/JSONRPC.swift +++ b/RPC/Sources/RPC/JSONRPC/JSONRPC.swift @@ -1,27 +1,27 @@ import Utils import Vapor -struct JSONRequest: Content { - let jsonrpc: String - let method: String - let params: JSON? - let id: JSON +public struct JSONRequest: Content { + public let jsonrpc: String + public let method: String + public let params: JSON? + public let id: JSON } -struct JSONResponse: Content { - let jsonrpc: String - let result: AnyCodable? - let error: JSONError? - let id: JSON? +public struct JSONResponse: Content { + public let jsonrpc: String + public let result: AnyCodable? + public let error: JSONError? + public let id: JSON? - init(id: JSON?, result: (any Encodable)?) { + public init(id: JSON?, result: (any Encodable)?) { jsonrpc = "2.0" self.result = result.map(AnyCodable.init) error = nil self.id = id } - init(id: JSON?, error: JSONError) { + public init(id: JSON?, error: JSONError) { jsonrpc = "2.0" result = nil self.error = error @@ -29,13 +29,13 @@ struct JSONResponse: Content { } } -struct JSONError: Content, Error { +public struct JSONError: Content, Error { let code: Int let message: String } extension JSONError { - static func methodNotFound(_ method: String) -> JSONError { + public static func methodNotFound(_ method: String) -> JSONError { JSONError(code: -32601, message: "Method not found: \(method)") } } diff --git a/RPC/Sources/RPC/JSONRPC/RPCHandler.swift b/RPC/Sources/RPC/JSONRPC/RPCHandler.swift index 98499c57..ee51b5fa 100644 --- a/RPC/Sources/RPC/JSONRPC/RPCHandler.swift +++ b/RPC/Sources/RPC/JSONRPC/RPCHandler.swift @@ -2,14 +2,20 @@ import Foundation import Utils import Vapor -protocol RPCHandler: Sendable { - associatedtype Request: FromJSON +public protocol RPCHandler: Sendable { + associatedtype Request: RequestParameter associatedtype Response: Encodable static var method: String { get } func handle(request: Request) async throws -> Response? func handle(jsonRequest: JSONRequest) async throws -> JSONResponse + + // for OpenRPC spec generation + static var summary: String? { get } + + static var requestType: any RequestParameter.Type { get } + static var responseType: any Encodable.Type { get } } extension RPCHandler { @@ -21,4 +27,12 @@ extension RPCHandler { result: res ) } + + public static var requestType: any RequestParameter.Type { + Request.self + } + + public static var responseType: any Encodable.Type { + Response.self + } } diff --git a/RPC/Sources/RPC/JSONRPC/RequestParameter.swift b/RPC/Sources/RPC/JSONRPC/RequestParameter.swift new file mode 100644 index 00000000..0b271558 --- /dev/null +++ b/RPC/Sources/RPC/JSONRPC/RequestParameter.swift @@ -0,0 +1,94 @@ +import Utils + +enum RequestError: Error { + case null + case notArray + case unexpectedLength +} + +public protocol RequestParameter: FromJSON { + static var types: [Any.Type] { get } +} + +extension VoidRequest: RequestParameter { + public static var types: [Any.Type] { [] } +} + +// Swift don't yet support variadic generics +// so we need to use this workaround + +public struct Request1: RequestParameter { + public static var types: [Any.Type] { [T.self] } + + public let value: T + + public init(from json: JSON?) throws { + guard let json else { + throw RequestError.null + } + guard case let .array(arr) = json else { + throw RequestError.notArray + } + guard arr.count == 1 else { + throw RequestError.unexpectedLength + } + value = try T(from: arr[0]) + } +} + +public struct Request2: RequestParameter { + public static var types: [Any.Type] { [T1.self, T2.self] } + + public let valuu: (T1, T2) + + public init(from json: JSON?) throws { + guard let json else { + throw RequestError.null + } + guard case let .array(arr) = json else { + throw RequestError.notArray + } + guard arr.count == 2 else { + throw RequestError.unexpectedLength + } + valuu = try (T1(from: arr[0]), T2(from: arr[1])) + } +} + +public struct Request3: RequestParameter { + public static var types: [Any.Type] { [T1.self, T2.self, T3.self] } + + public let value: (T1, T2, T3) + + public init(from json: JSON?) throws { + guard let json else { + throw RequestError.null + } + guard case let .array(arr) = json else { + throw RequestError.notArray + } + guard arr.count == 3 else { + throw RequestError.unexpectedLength + } + value = try (T1(from: arr[0]), T2(from: arr[1]), T3(from: arr[2])) + } +} + +public struct Request4: RequestParameter { + public static var types: [Any.Type] { [T1.self, T2.self, T3.self, T4.self] } + + public let value: (T1, T2, T3, T4) + + public init(from json: JSON?) throws { + guard let json else { + throw RequestError.null + } + guard case let .array(arr) = json else { + throw RequestError.notArray + } + guard arr.count == 4 else { + throw RequestError.unexpectedLength + } + value = try (T1(from: arr[0]), T2(from: arr[1]), T3(from: arr[2]), T4(from: arr[3])) + } +} diff --git a/Tools/Package.swift b/Tools/Package.swift index f417eda5..44cc66e3 100644 --- a/Tools/Package.swift +++ b/Tools/Package.swift @@ -13,6 +13,8 @@ let package = Package( .package(path: "../TracingUtils"), .package(path: "../Utils"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + .package(url: "https://github.com/ajevans99/swift-json-schema.git", from: "0.2.1"), + .package(url: "https://github.com/wickwirew/Runtime.git", from: "2.2.7"), ], targets: [ .executableTarget( @@ -22,6 +24,9 @@ let package = Package( "Utils", "TracingUtils", .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "JSONSchema", package: "swift-json-schema"), + .product(name: "JSONSchemaBuilder", package: "swift-json-schema"), + .product(name: "Runtime", package: "Runtime"), ] ), ], diff --git a/Tools/Sources/Tools/OpenRPC.swift b/Tools/Sources/Tools/OpenRPC.swift new file mode 100644 index 00000000..ef753046 --- /dev/null +++ b/Tools/Sources/Tools/OpenRPC.swift @@ -0,0 +1,257 @@ +import ArgumentParser +import Foundation +import JSONSchema +import JSONSchemaBuilder +import RPC +import Runtime +import Utils + +struct OpenRPC: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "OpenRPC tools", + subcommands: [Generate.self] + ) + + struct Generate: AsyncParsableCommand { + @Argument(help: "output file") + var output: String = "openrpc.json" + + func run() async throws { + let handlers = AllHandlers.handlers + + let spec = SpecDoc( + openrpc: "1.0.0", + info: SpecInfo( + title: "JAM JSONRPC (draft)", + version: "0.0.1", + description: "JSONRPC spec for JAM nodes (draft)" + ), + methods: handlers.map { h in + SpecMethod( + name: h.method, + summary: h.summary, + description: nil, + params: h.requestType.types.map { createSpecContent(type: $0) }, + result: createSpecContent(type: h.responseType), + examples: nil + ) + } + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + let data = try encoder.encode(spec) + try data.write(to: URL(fileURLWithPath: output)) + } + } +} + +private protocol OptionalProtocol { + static var wrappedType: Any.Type { get } +} + +extension Optional: OptionalProtocol { + static var wrappedType: Any.Type { + Wrapped.self + } +} + +func build(@JSONSchemaBuilder _ content: () -> any JSONSchemaComponent) -> any JSONSchemaComponent { + content() +} + +func createSpecContent(type: Any.Type) -> SpecContent { + // if it is optional + if let type = type as? OptionalProtocol.Type { + return createSpecContentInner(type: type.wrappedType, required: false) + } else { + return createSpecContentInner(type: type, required: true) + } + + func createSpecContentInner(type: Any.Type, required: Bool) -> SpecContent { + .init( + name: getName(type: type), + summary: nil, + description: nil, + required: required, + schema: getSchema(type: type).definition + ) + } +} + +protocol TypeDescription { + static var name: String { get } + + static var schema: any JSONSchemaComponent { get } +} + +func getName(type: Any.Type) -> String { + if let type = type as? TypeDescription.Type { + return type.name + } + return String(describing: type) +} + +func getSchema(type: Any.Type) -> any JSONSchemaComponent { + if let type = type as? TypeDescription.Type { + return type.schema + } + + print(type) + let info = try! typeInfo(of: type) + switch info.kind { + case .struct, .class: + return build { + JSONObject { + for field in info.properties { + JSONProperty(key: field.name) { + getSchema(type: field.type) + } + } + }.title(String(describing: type)) + } + default: + return build { + JSONObject().title(getName(type: type)) + } + } +} + +extension Optional: TypeDescription { + static var name: String { + "Optional<\(getName(type: Wrapped.self))>" + } + + static var schema: any JSONSchemaComponent { + getSchema(type: Wrapped.self) + } +} + +extension Bool: TypeDescription { + static var name: String { + "Bool" + } + + static var schema: any JSONSchemaComponent { + JSONBoolean() + } +} + +extension String: TypeDescription { + static var name: String { + "String" + } + + static var schema: any JSONSchemaComponent { + JSONString() + } +} + +extension BinaryInteger where Self: TypeDescription { + static var name: String { + String(describing: Self.self) + } + + static var schema: any JSONSchemaComponent { + JSONInteger() + } +} + +extension Int8: TypeDescription {} +extension Int16: TypeDescription {} +extension Int32: TypeDescription {} +extension Int64: TypeDescription {} +extension Int: TypeDescription {} +extension UInt8: TypeDescription {} +extension UInt16: TypeDescription {} +extension UInt32: TypeDescription {} +extension UInt64: TypeDescription {} +extension UInt: TypeDescription {} + +extension Data: TypeDescription { + static var name: String { + "Data" + } + + static var schema: any JSONSchemaComponent { + JSONString() + .title(name) + .pattern("^0x[0-9a-fA-F]*$") + } +} + +extension FixedSizeData: TypeDescription { + static var name: String { + "Data\(T.value)" + } + + static var schema: any JSONSchemaComponent { + JSONString() + .title(name) + .pattern("^0x[0-9a-fA-F]{\(T.value * 2)}$") + } +} + +extension Array: TypeDescription { + static var name: String { + "Array<\(getName(type: Element.self))>" + } + + static var schema: any JSONSchemaComponent { + JSONArray().items { getSchema(type: Element.self) } + } +} + +extension Dictionary: TypeDescription { + static var name: String { + "Dictionary<\(getName(type: Key.self)), \(getName(type: Value.self))>" + } + + static var schema: any JSONSchemaComponent { + JSONObject().title(name) + } +} + +extension Set: TypeDescription { + static var name: String { + "Set<\(getName(type: Element.self))>" + } + + static var schema: any JSONSchemaComponent { + JSONArray().items { getSchema(type: Element.self) } + } +} + +extension LimitedSizeArray: TypeDescription { + static var name: String { + if minLength == maxLength { + "Array\(minLength)<\(getName(type: T.self))>" + } else { + "Array<\(getName(type: T.self))>[\(minLength) ..< \(maxLength)]" + } + } + + static var schema: any JSONSchemaComponent { + JSONArray().items { getSchema(type: T.self) } + } +} + +extension ConfigLimitedSizeArray: TypeDescription { + static var name: String { + "Array<\(getName(type: T.self))>[\(getName(type: TMinLength.self)) ..< \(getName(type: TMaxLength.self))]" + } + + static var schema: any JSONSchemaComponent { + JSONArray().items { getSchema(type: T.self) } + } +} + +extension Ref: TypeDescription { + static var name: String { + getName(type: T.self) + } + + static var schema: any JSONSchemaComponent { + getSchema(type: T.self) + } +} diff --git a/Tools/Sources/Tools/SpecDoc.swift b/Tools/Sources/Tools/SpecDoc.swift new file mode 100644 index 00000000..44e8433e --- /dev/null +++ b/Tools/Sources/Tools/SpecDoc.swift @@ -0,0 +1,49 @@ +import JSONSchema +import Utils + +struct SpecInfo: Codable { + var title: String + var version: String + var description: String? +} + +struct SpecMethod: Codable { + var name: String + var summary: String? + var description: String? + var params: [SpecContent]? + var result: SpecContent + var examples: [SpecExample]? +} + +struct SpecContent: Codable { + var name: String + var summary: String? + var description: String? + var required: Bool? + var schema: Schema +} + +struct SpecExample: Codable { + var name: String + var summary: String? + var description: String? + var params: [SpecExampleParam] + var result: SpecExampleResult? +} + +struct SpecExampleParam: Codable { + var name: String + var value: JSON +} + +struct SpecExampleResult: Codable { + var name: String + var value: JSON +} + +struct SpecDoc: Codable { + var openrpc: String = "1.0.0" + var info: SpecInfo + var methods: [SpecMethod] +} diff --git a/Tools/Sources/Tools/Tools.swift b/Tools/Sources/Tools/Tools.swift index 5a136c0a..0c230263 100644 --- a/Tools/Sources/Tools/Tools.swift +++ b/Tools/Sources/Tools/Tools.swift @@ -7,7 +7,10 @@ import Utils struct Boka: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Boka Tools", - version: "0.0.1" + version: "0.0.1", + subcommands: [ + OpenRPC.self, + ] ) mutating func run() async throws {}