Skip to content

Commit fa7a6cd

Browse files
authored
add GitHubPackageMetadataProvider (#3063)
motivation: continue to implement package collections changes: * add metadata provider for repositories on github * add tests
1 parent efec8cd commit fa7a6cd

13 files changed

+904
-32
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[
2+
{
3+
"login": "octocat",
4+
"id": 1,
5+
"node_id": "MDQ6VXNlcjE=",
6+
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
7+
"gravatar_id": "",
8+
"url": "https://api.github.com/users/octocat",
9+
"html_url": "https://github.com/octocat",
10+
"followers_url": "https://api.github.com/users/octocat/followers",
11+
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
12+
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
13+
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
14+
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
15+
"organizations_url": "https://api.github.com/users/octocat/orgs",
16+
"repos_url": "https://api.github.com/users/octocat/repos",
17+
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
18+
"received_events_url": "https://api.github.com/users/octocat/received_events",
19+
"type": "User",
20+
"site_admin": false,
21+
"contributions": 32
22+
}
23+
]

Fixtures/Collections/GitHub/metadata.json

Lines changed: 385 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"type": "file",
3+
"encoding": "base64",
4+
"size": 5362,
5+
"name": "README.md",
6+
"path": "README.md",
7+
"content": "encoded content ...",
8+
"sha": "3d21ec53a331a6f037a91c368710b99387d012c1",
9+
"url": "https://api.github.com/repos/octokit/octokit.rb/contents/README.md",
10+
"git_url": "https://api.github.com/repos/octokit/octokit.rb/git/blobs/3d21ec53a331a6f037a91c368710b99387d012c1",
11+
"html_url": "https://github.com/octokit/octokit.rb/blob/master/README.md",
12+
"download_url": "https://raw.githubusercontent.com/octokit/octokit.rb/master/README.md",
13+
"_links": {
14+
"git": "https://api.github.com/repos/octokit/octokit.rb/git/blobs/3d21ec53a331a6f037a91c368710b99387d012c1",
15+
"self": "https://api.github.com/repos/octokit/octokit.rb/contents/README.md",
16+
"html": "https://github.com/octokit/octokit.rb/blob/master/README.md"
17+
}
18+
}

Fixtures/Collections/GitHub/tags.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[
2+
{
3+
"name": "0.1.0",
4+
"commit": {
5+
"sha": "c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc",
6+
"url": "https://api.github.com/repos/octocat/Hello-World/commits/c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc"
7+
},
8+
"zipball_url": "https://github.com/octocat/Hello-World/zipball/v0.1",
9+
"tarball_url": "https://github.com/octocat/Hello-World/tarball/v0.1",
10+
"node_id": "MDQ6VXNlcjE="
11+
}
12+
]

Sources/PackageCollections/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ add_library(PackageCollections
1414
Model/Profile.swift
1515
Model/Search.swift
1616
Model/TargetListResult.swift
17+
Providers/GitHubPackageMetadataProvider.swift
1718
Providers/JSONPackageCollectionProvider.swift
1819
Providers/PackageCollectionProvider.swift
1920
Providers/PackageMetadataProvider.swift

Sources/PackageCollections/PackageCollections.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,14 @@ public struct PackageCollections: PackageCollectionsProtocol {
214214
callback(.failure(error))
215215
case .success(let packageSearchResult):
216216
// then try to get more metadata from provider (optional)
217-
self.metadataProvider.get(reference: reference) { result in
217+
self.metadataProvider.get(reference) { result in
218218
switch result {
219+
case .failure(let error) where error is NotFoundError:
220+
let metadata = PackageCollectionsModel.PackageMetadata(
221+
package: Self.mergedPackageMetadata(package: packageSearchResult.package, basicMetadata: nil),
222+
collections: packageSearchResult.collections
223+
)
224+
callback(.success(metadata))
219225
case .failure(let error):
220226
callback(.failure(error))
221227
case .success(let basicMetadata):
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2020 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Basics
12+
import Dispatch
13+
import struct Foundation.Date
14+
import class Foundation.JSONDecoder
15+
import struct Foundation.NSRange
16+
import class Foundation.NSRegularExpression
17+
import struct Foundation.URL
18+
import PackageModel
19+
import TSCBasic
20+
21+
struct GitHubPackageMetadataProvider: PackageMetadataProvider {
22+
let httpClient: HTTPClient
23+
let defaultHttpClient: Bool
24+
let decoder: JSONDecoder
25+
let queue: DispatchQueue
26+
27+
init(httpClient: HTTPClient? = nil) {
28+
self.httpClient = httpClient ?? .init()
29+
self.defaultHttpClient = httpClient == nil
30+
self.decoder = JSONDecoder()
31+
#if os(Linux)
32+
self.decoder.dateDecodingStrategy = .iso8601
33+
#else
34+
if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
35+
self.decoder.dateDecodingStrategy = .iso8601
36+
} else {
37+
self.decoder.dateDecodingStrategy = .customISO8601
38+
}
39+
#endif
40+
self.queue = DispatchQueue(label: "org.swift.swiftpm.GitHubPackageMetadataProvider", attributes: .concurrent)
41+
}
42+
43+
func get(_ reference: PackageReference, callback: @escaping (Result<PackageCollectionsModel.PackageBasicMetadata, Error>) -> Void) {
44+
guard reference.kind == .remote else {
45+
return callback(.failure(Errors.unprocessable(reference)))
46+
}
47+
guard let baseURL = self.apiURL(reference.path) else {
48+
return callback(.failure(Errors.unprocessable(reference)))
49+
}
50+
51+
let metadataURL = baseURL
52+
let tagsURL = baseURL.appendingPathComponent("tags")
53+
let contributorsURL = baseURL.appendingPathComponent("contributors")
54+
let readmeURL = baseURL.appendingPathComponent("readme")
55+
56+
self.queue.async {
57+
let sync = DispatchGroup()
58+
var results = [URL: Result<HTTPClientResponse, Error>]()
59+
let resultsLock = Lock()
60+
61+
// get the main data
62+
sync.enter()
63+
let options = self.makeRequestOptions(validResponseCodes: [200])
64+
httpClient.get(metadataURL, options: options) { result in
65+
defer { sync.leave() }
66+
resultsLock.withLock {
67+
results[metadataURL] = result
68+
}
69+
// if successful, fan out multiple API calls
70+
if case .success = result {
71+
[tagsURL, contributorsURL, readmeURL].forEach { url in
72+
sync.enter()
73+
httpClient.get(url, options: options) { result in
74+
defer { sync.leave() }
75+
resultsLock.withLock {
76+
results[url] = result
77+
}
78+
}
79+
}
80+
}
81+
}
82+
83+
sync.wait()
84+
85+
// process results
86+
87+
do {
88+
// check for main request error state
89+
switch results[metadataURL] {
90+
case .none:
91+
throw Errors.invalidResponse(metadataURL)
92+
case .some(.failure(let error)) where error as? HTTPClientError == .badResponseStatusCode(404):
93+
throw NotFoundError("\(baseURL)")
94+
case .some(.failure(let error)):
95+
throw error
96+
case .some(.success(let metadataResponse)):
97+
guard let metadata = try metadataResponse.decodeBody(GetRepositoryResponse.self, using: self.decoder) else {
98+
throw Errors.invalidResponse(metadataURL)
99+
}
100+
let tags = try results[tagsURL]?.success?.decodeBody([Tag].self, using: self.decoder) ?? []
101+
let contributors = try results[contributorsURL]?.success?.decodeBody([Contributor].self, using: self.decoder)
102+
let readme = try results[readmeURL]?.success?.decodeBody(Readme.self, using: self.decoder)
103+
104+
callback(.success(.init(
105+
description: metadata.description,
106+
versions: tags.compactMap { TSCUtility.Version(string: $0.name) },
107+
watchersCount: metadata.watchersCount,
108+
readmeURL: readme?.downloadURL,
109+
authors: contributors?.map { .init(username: $0.login, url: $0.url, service: .init(name: "GitHub")) },
110+
processedAt: Date()
111+
)))
112+
}
113+
} catch {
114+
return callback(.failure(error))
115+
}
116+
}
117+
}
118+
119+
internal func apiURL(_ url: String) -> Foundation.URL? {
120+
do {
121+
let regex = try NSRegularExpression(pattern: "([^/@]+)[:/]([^:/]+)/([^/]+)\\.git$", options: .caseInsensitive)
122+
if let match = regex.firstMatch(in: url, options: [], range: NSRange(location: 0, length: url.count)) {
123+
if let hostRange = Range(match.range(at: 1), in: url),
124+
let ownerRange = Range(match.range(at: 2), in: url),
125+
let repoRange = Range(match.range(at: 3), in: url) {
126+
let host = String(url[hostRange])
127+
let owner = String(url[ownerRange])
128+
let repo = String(url[repoRange])
129+
130+
return URL(string: "https://api.\(host)/\(owner)/\(repo)")
131+
}
132+
}
133+
return nil
134+
} catch {
135+
return nil
136+
}
137+
}
138+
139+
private func makeRequestOptions(validResponseCodes: [Int]) -> HTTPClientRequest.Options {
140+
var options = HTTPClientRequest.Options()
141+
options.addUserAgent = true
142+
options.validResponseCodes = validResponseCodes
143+
if defaultHttpClient {
144+
// TODO: make these defaults configurable?
145+
options.timeout = httpClient.configuration.requestTimeout ?? .seconds(1)
146+
options.retryStrategy = httpClient.configuration.retryStrategy ?? .exponentialBackoff(maxAttempts: 3, baseDelay: .milliseconds(50))
147+
options.circuitBreakerStrategy = httpClient.configuration.circuitBreakerStrategy ?? .hostErrors(maxErrors: 5, age: .seconds(5))
148+
} else {
149+
options.timeout = httpClient.configuration.requestTimeout
150+
options.retryStrategy = httpClient.configuration.retryStrategy
151+
options.circuitBreakerStrategy = httpClient.configuration.circuitBreakerStrategy
152+
}
153+
return options
154+
}
155+
156+
enum Errors: Error, Equatable {
157+
case unprocessable(PackageReference)
158+
case invalidResponse(URL)
159+
}
160+
}
161+
162+
extension GitHubPackageMetadataProvider {
163+
fileprivate struct GetRepositoryResponse: Codable {
164+
let name: String
165+
let fullName: String
166+
let description: String?
167+
let isPrivate: Bool
168+
let isFork: Bool
169+
let defaultBranch: String
170+
let updatedAt: Date
171+
let sshURL: Foundation.URL
172+
let cloneURL: Foundation.URL
173+
let tagsURL: Foundation.URL
174+
let contributorsURL: Foundation.URL
175+
let language: String?
176+
let license: License?
177+
let watchersCount: Int
178+
let forksCount: Int
179+
180+
private enum CodingKeys: String, CodingKey {
181+
case name
182+
case fullName = "full_name"
183+
case description
184+
case isPrivate = "private"
185+
case isFork = "fork"
186+
case defaultBranch = "default_branch"
187+
case updatedAt = "updated_at"
188+
case sshURL = "ssh_url"
189+
case cloneURL = "clone_url"
190+
case tagsURL = "tags_url"
191+
case contributorsURL = "contributors_url"
192+
case language
193+
case license
194+
case watchersCount = "watchers_count"
195+
case forksCount = "forks_count"
196+
}
197+
}
198+
}
199+
200+
extension GitHubPackageMetadataProvider {
201+
fileprivate struct License: Codable {
202+
let key: String
203+
let name: String
204+
}
205+
206+
fileprivate struct Tag: Codable {
207+
let name: String
208+
let tarballURL: Foundation.URL
209+
let commit: Commit
210+
211+
private enum CodingKeys: String, CodingKey {
212+
case name
213+
case tarballURL = "tarball_url"
214+
case commit
215+
}
216+
}
217+
218+
fileprivate struct Commit: Codable {
219+
let sha: String
220+
let url: Foundation.URL
221+
}
222+
223+
fileprivate struct Contributor: Codable {
224+
let login: String
225+
let url: Foundation.URL
226+
let contributions: Int
227+
}
228+
229+
fileprivate struct Readme: Codable {
230+
let url: Foundation.URL
231+
let htmlURL: Foundation.URL
232+
let downloadURL: Foundation.URL
233+
234+
private enum CodingKeys: String, CodingKey {
235+
case url
236+
case htmlURL = "html_url"
237+
case downloadURL = "download_url"
238+
}
239+
}
240+
}

Sources/PackageCollections/Providers/JSONPackageCollectionProvider.swift

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ import SourceControl
1919
struct JSONPackageCollectionProvider: PackageCollectionProvider {
2020
let configuration: Configuration
2121
let httpClient: HTTPClient
22+
let defaultHttpClient: Bool
2223
let decoder: JSONDecoder
2324

24-
init(configuration: Configuration = .init(), httpClient: HTTPClient = .init()) {
25+
init(configuration: Configuration = .init(), httpClient: HTTPClient? = nil) {
2526
self.configuration = configuration
26-
self.httpClient = httpClient
27+
self.httpClient = httpClient ?? .init()
28+
self.defaultHttpClient = httpClient == nil
2729
self.decoder = JSONDecoder()
2830
#if os(Linux)
2931
self.decoder.dateDecodingStrategy = .iso8601
@@ -133,22 +135,25 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
133135
var options = HTTPClientRequest.Options()
134136
options.addUserAgent = true
135137
options.validResponseCodes = validResponseCodes
136-
options.timeout = self.configuration.requestTimeout
137-
// TODO: consider making these configurable
138-
options.retryStrategy = .exponentialBackoff(maxAttempts: 3, baseDelay: .milliseconds(50))
139-
options.circuitBreakerStrategy = .hostErrors(maxErrors: 5, age: .seconds(5))
138+
if defaultHttpClient {
139+
// TODO: make these defaults configurable?
140+
options.timeout = httpClient.configuration.requestTimeout ?? .seconds(1)
141+
options.retryStrategy = httpClient.configuration.retryStrategy ?? .exponentialBackoff(maxAttempts: 3, baseDelay: .milliseconds(50))
142+
options.circuitBreakerStrategy = httpClient.configuration.circuitBreakerStrategy ?? .hostErrors(maxErrors: 5, age: .seconds(5))
143+
} else {
144+
options.timeout = httpClient.configuration.requestTimeout
145+
options.retryStrategy = httpClient.configuration.retryStrategy
146+
options.circuitBreakerStrategy = httpClient.configuration.circuitBreakerStrategy
147+
}
140148
return options
141149
}
142150

143151
public struct Configuration {
144152
public var maximumSizeInBytes: Int
145-
public var requestTimeout: DispatchTimeInterval?
146153

147-
public init(maximumSizeInBytes: Int? = nil,
148-
requestTimeout: DispatchTimeInterval? = nil) {
154+
public init(maximumSizeInBytes: Int? = nil) {
149155
// TODO: where should we read defaults from?
150156
self.maximumSizeInBytes = maximumSizeInBytes ?? 5_000_000 // 5MB
151-
self.requestTimeout = requestTimeout ?? .seconds(1)
152157
}
153158
}
154159

Sources/PackageCollections/Providers/PackageMetadataProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ protocol PackageMetadataProvider {
2020
/// - Parameters:
2121
/// - reference: The package's reference
2222
/// - callback: The closure to invoke when result becomes available
23-
func get(reference: PackageReference, callback: @escaping (Result<PackageCollectionsModel.PackageBasicMetadata?, Error>) -> Void)
23+
func get(_ reference: PackageReference, callback: @escaping (Result<PackageCollectionsModel.PackageBasicMetadata, Error>) -> Void)
2424
}
2525

2626
extension PackageCollectionsModel {

0 commit comments

Comments
 (0)