diff --git a/Package.swift b/Package.swift index 9df11919f32..2f406e9e57f 100644 --- a/Package.swift +++ b/Package.swift @@ -133,10 +133,16 @@ let package = Package( name: "LLBuildManifest", dependencies: ["SwiftToolsSupport-auto", "Basics"]), + .target( + /** Package registry support */ + name: "PackageRegistry", + dependencies: ["SwiftToolsSupport-auto", "Basics", "PackageLoading", "PackageModel"]), + .target( /** Source control operations */ name: "SourceControl", dependencies: ["SwiftToolsSupport-auto", "Basics"]), + .target( /** Shim for llbuild library */ name: "SPMLLBuild", @@ -158,7 +164,7 @@ let package = Package( .target( /** Data structures and support for complete package graphs */ name: "PackageGraph", - dependencies: ["SwiftToolsSupport-auto", "Basics", "PackageLoading", "PackageModel", "SourceControl"]), + dependencies: ["SwiftToolsSupport-auto", "Basics", "PackageLoading", "PackageModel", "PackageRegistry", "SourceControl"]), // MARK: Package Collections @@ -234,6 +240,10 @@ let package = Package( /** Interacts with package collections */ name: "swift-package-collection", dependencies: ["Commands"]), + .target( + /** Interact with package registry */ + name: "swift-package-registry", + dependencies: ["Commands"]), .target( /** Shim tool to find test names on OS X */ name: "swiftpm-xctest-helper", @@ -300,6 +310,9 @@ let package = Package( .testTarget( name: "PackageCollectionsTests", dependencies: ["PackageCollections", "SPMTestSupport"]), + .testTarget( + name: "PackageRegistryTests", + dependencies: ["SPMTestSupport", "PackageRegistry"]), .testTarget( name: "SourceControlTests", dependencies: ["SourceControl", "SPMTestSupport"]), diff --git a/Sources/Basics/CMakeLists.txt b/Sources/Basics/CMakeLists.txt index c2d57b51a59..db4c13223bd 100644 --- a/Sources/Basics/CMakeLists.txt +++ b/Sources/Basics/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(Basics HTPClient+URLSession.swift HTTPClient.swift JSON+Extensions.swift + JSONDecoder+Extensions.swift Sandbox.swift SwiftVersion.swift SQLiteBackedCache.swift diff --git a/Sources/Basics/JSONDecoder+Extensions.swift b/Sources/Basics/JSONDecoder+Extensions.swift new file mode 100644 index 00000000000..ff4a33c7f0c --- /dev/null +++ b/Sources/Basics/JSONDecoder+Extensions.swift @@ -0,0 +1,22 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Foundation + +extension JSONDecoder { + public func decode(_ type: T.Type, from string: String) throws -> T where T : Decodable { + guard let data = string.data(using: .utf8) else { + let context = DecodingError.Context(codingPath: [], debugDescription: "invalid UTF-8 string") + throw DecodingError.dataCorrupted(context) + } + + return try decode(type, from: data) + } +} diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index 14370edcab7..5b8e30e3c36 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -19,6 +19,7 @@ add_subdirectory(PackageGraph) add_subdirectory(PackageLoading) add_subdirectory(PackageModel) add_subdirectory(PackagePlugin) +add_subdirectory(PackageRegistry) add_subdirectory(SPMBuildCore) add_subdirectory(SPMLLBuild) add_subdirectory(SourceControl) diff --git a/Sources/Commands/CMakeLists.txt b/Sources/Commands/CMakeLists.txt index 8a067c7f340..6745c630732 100644 --- a/Sources/Commands/CMakeLists.txt +++ b/Sources/Commands/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(Commands show-dependencies.swift SwiftBuildTool.swift SwiftPackageCollectionsTool.swift + SwiftPackageRegistryTool.swift SwiftPackageTool.swift SwiftRunTool.swift SwiftTestTool.swift diff --git a/Sources/Commands/SwiftPackageRegistryTool.swift b/Sources/Commands/SwiftPackageRegistryTool.swift new file mode 100644 index 00000000000..98cdd39e610 --- /dev/null +++ b/Sources/Commands/SwiftPackageRegistryTool.swift @@ -0,0 +1,170 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import ArgumentParser +import Basics +import TSCBasic +import SPMBuildCore +import Build +import PackageModel +import PackageLoading +import PackageGraph +import SourceControl +import TSCUtility +import XCBuildSupport +import Workspace +import Foundation +import PackageRegistry + +private enum RegistryConfigurationError: Swift.Error { + case missingScope(String? = nil) + case invalidURL(String) +} + +extension RegistryConfigurationError: CustomStringConvertible { + var description: String { + switch self { + case .missingScope(let scope?): + return "no existing entry for scope: \(scope)" + case .missingScope: + return "no existing entry for default scope" + case .invalidURL(let url): + return "invalid URL: \(url)" + } + } +} + +public struct SwiftPackageRegistryTool: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "package-registry", + _superCommandName: "swift", + abstract: "Interact with package registry and manage related configuration", + discussion: "SEE ALSO: swift package", + version: SwiftVersion.currentVersion.completeDisplayString, + subcommands: [ + Set.self, + Unset.self + ], + helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) + + @OptionGroup() + var swiftOptions: SwiftToolOptions + + public init() {} + + struct Set: SwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Set a custom registry") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Flag(help: "Apply settings to all projects for this user") + var global: Bool = false + + @Option(help: "Associate the registry with a given scope") + var scope: String? + + // TODO: Uncomment once .netrc management is implemented + + // @Option(help: "Specify a user name for the remote machine") + // var login: String? + + // @Option(help: "Supply a password for the remote machine") + // var password: String? + + @Argument(help: "The registry URL") + var url: String + + func run(_ swiftTool: SwiftTool) throws { + guard let url = URL(string: self.url), + url.scheme == "https" + else { + throw RegistryConfigurationError.invalidURL(self.url) + } + + // TODO: Require login if password is specified + + let set: (inout RegistryConfiguration) throws -> Void = { configuration in + if let scope = scope { + configuration.scopedRegistries[scope] = .init(url: url) + } else { + configuration.defaultRegistry = .init(url: url) + } + } + + let configuration = try swiftTool.getRegistriesConfig() + if global { + try configuration.updateShared(with: set) + } else { + try configuration.updateLocal(with: set) + } + + // TODO: Add login and password to .netrc + } + } + + struct Unset: SwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Remove a configured registry") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Flag(help: "Apply settings to all projects for this user") + var global: Bool = false + + @Option(help: "Associate the registry with a given scope") + var scope: String? + + func run(_ swiftTool: SwiftTool) throws { + let unset: (inout RegistryConfiguration) throws -> Void = { configuration in + if let scope = scope { + guard let _ = configuration.scopedRegistries[scope] else { + throw RegistryConfigurationError.missingScope(scope) + } + configuration.scopedRegistries.removeValue(forKey: scope) + } else { + guard let _ = configuration.defaultRegistry else { + throw RegistryConfigurationError.missingScope() + } + configuration.defaultRegistry = nil + } + } + + let configuration = try swiftTool.getRegistriesConfig() + if global { + try configuration.updateShared(with: unset) + } else { + try configuration.updateLocal(with: unset) + } + } + } +} + +// MARK: - + + +private extension SwiftTool { + func getRegistriesConfig() throws -> Workspace.Configuration.Registries { + let localRegistriesFile = try Workspace.DefaultLocations.registriesConfigurationFile(forRootPackage: self.getPackageRoot()) + + let workspace = try getActiveWorkspace() + let sharedRegistriesFile = workspace.location.sharedConfigurationDirectory.map { + Workspace.DefaultLocations.registriesConfigurationFile(at: $0) + } + + return try .init( + localRegistriesFile: localRegistriesFile, + sharedRegistriesFile: sharedRegistriesFile, + fileSystem: localFileSystem + ) + } +} diff --git a/Sources/PackageRegistry/CMakeLists.txt b/Sources/PackageRegistry/CMakeLists.txt new file mode 100644 index 00000000000..64f0efda260 --- /dev/null +++ b/Sources/PackageRegistry/CMakeLists.txt @@ -0,0 +1,26 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2021 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_library(PackageRegistry + RegistryConfiguration.swift) +target_link_libraries(PackageRegistry PUBLIC + TSCBasic + PackageLoading + PackageModel + TSCUtility) +# NOTE(compnerd) workaround for CMake not setting up include flags yet +set_target_properties(PackageRegistry PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) + +if(USE_CMAKE_INSTALL) +install(TARGETS PackageRegistry + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) +endif() +set_property(GLOBAL APPEND PROPERTY SwiftPM_EXPORTS PackageRegistry) diff --git a/Sources/PackageRegistry/RegistryConfiguration.swift b/Sources/PackageRegistry/RegistryConfiguration.swift new file mode 100644 index 00000000000..4386559ea96 --- /dev/null +++ b/Sources/PackageRegistry/RegistryConfiguration.swift @@ -0,0 +1,113 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation + +public struct RegistryConfiguration: Hashable { + public typealias Scope = String + + public struct Registry: Hashable { + public var url: Foundation.URL + + public init(url: Foundation.URL) { + self.url = url + } + } + + public enum Version: Int { + case v1 = 1 + } + + public static let version: Version = .v1 + + public var defaultRegistry: Registry? + public var scopedRegistries: [Scope: Registry] + + public init() { + self.defaultRegistry = nil + self.scopedRegistries = [:] + } + + public var isEmpty: Bool { + return self.defaultRegistry == nil && self.scopedRegistries.isEmpty + } + + public mutating func merge(_ other: RegistryConfiguration) { + if let defaultRegistry = other.defaultRegistry { + self.defaultRegistry = defaultRegistry + } + + for (scope, registry) in other.scopedRegistries { + self.scopedRegistries[scope] = registry + } + } +} + +// MARK: - Codable + +extension RegistryConfiguration: Codable { + private enum CodingKeys: String, CodingKey { + case registries + case version + } + + private struct ScopeCodingKey: CodingKey, Hashable { + static let `default` = ScopeCodingKey(stringValue: "[default]") + + var stringValue: String + var intValue: Int? { nil } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + return nil + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let version = try container.decode(Version.RawValue.self, forKey: .version) + switch Version(rawValue: version) { + case .v1: + let nestedContainer = try container.nestedContainer(keyedBy: ScopeCodingKey.self, forKey: .registries) + + self.defaultRegistry = try nestedContainer.decodeIfPresent(Registry.self, forKey: .default) + + var scopedRegistries: [Scope: Registry] = [:] + for key in nestedContainer.allKeys where key != .default { + scopedRegistries[key.stringValue] = try nestedContainer.decode(Registry.self, forKey: key) + } + self.scopedRegistries = scopedRegistries + case nil: + throw DecodingError.dataCorruptedError(forKey: .version, in: container, debugDescription: "invalid version: \(version)") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(Self.version, forKey: .version) + + var nestedContainer = container.nestedContainer(keyedBy: ScopeCodingKey.self, forKey: .registries) + + try nestedContainer.encodeIfPresent(defaultRegistry, forKey: .default) + + for (scope, registry) in scopedRegistries { + let key = ScopeCodingKey(stringValue: scope) + try nestedContainer.encode(registry, forKey: key) + } + } +} + +extension RegistryConfiguration.Version: Codable {} +extension RegistryConfiguration.Registry: Codable {} diff --git a/Sources/SPMTestSupport/SwiftPMProduct.swift b/Sources/SPMTestSupport/SwiftPMProduct.swift index d6d41fd2905..a11adf8cfa5 100644 --- a/Sources/SPMTestSupport/SwiftPMProduct.swift +++ b/Sources/SPMTestSupport/SwiftPMProduct.swift @@ -15,6 +15,7 @@ import TSCBasic public enum SwiftPMProduct: Product { case SwiftBuild case SwiftPackage + case SwiftPackageRegistry case SwiftTest case SwiftRun case XCTestHelper @@ -26,6 +27,8 @@ public enum SwiftPMProduct: Product { return RelativePath("swift-build") case .SwiftPackage: return RelativePath("swift-package") + case .SwiftPackageRegistry: + return RelativePath("swift-package-registry") case .SwiftTest: return RelativePath("swift-test") case .SwiftRun: diff --git a/Sources/Workspace/WorkspaceConfiguration.swift b/Sources/Workspace/WorkspaceConfiguration.swift index 90ec636c517..ea3beb0a96f 100644 --- a/Sources/Workspace/WorkspaceConfiguration.swift +++ b/Sources/Workspace/WorkspaceConfiguration.swift @@ -11,6 +11,7 @@ import Basics import Foundation import TSCBasic +import PackageRegistry // MARK: - Location @@ -62,6 +63,11 @@ extension Workspace { self.sharedConfigurationDirectory.map { DefaultLocations.mirrorsConfigurationFile(at: $0) } } + /// Path to the shared registries configuration. + public var sharedRegistriesConfigurationFile: AbsolutePath? { + self.sharedConfigurationDirectory.map { DefaultLocations.registriesConfigurationFile(at: $0) } + } + /// Create a new workspace location. /// /// - Parameters: @@ -129,6 +135,14 @@ extension Workspace { path.appending(component: "mirrors.json") } + public static func registriesConfigurationFile(forRootPackage rootPath: AbsolutePath) -> AbsolutePath { + registriesConfigurationFile(at: configurationDirectory(forRootPackage: rootPath)) + } + + public static func registriesConfigurationFile(at path: AbsolutePath) -> AbsolutePath { + path.appending(component: "registries.json") + } + public static func manifestsDirectory(at path: AbsolutePath) -> AbsolutePath { path.appending(component: "manifests") } @@ -360,6 +374,120 @@ extension Workspace.Configuration { } } +// MARK: - Registries + +extension Workspace.Configuration { + public class Registries { + private let localRegistries: RegistriesStorage + private let sharedRegistries: RegistriesStorage? + private let fileSystem: FileSystem + + private var _configuration = RegistryConfiguration() + private let lock = Lock() + + /// The registry configuration + public var configuration: RegistryConfiguration { + self.lock.withLock { + return self._configuration + } + } + + /// Initialize the workspace registries configuration + /// + /// - Parameters: + /// - localRegistriesFile: Path to the workspace registries configuration file + /// - sharedRegistriesFile: Path to the shared registries configuration file, defaults to the standard location. + /// - fileSystem: The file system to use. + public init( + localRegistriesFile: AbsolutePath, + sharedRegistriesFile: AbsolutePath?, + fileSystem: FileSystem + ) throws { + self.localRegistries = .init(path: localRegistriesFile, fileSystem: fileSystem) + self.sharedRegistries = sharedRegistriesFile.map { .init(path: $0, fileSystem: fileSystem) } + self.fileSystem = fileSystem + try self.computeRegistries() + } + + @discardableResult + public func updateLocal(with handler: (inout RegistryConfiguration) throws -> Void) throws -> RegistryConfiguration { + try self.localRegistries.update(with: handler) + try self.computeRegistries() + return self.configuration + } + + @discardableResult + public func updateShared(with handler: (inout RegistryConfiguration) throws -> Void) throws -> RegistryConfiguration { + guard let sharedRegistries = self.sharedRegistries else { + throw InternalError("shared registries not configured") + } + try sharedRegistries.update(with: handler) + try self.computeRegistries() + return self.configuration + } + + // mutating the state we hold since we are passing it by reference to the workspace + // access should be done using a lock + private func computeRegistries() throws { + try self.lock.withLock { + var configuration = RegistryConfiguration() + + if let sharedConfiguration = try sharedRegistries?.load() { + configuration.merge(sharedConfiguration) + } + + let localConfiguration = try localRegistries.load() + configuration.merge(localConfiguration) + + self._configuration = configuration + } + } + } +} + +extension Workspace.Configuration { + private struct RegistriesStorage { + private let path: AbsolutePath + private let fileSystem: FileSystem + + public init(path: AbsolutePath, fileSystem: FileSystem) { + self.path = path + self.fileSystem = fileSystem + } + + public func load() throws -> RegistryConfiguration { + guard fileSystem.exists(path) else { + return RegistryConfiguration() + } + + let data: Data = try fileSystem.readFileContents(path) + let decoder = JSONDecoder.makeWithDefaults() + return try decoder.decode(RegistryConfiguration.self, from: data) + } + + public func save(_ configuration: RegistryConfiguration) throws { + let encoder = JSONEncoder.makeWithDefaults() + let data = try encoder.encode(configuration) + + if !fileSystem.exists(path.parentDirectory) { + try fileSystem.createDirectory(path.parentDirectory, recursive: true) + } + try fileSystem.writeFileContents(path, bytes: ByteString(data), atomically: true) + } + + @discardableResult + public func update(with handler: (inout RegistryConfiguration) throws -> Void) throws -> RegistryConfiguration { + let configuration = try load() + var updatedConfiguration = configuration + try handler(&updatedConfiguration) + if updatedConfiguration != configuration { + try save(updatedConfiguration) + } + + return updatedConfiguration + } + } +} // MARK: - Deprecated 8/20201 diff --git a/Sources/swift-package-registry/CMakeLists.txt b/Sources/swift-package-registry/CMakeLists.txt new file mode 100644 index 00000000000..f283fb3ba91 --- /dev/null +++ b/Sources/swift-package-registry/CMakeLists.txt @@ -0,0 +1,17 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2021 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_executable(swift-package-registry + main.swift) +target_link_libraries(swift-package-registry PRIVATE + Commands) + +if(USE_CMAKE_INSTALL) +install(TARGETS swift-package-registry + RUNTIME DESTINATION bin) +endif() diff --git a/Sources/swift-package-registry/main.swift b/Sources/swift-package-registry/main.swift new file mode 100644 index 00000000000..0c55b20540c --- /dev/null +++ b/Sources/swift-package-registry/main.swift @@ -0,0 +1,13 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Commands + +SwiftPackageRegistryTool.main() diff --git a/Tests/CommandsTests/PackageRegistryToolTests.swift b/Tests/CommandsTests/PackageRegistryToolTests.swift new file mode 100644 index 00000000000..42bc6d938da --- /dev/null +++ b/Tests/CommandsTests/PackageRegistryToolTests.swift @@ -0,0 +1,198 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import Foundation + +import Commands +import SPMTestSupport +import TSCBasic +import TSCUtility + +let defaultRegistryBaseURL = URL(string: "https://packages.example.com")! +let customRegistryBaseURL = URL(string: "https://custom.packages.example.com")! + +final class PackageRegistryToolTests: XCTestCase { + @discardableResult + private func execute( + _ args: [String], + packagePath: AbsolutePath? = nil, + env: [String: String]? = nil + ) throws -> (exitStatus: ProcessResult.ExitStatus, stdout: String, stderr: String) { + var environment = env ?? [:] + // don't ignore local packages when caching + environment["SWIFTPM_TESTS_PACKAGECACHE"] = "1" + let result = try SwiftPMProduct.SwiftPackageRegistry.executeProcess(args, packagePath: packagePath, env: environment) + return try (result.exitStatus, result.utf8Output(), result.utf8stderrOutput()) + } + + func testUsage() throws { + let stdout = try execute(["-help"]).stdout + XCTAssert(stdout.contains("USAGE: swift package-registry"), "got stdout:\n" + stdout) + } + + func testSeeAlso() throws { + let stdout = try execute(["--help"]).stdout + XCTAssert(stdout.contains("SEE ALSO: swift package"), "got stdout:\n" + stdout) + } + + func testVersion() throws { + let stdout = try execute(["--version"]).stdout + XCTAssert(stdout.contains("Swift Package Manager"), "got stdout:\n" + stdout) + } + + func testLocalConfiguration() throws { + fixture(name: "DependencyResolution/External/Simple") { prefix in + let packageRoot = prefix.appending(component: "Bar") + let configurationFilePath = packageRoot.appending(RelativePath(".swiftpm/configuration/registries.json")) + + XCTAssertFalse(localFileSystem.exists(configurationFilePath)) + + // Set default registry + do { + let result = try execute(["set", "\(defaultRegistryBaseURL)"], packagePath: packageRoot) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 1) + XCTAssertEqual(json["registries"]?.dictionary?["[default]"]?.dictionary?["url"]?.string, "\(defaultRegistryBaseURL)") + XCTAssertEqual(json["version"], .int(1)) + } + + // Set new default registry + do { + let result = try execute(["set", "\(customRegistryBaseURL)"], packagePath: packageRoot) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 1) + XCTAssertEqual(json["registries"]?.dictionary?["[default]"]?.dictionary?["url"]?.string, "\(customRegistryBaseURL)") + XCTAssertEqual(json["version"], .int(1)) + } + + // Unset default registry + do { + let result = try execute(["unset"], packagePath: packageRoot) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 0) + XCTAssertEqual(json["version"], .int(1)) + } + + // Set registry for "foo" scope + do { + let result = try execute(["set", "\(customRegistryBaseURL)", "--scope", "foo"], packagePath: packageRoot) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 1) + XCTAssertEqual(json["registries"]?.dictionary?["foo"]?.dictionary?["url"]?.string, "\(customRegistryBaseURL)") + XCTAssertEqual(json["version"], .int(1)) + } + + // Set registry for "bar" scope + do { + let result = try execute(["set", "\(customRegistryBaseURL)", "--scope", "bar"], packagePath: packageRoot) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 2) + XCTAssertEqual(json["registries"]?.dictionary?["foo"]?.dictionary?["url"]?.string, "\(customRegistryBaseURL)") + XCTAssertEqual(json["registries"]?.dictionary?["bar"]?.dictionary?["url"]?.string, "\(customRegistryBaseURL)") + XCTAssertEqual(json["version"], .int(1)) + } + + // Unset registry for "foo" scope + do { + let result = try execute(["unset", "--scope", "foo"], packagePath: packageRoot) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 1) + XCTAssertEqual(json["registries"]?.dictionary?["bar"]?.dictionary?["url"]?.string, "\(customRegistryBaseURL)") + XCTAssertEqual(json["version"], .int(1)) + } + + XCTAssertTrue(localFileSystem.exists(configurationFilePath)) + } + } + + // TODO: Test global configuration + + func testSetMissingURL() throws { + fixture(name: "DependencyResolution/External/Simple") { prefix in + let packageRoot = prefix.appending(component: "Bar") + let configurationFilePath = packageRoot.appending(RelativePath(".swiftpm/configuration/registries.json")) + + XCTAssertFalse(localFileSystem.exists(configurationFilePath)) + + // Set default registry + do { + let result = try execute(["set", "--scope", "foo"], packagePath: packageRoot) + XCTAssertNotEqual(result.exitStatus, .terminated(code: 0)) + } + + XCTAssertFalse(localFileSystem.exists(configurationFilePath)) + } + } + + func testSetInvalidURL() throws { + fixture(name: "DependencyResolution/External/Simple") { prefix in + let packageRoot = prefix.appending(component: "Bar") + let configurationFilePath = packageRoot.appending(RelativePath(".swiftpm/configuration/registries.json")) + + XCTAssertFalse(localFileSystem.exists(configurationFilePath)) + + // Set default registry + do { + let result = try execute(["set", "invalid"], packagePath: packageRoot) + XCTAssertNotEqual(result.exitStatus, .terminated(code: 0)) + } + + XCTAssertFalse(localFileSystem.exists(configurationFilePath)) + } + } + + func testUnsetMissingEntry() throws { + fixture(name: "DependencyResolution/External/Simple") { prefix in + let packageRoot = prefix.appending(component: "Bar") + let configurationFilePath = packageRoot.appending(RelativePath(".swiftpm/configuration/registries.json")) + + XCTAssertFalse(localFileSystem.exists(configurationFilePath)) + + // Set default registry + do { + let result = try execute(["set", "\(defaultRegistryBaseURL)"], packagePath: packageRoot) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 1) + XCTAssertEqual(json["registries"]?.dictionary?["[default]"]?.dictionary?["url"]?.string, "\(defaultRegistryBaseURL)") + XCTAssertEqual(json["version"], .int(1)) + } + + // Unset registry for missing "baz" scope + do { + let result = try execute(["unset", "--scope", "baz"], packagePath: packageRoot) + XCTAssertNotEqual(result.exitStatus, .terminated(code: 0)) + + let json = try JSON(bytes: localFileSystem.readFileContents(configurationFilePath)) + XCTAssertEqual(json["registries"]?.dictionary?.count, 1) + XCTAssertEqual(json["registries"]?.dictionary?["[default]"]?.dictionary?["url"]?.string, "\(defaultRegistryBaseURL)") + XCTAssertEqual(json["version"], .int(1)) + } + + XCTAssertTrue(localFileSystem.exists(configurationFilePath)) + } + } + + // TODO: Test example with login and password +} diff --git a/Tests/PackageRegistryTests/RegistryConfigurationTests.swift b/Tests/PackageRegistryTests/RegistryConfigurationTests.swift new file mode 100644 index 00000000000..a2be91a7c5f --- /dev/null +++ b/Tests/PackageRegistryTests/RegistryConfigurationTests.swift @@ -0,0 +1,133 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import XCTest +import SPMTestSupport +@testable import PackageRegistry + +private let defaultRegistryBaseURL = URL(string: "https://packages.example.com/")! +private let customRegistryBaseURL = URL(string: "https://custom.packages.example.com/")! + +final class RegistryConfigurationTests: XCTestCase { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + func testEmptyConfiguration() throws { + let configuration = RegistryConfiguration() + XCTAssertNil(configuration.defaultRegistry) + XCTAssertEqual(configuration.scopedRegistries, [:]) + } + + func testRoundTripCodingForEmptyConfiguration() throws { + let configuration = RegistryConfiguration() + + let encoded = try encoder.encode(configuration) + let decoded = try decoder.decode(RegistryConfiguration.self, from: encoded) + + XCTAssertEqual(configuration, decoded) + } + + func testRoundTripCodingForExampleConfiguration() throws { + var configuration = RegistryConfiguration() + + configuration.defaultRegistry = .init(url: defaultRegistryBaseURL) + configuration.scopedRegistries["foo"] = .init(url: customRegistryBaseURL) + configuration.scopedRegistries["bar"] = .init(url: customRegistryBaseURL) + + let encoded = try encoder.encode(configuration) + let decoded = try decoder.decode(RegistryConfiguration.self, from: encoded) + + XCTAssertEqual(configuration, decoded) + } + + func testDecodeEmptyConfiguration() throws { + let json = #""" + { + "registries": {}, + "version": 1 + } + """# + + let configuration = try decoder.decode(RegistryConfiguration.self, from: json) + XCTAssertNil(configuration.defaultRegistry) + XCTAssertEqual(configuration.scopedRegistries, [:]) + } + + func testDecodeExampleConfiguration() throws { + let json = #""" + { + "registries": { + "[default]": { + "url": "\#(defaultRegistryBaseURL)" + }, + "foo": { + "url": "\#(customRegistryBaseURL)" + }, + "bar": { + "url": "\#(customRegistryBaseURL)" + }, + }, + "version": 1 + } + """# + + let configuration = try decoder.decode(RegistryConfiguration.self, from: json) + XCTAssertEqual(configuration.defaultRegistry?.url, defaultRegistryBaseURL) + XCTAssertEqual(configuration.scopedRegistries["foo"]?.url, customRegistryBaseURL) + XCTAssertEqual(configuration.scopedRegistries["bar"]?.url, customRegistryBaseURL) + } + + func testDecodeConfigurationWithInvalidRegistryKey() throws { + let json = #""" + { + "registries": { + 0: "\#(customRegistryBaseURL)" + }, + "version": 1 + } + """# + + XCTAssertThrowsError(try decoder.decode(RegistryConfiguration.self, from: json)) + } + + func testDecodeConfigurationWithInvalidRegistryValue() throws { + let json = #""" + { + "registries": { + "[default]": "\#(customRegistryBaseURL)" + }, + "version": 1 + } + """# + + XCTAssertThrowsError(try decoder.decode(RegistryConfiguration.self, from: json)) + } + + func testDecodeConfigurationWithMissingVersion() throws { + let json = #""" + { + "registries": {} + } + """# + + XCTAssertThrowsError(try decoder.decode(RegistryConfiguration.self, from: json)) + } + + func testDecodeConfigurationWithInvalidVersion() throws { + let json = #""" + { + "registries": {}, + "version": 999 + } + """# + + XCTAssertThrowsError(try decoder.decode(RegistryConfiguration.self, from: json)) + } +}