Skip to content

Commit 4d312bd

Browse files
authored
Add PackageSearchClient (#5950)
This offers a generic interface which allows clients to find packages via exact matching of registry identities or URLs, as well as search through collections and index if there are no exact matches.
1 parent 87914ab commit 4d312bd

File tree

4 files changed

+190
-0
lines changed

4 files changed

+190
-0
lines changed

Package.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ let swiftPMDataModelProduct = (
2828
"PackageCollectionsModel",
2929
"PackageGraph",
3030
"PackageLoading",
31+
"PackageMetadata",
3132
"PackageModel",
3233
"SourceControl",
3334
"Workspace",
@@ -334,6 +335,16 @@ let package = Package(
334335
],
335336
exclude: ["CMakeLists.txt"]
336337
),
338+
.target(
339+
// ** High level interface for package discovery */
340+
name: "PackageMetadata",
341+
dependencies: [
342+
"Basics",
343+
"PackageCollections",
344+
"PackageModel",
345+
"PackageRegistry",
346+
]
347+
),
337348

338349
// MARK: Commands
339350

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2022 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import Dispatch
15+
import PackageCollections
16+
import PackageModel
17+
import PackageRegistry
18+
import SourceControl
19+
20+
import struct Foundation.URL
21+
import struct TSCBasic.AbsolutePath
22+
import protocol TSCBasic.FileSystem
23+
import func TSCBasic.withTemporaryDirectory
24+
25+
public struct Package {
26+
public enum Source {
27+
case indexAndCollections(collections: [PackageCollectionsModel.CollectionIdentifier], indexes: [URL])
28+
case registry(url: URL)
29+
case sourceControl(url: URL)
30+
}
31+
32+
public let identity: PackageIdentity
33+
public let location: String?
34+
public let branches: [String]
35+
public let versions: [Version]
36+
public let readmeURL: URL?
37+
public let source: Source
38+
39+
fileprivate init(identity: PackageIdentity, location: String? = nil, branches: [String] = [], versions: [Version], readmeURL: URL? = nil, source: Source) {
40+
self.identity = identity
41+
self.location = location
42+
self.branches = branches
43+
self.versions = versions
44+
self.readmeURL = readmeURL
45+
self.source = source
46+
}
47+
}
48+
49+
public struct PackageSearchClient {
50+
private let registryClient: RegistryClient
51+
private let indexAndCollections: PackageIndexAndCollections
52+
private let observabilityScope: ObservabilityScope
53+
54+
public init(
55+
registryClient: RegistryClient,
56+
fileSystem: FileSystem,
57+
observabilityScope: ObservabilityScope
58+
) {
59+
self.registryClient = registryClient
60+
self.indexAndCollections = PackageIndexAndCollections(fileSystem: fileSystem, observabilityScope: observabilityScope)
61+
self.observabilityScope = observabilityScope
62+
}
63+
64+
var repositoryProvider: RepositoryProvider {
65+
return GitRepositoryProvider()
66+
}
67+
68+
// FIXME: This matches the current implementation, but we may want be smarter about it?
69+
private func guessReadMeURL(baseURL: URL, defaultBranch: String) -> URL {
70+
return baseURL.appendingPathComponent("raw").appendingPathComponent(defaultBranch).appendingPathComponent("README.md")
71+
}
72+
73+
public func findPackages(
74+
_ query: String,
75+
callback: @escaping (Result<[Package], Error>) -> Void
76+
) {
77+
let identity = PackageIdentity.plain(query)
78+
let isRegistryIdentity = identity.scopeAndName != nil
79+
80+
// Search the package index and collections for a search term.
81+
let search = { (error: Error?) -> Void in
82+
self.indexAndCollections.findPackages(query) { result in
83+
do {
84+
let packages = try result.get().items.map {
85+
Package(identity: $0.package.identity,
86+
location: $0.package.location,
87+
versions: $0.package.versions.map { $0.version },
88+
readmeURL: $0.package.readmeURL,
89+
source: .indexAndCollections(collections: $0.collections, indexes: $0.indexes)
90+
)
91+
}
92+
if packages.isEmpty, let error = error {
93+
// If the search result is empty and we had a previous error, emit it now.
94+
return callback(.failure(error))
95+
} else {
96+
return callback(.success(packages))
97+
}
98+
} catch {
99+
return callback(.failure(error))
100+
}
101+
}
102+
}
103+
104+
// Interpret the given search term as a URL and fetch the corresponding Git repository to determine the available version tags and branches. If the search term cannot be interpreted as a URL or there are any errors during the process, we fall back to searching the configured index or package collections.
105+
let fetchStandalonePackageByURL = { (error: Error?) -> Void in
106+
guard let url = URL(string: query) else {
107+
return search(error)
108+
}
109+
110+
do {
111+
try withTemporaryDirectory(removeTreeOnDeinit: true) { (tempDir: AbsolutePath) -> Void in
112+
let tempPath = tempDir.appending(component: url.lastPathComponent)
113+
do {
114+
let repositorySpecifier = RepositorySpecifier(url: url)
115+
try self.repositoryProvider.fetch(repository: repositorySpecifier, to: tempPath, progressHandler: nil)
116+
if self.repositoryProvider.isValidDirectory(tempPath), let repository = try self.repositoryProvider.open(repository: repositorySpecifier, at: tempPath) as? GitRepository {
117+
let branches = try repository.getBranches()
118+
let versions = try repository.getTags().compactMap { Version($0) }
119+
let package = Package(identity: .init(url: url),
120+
location: url.absoluteString,
121+
branches: branches,
122+
versions: versions,
123+
readmeURL: self.guessReadMeURL(baseURL: url, defaultBranch: try repository.getDefaultBranch()),
124+
source: .sourceControl(url: url))
125+
return callback(.success([package]))
126+
}
127+
} catch {
128+
return search(error)
129+
}
130+
}
131+
} catch {
132+
return search(error)
133+
}
134+
}
135+
136+
// If the given search term can be interpreted as a registry identity, try to get package metadata for it from the configured registry. If there are any errors or the search term does not work as a registry identity, we will fall back on `fetchStandalonePackageByURL`.
137+
if isRegistryIdentity {
138+
return self.registryClient.getPackageMetadata(package: identity, observabilityScope: observabilityScope, callbackQueue: DispatchQueue.sharedConcurrent) { result in
139+
do {
140+
let metadata = try result.get()
141+
let readmeURL: URL?
142+
if let alternateURL = metadata.alternateLocations?.first {
143+
// FIXME: This is pretty crude, we should let the registry metadata provide the value instead.
144+
readmeURL = guessReadMeURL(baseURL: alternateURL, defaultBranch: "main")
145+
} else {
146+
readmeURL = nil
147+
}
148+
return callback(.success([Package(identity: identity,
149+
versions: metadata.versions,
150+
readmeURL: readmeURL,
151+
source: .registry(url: metadata.registry.url)
152+
)]))
153+
} catch {
154+
return fetchStandalonePackageByURL(error)
155+
}
156+
}
157+
} else {
158+
return fetchStandalonePackageByURL(nil)
159+
}
160+
}
161+
}

Sources/PackageRegistry/RegistryClient.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ public final class RegistryClient: Cancellable {
108108
let alternateLocations = try response.headers.parseAlternativeLocationLinks()
109109

110110
return PackageMetadata(
111+
registry: registry,
111112
versions: versions,
112113
alternateLocations: alternateLocations?.map{ $0.url }
113114
)
@@ -659,6 +660,7 @@ fileprivate extension RegistryClient {
659660

660661
extension RegistryClient {
661662
public struct PackageMetadata {
663+
public let registry: Registry
662664
public let versions: [Version]
663665
public let alternateLocations: [URL]?
664666
}

Sources/SourceControl/GitRepository.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ public final class GitRepository: Repository, WorkingCheckout {
342342
private var cachedBlobs = ThreadSafeKeyValueStore<Hash, ByteString>()
343343
private var cachedTrees = ThreadSafeKeyValueStore<String, Tree>()
344344
private var cachedTags = ThreadSafeBox<[String]>()
345+
private var cachedBranches = ThreadSafeBox<[String]>()
345346

346347
public convenience init(path: AbsolutePath, isWorkingRepo: Bool = true, cancellator: Cancellator? = .none) {
347348
// used in one-off operations on git repo, as such the terminator is not ver important
@@ -424,6 +425,21 @@ public final class GitRepository: Repository, WorkingCheckout {
424425
}
425426
}
426427

428+
// MARK: Helpers for package search functionality
429+
430+
public func getDefaultBranch() throws -> String {
431+
return try callGit("rev-parse", "--abbrev-ref", "HEAD", failureMessage: "Couldn’t get the default branch")
432+
}
433+
434+
public func getBranches() throws -> [String] {
435+
try self.cachedBranches.memoize {
436+
try self.lock.withLock {
437+
let branches = try callGit("branch", "-l", failureMessage: "Couldn’t get the list of branches")
438+
return branches.split(separator: "\n").map { $0.dropFirst(2) }.map(String.init)
439+
}
440+
}
441+
}
442+
427443
// MARK: Repository Interface
428444

429445
/// Returns the tags present in repository.

0 commit comments

Comments
 (0)