Skip to content

Commit df11f9b

Browse files
authored
package metadata: add support for auth tokens (#3077)
motivate: gfithub emposes API rate limiting, using an auth token helps mitigate low non-auth limits changes: * expose auth token configuration that can be set by clients of libSwiftPM * add functionality to GitHubPackageMetadataProvider to handle auth token * add functionality to GitHubPackageMetadataProvider to handle api limit errors * add tests TODO: * allow users to configure auth tokens via config files (follow up PR)
1 parent ef468fc commit df11f9b

File tree

6 files changed

+196
-44
lines changed

6 files changed

+196
-44
lines changed

Sources/PackageCollections/PackageCollections+Configuration.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ extension PackageCollections {
1313
public struct Configuration {
1414
// TODO: add configuration like mx size of feed, retries, etc
1515

16-
public init() {}
16+
/// Auth tokens for the collections or metadata provider
17+
public var authTokens: [AuthTokenType: String]?
18+
19+
public init(authTokens: [AuthTokenType: String]? = nil) {
20+
self.authTokens = authTokens
21+
}
1722
}
1823
}
24+
25+
public enum AuthTokenType: Hashable {
26+
case github(_ host: String)
27+
}

Sources/PackageCollections/PackageCollections.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ public struct PackageCollections: PackageCollectionsProtocol {
2727
public init(configuration: Configuration = .init(), diagnosticsEngine: DiagnosticsEngine? = nil) {
2828
let storage = Storage(sources: FilePackageCollectionsSourcesStorage(diagnosticsEngine: diagnosticsEngine),
2929
collections: SQLitePackageCollectionsStorage(diagnosticsEngine: diagnosticsEngine))
30+
3031
let collectionProviders = [Model.CollectionSourceType.json: JSONPackageCollectionProvider(diagnosticsEngine: diagnosticsEngine)]
31-
let metadataProvider = GitHubPackageMetadataProvider(diagnosticsEngine: diagnosticsEngine)
32+
33+
let metadataProvider = GitHubPackageMetadataProvider(configuration: .init(authTokens: configuration.authTokens),
34+
diagnosticsEngine: diagnosticsEngine)
3235

3336
self.configuration = configuration
3437
self.diagnosticsEngine = diagnosticsEngine

Sources/PackageCollections/Providers/GitHubPackageMetadataProvider.swift

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ import TSCBasic
2121
struct GitHubPackageMetadataProvider: PackageMetadataProvider {
2222
public var name: String = "GitHub"
2323

24+
var configuration: Configuration
25+
2426
private let httpClient: HTTPClient
2527
private let diagnosticsEngine: DiagnosticsEngine?
2628
private let decoder: JSONDecoder
2729
private let queue: DispatchQueue
2830

29-
init(httpClient: HTTPClient? = nil, diagnosticsEngine: DiagnosticsEngine? = nil) {
31+
init(configuration: Configuration = .init(), httpClient: HTTPClient? = nil, diagnosticsEngine: DiagnosticsEngine? = nil) {
32+
self.configuration = configuration
3033
self.httpClient = httpClient ?? Self.makeDefaultHTTPClient(diagnosticsEngine: diagnosticsEngine)
3134
self.diagnosticsEngine = diagnosticsEngine
3235
self.decoder = JSONDecoder.makeWithDefaults()
@@ -53,28 +56,52 @@ struct GitHubPackageMetadataProvider: PackageMetadataProvider {
5356

5457
// get the main data
5558
sync.enter()
56-
let options = self.makeRequestOptions(validResponseCodes: [200])
57-
var headers = HTTPClientHeaders()
58-
headers.add(name: "Accept", value: "application/vnd.github.mercy-preview+json")
59-
httpClient.get(metadataURL, headers: headers, options: options) { result in
59+
var metadataHeaders = self.makeRequestHeaders(metadataURL)
60+
metadataHeaders.add(name: "Accept", value: "application/vnd.github.mercy-preview+json")
61+
let metadataOptions = self.makeRequestOptions(validResponseCodes: [200, 401, 403, 404])
62+
httpClient.get(metadataURL, headers: metadataHeaders, options: metadataOptions) { result in
6063
defer { sync.leave() }
6164
resultsLock.withLock {
6265
results[metadataURL] = result
6366
}
64-
// if successful, fan out multiple API calls
65-
if case .success = result {
66-
[tagsURL, contributorsURL, readmeURL].forEach { url in
67-
sync.enter()
68-
httpClient.get(url, options: options) { result in
69-
defer { sync.leave() }
70-
resultsLock.withLock {
71-
results[url] = result
67+
if case .success(let response) = result {
68+
let apiLimit = response.headers.get("X-RateLimit-Limit").first.flatMap(Int.init) ?? -1
69+
let apiRemaining = response.headers.get("X-RateLimit-Remaining").first.flatMap(Int.init) ?? -1
70+
71+
switch (response.statusCode, metadataHeaders.contains("Authorization"), apiRemaining) {
72+
case (_, _, 0):
73+
self.diagnosticsEngine?.emit(warning: "Exceeded API limits on \(metadataURL.host ?? metadataURL.absoluteString) (\(apiRemaining)/\(apiLimit)), consider configuring an API token for this service.")
74+
return callback(.failure(Errors.apiLimitsExceeded(metadataURL, apiLimit)))
75+
case (401, true, _):
76+
return callback(.failure(Errors.invalidAuthToken(metadataURL)))
77+
case (401, false, _):
78+
return callback(.failure(Errors.permissionDenied(metadataURL)))
79+
case (403, _, _):
80+
return callback(.failure(Errors.permissionDenied(metadataURL)))
81+
case (404, _, _):
82+
return callback(.failure(NotFoundError("\(baseURL)")))
83+
case (200, _, _):
84+
if apiRemaining < self.configuration.apiLimitWarningThreshold {
85+
self.diagnosticsEngine?.emit(warning: "Approaching API limits on \(metadataURL.host ?? metadataURL.absoluteString) (\(apiRemaining)/\(apiLimit)), consider configuring an API token for this service.")
86+
}
87+
// if successful, fan out multiple API calls
88+
[tagsURL, contributorsURL, readmeURL].forEach { url in
89+
sync.enter()
90+
var headers = self.makeRequestHeaders(url)
91+
headers.add(name: "Accept", value: "application/vnd.github.v3+json")
92+
let options = self.makeRequestOptions(validResponseCodes: [200])
93+
httpClient.get(url, headers: headers, options: options) { result in
94+
defer { sync.leave() }
95+
resultsLock.withLock {
96+
results[url] = result
97+
}
7298
}
7399
}
100+
default:
101+
return callback(.failure(Errors.invalidResponse(metadataURL, "Invalid status code: \(response.statusCode)")))
74102
}
75103
}
76104
}
77-
78105
sync.wait()
79106

80107
// process results
@@ -83,14 +110,12 @@ struct GitHubPackageMetadataProvider: PackageMetadataProvider {
83110
// check for main request error state
84111
switch results[metadataURL] {
85112
case .none:
86-
throw Errors.invalidResponse(metadataURL)
87-
case .some(.failure(let error)) where error as? HTTPClientError == .badResponseStatusCode(404):
88-
throw NotFoundError("\(baseURL)")
113+
throw Errors.invalidResponse(metadataURL, "Response missing")
89114
case .some(.failure(let error)):
90115
throw error
91116
case .some(.success(let metadataResponse)):
92117
guard let metadata = try metadataResponse.decodeBody(GetRepositoryResponse.self, using: self.decoder) else {
93-
throw Errors.invalidResponse(metadataURL)
118+
throw Errors.invalidResponse(metadataURL, "Empty body")
94119
}
95120
let tags = try results[tagsURL]?.success?.decodeBody([Tag].self, using: self.decoder) ?? []
96121
let contributors = try results[contributorsURL]?.success?.decodeBody([Contributor].self, using: self.decoder)
@@ -140,6 +165,14 @@ struct GitHubPackageMetadataProvider: PackageMetadataProvider {
140165
return options
141166
}
142167

168+
private func makeRequestHeaders(_ url: URL) -> HTTPClientHeaders {
169+
var headers = HTTPClientHeaders()
170+
if let host = url.host, let token = self.configuration.authTokens?[.github(host)] {
171+
headers.add(name: "Authorization", value: "token \(token)")
172+
}
173+
return headers
174+
}
175+
143176
private static func makeDefaultHTTPClient(diagnosticsEngine: DiagnosticsEngine?) -> HTTPClient {
144177
var client = HTTPClient(diagnosticsEngine: diagnosticsEngine)
145178
// TODO: make these defaults configurable?
@@ -149,10 +182,24 @@ struct GitHubPackageMetadataProvider: PackageMetadataProvider {
149182
return client
150183
}
151184

185+
public struct Configuration {
186+
public var apiLimitWarningThreshold: Int
187+
public var authTokens: [AuthTokenType: String]?
188+
189+
public init(authTokens: [AuthTokenType: String]? = nil,
190+
apiLimitWarningThreshold: Int? = nil) {
191+
self.authTokens = authTokens
192+
self.apiLimitWarningThreshold = apiLimitWarningThreshold ?? 5
193+
}
194+
}
195+
152196
enum Errors: Error, Equatable {
153197
case invalidReferenceType(PackageReference)
154198
case invalidGitUrl(String)
155-
case invalidResponse(URL)
199+
case invalidResponse(URL, String)
200+
case permissionDenied(URL)
201+
case invalidAuthToken(URL)
202+
case apiLimitsExceeded(URL, Int)
156203
}
157204
}
158205

Sources/PackageCollections/Providers/JSONPackageCollectionProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
2828
init(configuration: Configuration = .init(), httpClient: HTTPClient? = nil, diagnosticsEngine: DiagnosticsEngine? = nil) {
2929
self.configuration = configuration
3030
self.diagnosticsEngine = diagnosticsEngine
31-
self.httpClient = httpClient ?? Self.makeDefaultHTTPClient(diagnosticsEngine: diagnosticsEngine)
31+
self.httpClient = httpClient ?? Self.makeDefaultHTTPClient(diagnosticsEngine: diagnosticsEngine)
3232
self.decoder = JSONDecoder.makeWithDefaults()
3333
}
3434

Sources/PackageCollections/Storage/FilePackageCollectionsSourcesStorage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ struct FilePackageCollectionsSourcesStorage: PackageCollectionsSourcesStorage {
3232
let name = "collections"
3333
self.path = path ?? fileSystem.dotSwiftPM.appending(components: "config", "\(name).json")
3434
self.diagnosticsEngine = diagnosticsEngine
35-
self.encoder = JSONEncoder.makeWithDefaults()
35+
self.encoder = JSONEncoder.makeWithDefaults()
3636
self.decoder = JSONDecoder.makeWithDefaults()
3737
}
3838

Tests/PackageCollectionsTests/GitHubPackageMetadataProviderTests.swift

Lines changed: 114 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,17 @@ class GitHubPackageMetadataProviderTests: XCTestCase {
8989
func testRepoNotFound() throws {
9090
let repoURL = "https://github.com/octocat/Hello-World.git"
9191

92-
fixture(name: "Collections") { _ in
93-
let handler = { (_: HTTPClient.Request, callback: @escaping (Result<HTTPClient.Response, Error>) -> Void) in
94-
callback(.success(.init(statusCode: 404)))
95-
}
92+
let handler = { (_: HTTPClient.Request, callback: @escaping (Result<HTTPClient.Response, Error>) -> Void) in
93+
callback(.success(.init(statusCode: 404)))
94+
}
9695

97-
var httpClient = HTTPClient(handler: handler)
98-
httpClient.configuration.circuitBreakerStrategy = .none
99-
httpClient.configuration.retryStrategy = .none
100-
let provider = GitHubPackageMetadataProvider(httpClient: httpClient)
101-
let reference = PackageReference(repository: RepositorySpecifier(url: repoURL))
102-
XCTAssertThrowsError(try tsc_await { callback in provider.get(reference, callback: callback) }, "should throw error") { error in
103-
XCTAssert(error is NotFoundError, "\(error)")
104-
}
96+
var httpClient = HTTPClient(handler: handler)
97+
httpClient.configuration.circuitBreakerStrategy = .none
98+
httpClient.configuration.retryStrategy = .none
99+
let provider = GitHubPackageMetadataProvider(httpClient: httpClient)
100+
let reference = PackageReference(repository: RepositorySpecifier(url: repoURL))
101+
XCTAssertThrowsError(try tsc_await { callback in provider.get(reference, callback: callback) }, "should throw error") { error in
102+
XCTAssert(error is NotFoundError, "\(error)")
105103
}
106104
}
107105

@@ -110,11 +108,11 @@ class GitHubPackageMetadataProviderTests: XCTestCase {
110108
let apiURL = URL(string: "https://api.github.com/repos/octocat/Hello-World")!
111109

112110
fixture(name: "Collections") { directoryPath in
111+
let path = directoryPath.appending(components: "GitHub", "metadata.json")
112+
let data = try Data(localFileSystem.readFileContents(path).contents)
113113
let handler = { (request: HTTPClient.Request, callback: @escaping (Result<HTTPClient.Response, Error>) -> Void) in
114114
switch (request.method, request.url) {
115115
case (.get, apiURL):
116-
let path = directoryPath.appending(components: "GitHub", "metadata.json")
117-
let data = Data(try! localFileSystem.readFileContents(path).contents)
118116
callback(.success(.init(statusCode: 200,
119117
headers: .init([.init(name: "Content-Length", value: "\(data.count)")]),
120118
body: data)))
@@ -138,6 +136,93 @@ class GitHubPackageMetadataProviderTests: XCTestCase {
138136
}
139137
}
140138

139+
func testPermissionDenied() throws {
140+
let repoURL = "https://github.com/octocat/Hello-World.git"
141+
let apiURL = URL(string: "https://api.github.com/repos/octocat/Hello-World")!
142+
143+
let handler = { (_: HTTPClient.Request, callback: @escaping (Result<HTTPClient.Response, Error>) -> Void) in
144+
callback(.success(.init(statusCode: 401)))
145+
}
146+
147+
var httpClient = HTTPClient(handler: handler)
148+
httpClient.configuration.circuitBreakerStrategy = .none
149+
httpClient.configuration.retryStrategy = .none
150+
let provider = GitHubPackageMetadataProvider(httpClient: httpClient)
151+
let reference = PackageReference(repository: RepositorySpecifier(url: repoURL))
152+
XCTAssertThrowsError(try tsc_await { callback in provider.get(reference, callback: callback) }, "should throw error") { error in
153+
XCTAssertEqual(error as? GitHubPackageMetadataProvider.Errors, .permissionDenied(apiURL))
154+
}
155+
}
156+
157+
func testInvalidAuthToken() throws {
158+
let repoURL = "https://github.com/octocat/Hello-World.git"
159+
let apiURL = URL(string: "https://api.github.com/repos/octocat/Hello-World")!
160+
let authTokens = [AuthTokenType.github("api.github.com"): "foo"]
161+
162+
let handler = { (request: HTTPClient.Request, callback: @escaping (Result<HTTPClient.Response, Error>) -> Void) in
163+
if request.headers.get("Authorization").first == "token \(authTokens.first!.value)" {
164+
callback(.success(.init(statusCode: 401)))
165+
} else {
166+
XCTFail("expected correct authorization header")
167+
callback(.success(.init(statusCode: 500)))
168+
}
169+
}
170+
171+
var httpClient = HTTPClient(handler: handler)
172+
httpClient.configuration.circuitBreakerStrategy = .none
173+
httpClient.configuration.retryStrategy = .none
174+
var provider = GitHubPackageMetadataProvider(httpClient: httpClient)
175+
provider.configuration.authTokens = authTokens
176+
let reference = PackageReference(repository: RepositorySpecifier(url: repoURL))
177+
XCTAssertThrowsError(try tsc_await { callback in provider.get(reference, callback: callback) }, "should throw error") { error in
178+
XCTAssertEqual(error as? GitHubPackageMetadataProvider.Errors, .invalidAuthToken(apiURL))
179+
}
180+
}
181+
182+
func testAPILimit() throws {
183+
let repoURL = "https://github.com/octocat/Hello-World.git"
184+
let apiURL = URL(string: "https://api.github.com/repos/octocat/Hello-World")!
185+
186+
let total = 5
187+
var remaining = total
188+
189+
fixture(name: "Collections") { directoryPath in
190+
let path = directoryPath.appending(components: "GitHub", "metadata.json")
191+
let data = try Data(localFileSystem.readFileContents(path).contents)
192+
let handler = { (request: HTTPClient.Request, callback: @escaping (Result<HTTPClient.Response, Error>) -> Void) in
193+
var headers = HTTPClientHeaders()
194+
headers.add(name: "X-RateLimit-Limit", value: "\(total)")
195+
headers.add(name: "X-RateLimit-Remaining", value: "\(remaining)")
196+
if remaining == 0 {
197+
callback(.success(.init(statusCode: 403, headers: headers)))
198+
} else if request.url == apiURL {
199+
remaining = remaining - 1
200+
headers.add(name: "Content-Length", value: "\(data.count)")
201+
callback(.success(.init(statusCode: 200,
202+
headers: headers,
203+
body: data)))
204+
} else {
205+
callback(.success(.init(statusCode: 500)))
206+
}
207+
}
208+
209+
var httpClient = HTTPClient(handler: handler)
210+
httpClient.configuration.circuitBreakerStrategy = .none
211+
httpClient.configuration.retryStrategy = .none
212+
let provider = GitHubPackageMetadataProvider(httpClient: httpClient)
213+
let reference = PackageReference(repository: RepositorySpecifier(url: repoURL))
214+
for index in 0 ... total * 2 {
215+
if index >= total {
216+
XCTAssertThrowsError(try tsc_await { callback in provider.get(reference, callback: callback) }, "should throw error") { error in
217+
XCTAssertEqual(error as? GitHubPackageMetadataProvider.Errors, .apiLimitsExceeded(apiURL, total))
218+
}
219+
} else {
220+
XCTAssertNoThrow(try tsc_await { callback in provider.get(reference, callback: callback) })
221+
}
222+
}
223+
}
224+
}
225+
141226
func testInvalidURL() throws {
142227
fixture(name: "Collections") { _ in
143228
let provider = GitHubPackageMetadataProvider()
@@ -169,13 +254,21 @@ class GitHubPackageMetadataProviderTests: XCTestCase {
169254
var httpClient = HTTPClient()
170255
httpClient.configuration.circuitBreakerStrategy = .none
171256
httpClient.configuration.retryStrategy = .none
172-
let provider = GitHubPackageMetadataProvider(httpClient: httpClient)
257+
httpClient.configuration.requestHeaders = .init()
258+
httpClient.configuration.requestHeaders!.add(name: "Cache-Control", value: "no-cache")
259+
var configuration = GitHubPackageMetadataProvider.Configuration()
260+
if let token = ProcessEnv.vars["GITHUB_API_TOKEN"] {
261+
configuration.authTokens = [.github("api.github.com"): token]
262+
}
263+
configuration.apiLimitWarningThreshold = 50
264+
let provider = GitHubPackageMetadataProvider(configuration: configuration, httpClient: httpClient)
173265
let reference = PackageReference(repository: RepositorySpecifier(url: repoURL))
174-
let metadata = try tsc_await { callback in provider.get(reference, callback: callback) }
175-
176-
XCTAssertNotNil(metadata)
177-
XCTAssert(metadata.versions.count > 0)
178-
XCTAssert(metadata.keywords!.count > 0)
179-
XCTAssert(metadata.authors!.count > 0)
266+
for _ in 0 ... 60 {
267+
let metadata = try tsc_await { callback in provider.get(reference, callback: callback) }
268+
XCTAssertNotNil(metadata)
269+
XCTAssert(metadata.versions.count > 0)
270+
XCTAssert(metadata.keywords!.count > 0)
271+
XCTAssert(metadata.authors!.count > 0)
272+
}
180273
}
181274
}

0 commit comments

Comments
 (0)