-
Notifications
You must be signed in to change notification settings - Fork 32
Add GitLab as metadata-provider and make it easier to support other ones #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
aff65ff
a09adf0
11c6009
0b80437
71081f8
563083c
af23a7a
cf57ae7
2db31a8
40b1d60
343f214
009217f
d389af0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// This source file is part of the Swift Package Collection Generator open source project | ||
// | ||
// Copyright (c) 2021 Apple Inc. and the Swift Package Collection Generator project authors | ||
// Licensed under Apache License v2.0 | ||
// | ||
// See LICENSE.txt for license information | ||
// See CONTRIBUTORS.txt for the list of Swift Package Collection Generator project authors | ||
// | ||
// SPDX-License-Identifier: Apache-2.0 | ||
// | ||
//===----------------------------------------------------------------------===// | ||
|
||
import Dispatch | ||
import Foundation | ||
|
||
import TSCBasic | ||
import Basics | ||
import PackageCollectionsModel | ||
import Utilities | ||
|
||
struct GitLabPackageMetadataProvider: PackageMetadataProvider { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I will add them. |
||
private static let apiHostURLPathPostfix = "api/v4" | ||
|
||
private let authTokens: [AuthTokenType: String] | ||
private let httpClient: HTTPClient | ||
private let decoder: JSONDecoder | ||
private enum ResultKeys { | ||
case metadata | ||
} | ||
|
||
init(authTokens: [AuthTokenType: String] = [:], httpClient: HTTPClient? = nil) { | ||
self.authTokens = authTokens | ||
self.httpClient = httpClient ?? Self.makeDefaultHTTPClient() | ||
self.decoder = JSONDecoder.makeWithDefaults() | ||
} | ||
|
||
func get(_ packageURL: URL, callback: @escaping (Result<PackageBasicMetadata, Error>) -> Void) { | ||
guard let baseURL: URL = self.apiURL(packageURL) else { | ||
return callback(.failure(Errors.invalidGitURL(packageURL))) | ||
} | ||
|
||
// get the main data | ||
let metadataHeaders = HTTPClientHeaders() | ||
let metadataOptions = self.makeRequestOptions(validResponseCodes: [200, 401, 403, 404]) | ||
let hasAuthorization = metadataOptions.authorizationProvider?(baseURL) != nil | ||
var result: Result<HTTPClient.Response, Error> = tsc_await { callback in self.httpClient.get(baseURL, headers: metadataHeaders, options: metadataOptions, completion: callback) } | ||
|
||
if case .success(let response) = result { | ||
let apiLimit = response.headers.get("RateLimit-Limit").first.flatMap(Int.init) ?? -1 | ||
let apiRemaining = response.headers.get("RateLimit-Remaining").first.flatMap(Int.init) ?? -1 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
switch (response.statusCode, hasAuthorization, apiRemaining) { | ||
case (_, _, 0): | ||
result = .failure(Errors.apiLimitsExceeded(baseURL, apiLimit, apiRemaining)) | ||
case (401, true, _): | ||
result = .failure(Errors.invalidAuthToken(baseURL)) | ||
case (401, false, _): | ||
result = .failure(Errors.permissionDenied(baseURL)) | ||
case (403, _, _): | ||
result = .failure(Errors.permissionDenied(baseURL)) | ||
case (404, _, _): | ||
result = .failure(Errors.notFound(baseURL)) | ||
case (200, _, _): | ||
guard let metadata = try? response.decodeBody(GetProjectResponse.self, using: self.decoder) else { | ||
callback(.failure(Errors.invalidResponse(baseURL, "Invalid body"))) | ||
return | ||
} | ||
|
||
let license = metadata.license | ||
let packageLicense: PackageCollectionModel.V1.License? | ||
if let licenseURL = metadata.licenseURL.flatMap({ URL(string: $0) }) { | ||
packageLicense = .init(name: license?.key, url: licenseURL) | ||
} else { | ||
packageLicense = nil | ||
} | ||
yim-lee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
let model = PackageBasicMetadata( | ||
summary: metadata.description, | ||
keywords: metadata.topics, | ||
readmeURL: metadata.readmeURL.flatMap { URL(string: $0) }, | ||
license: packageLicense | ||
) | ||
|
||
callback(.success(model)) | ||
default: | ||
callback(.failure(Errors.invalidResponse(baseURL, "Invalid status code: \(response.statusCode)"))) | ||
} | ||
} | ||
} | ||
|
||
internal func apiURL(_ url: URL) -> Foundation.URL? { | ||
guard let baseURL = URL(string: url.absoluteString.spm_dropGitSuffix()), | ||
let urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false), | ||
let scheme = urlComponents.scheme, | ||
let host = urlComponents.host else { | ||
return nil | ||
} | ||
let projectPath = urlComponents.path.dropFirst().replacingOccurrences(of: "/", with: "%2F") | ||
let apiPrefix = GitLabPackageMetadataProvider.apiHostURLPathPostfix | ||
let metadataURL = URL(string: "\(scheme)://\(host)/\(apiPrefix)/projects/\(projectPath)")! | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to be safe, we can |
||
return metadataURL | ||
} | ||
|
||
private func makeRequestOptions(validResponseCodes: [Int]) -> HTTPClientRequest.Options { | ||
var options = HTTPClientRequest.Options() | ||
options.addUserAgent = true | ||
options.validResponseCodes = validResponseCodes | ||
options.authorizationProvider = { url in | ||
url.host.flatMap { host in | ||
return self.authTokens[.gitlab(host)].flatMap { token in "Bearer \(token)" } | ||
} | ||
} | ||
return options | ||
} | ||
|
||
private static func makeDefaultHTTPClient() -> HTTPClient { | ||
var client = HTTPClient() | ||
client.configuration.requestTimeout = .seconds(2) | ||
client.configuration.retryStrategy = .exponentialBackoff(maxAttempts: 3, baseDelay: .milliseconds(50)) | ||
client.configuration.circuitBreakerStrategy = .hostErrors(maxErrors: 50, age: .seconds(30)) | ||
return client | ||
} | ||
|
||
enum Errors: Error, Equatable { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps refactor this into There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I only realise now that there is also code inside SwiftPM to fetch metadata. And it seems like that the usage of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There is plan to move swift-package-collection-generator into SwiftPM actually. This repo started out separate from SwiftPM because we needed to be able to iterate on this more quickly, but now that things have stabilized, we would like to make this part of SwiftPM, which is more fitting. And when we do that, we will consolidate the code as you point out. Let's just leave this code as-is for now. |
||
case invalidGitURL(URL) | ||
case invalidResponse(URL, String) | ||
case permissionDenied(URL) | ||
case invalidAuthToken(URL) | ||
case apiLimitsExceeded(URL, Int, Int) | ||
case notFound(URL) | ||
} | ||
} | ||
|
||
extension GitLabPackageMetadataProvider { | ||
fileprivate struct GetProjectResponse: Codable { | ||
let name: String | ||
let fullName: String | ||
let description: String? | ||
let topics: [String]? | ||
let licenseURL: String? | ||
let readmeURL: String? | ||
let license: License? | ||
|
||
private enum CodingKeys: String, CodingKey { | ||
case name | ||
case fullName = "name_with_namespace" | ||
case description | ||
case topics | ||
case licenseURL = "license_url" | ||
case readmeURL = "readme_url" | ||
case license | ||
} | ||
} | ||
} | ||
|
||
extension GitLabPackageMetadataProvider { | ||
fileprivate struct License: Codable { | ||
let htmlURL: Foundation.URL | ||
let sourceURL: Foundation.URL | ||
let key: String? | ||
|
||
private enum CodingKeys: String, CodingKey { | ||
case htmlURL = "html_url" | ||
case sourceURL = "source_url" | ||
case key | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should introduce
PackageMetadataProviderType
enum:such that:
PackageCollectionGeneratorInput.metadataProviderMapping
would be of type[String: PackageMetadataProviderType]
instead.metadataProviderByType: [PackageMetadataProviderType: PackageMetadataProvider]
soGitHubPackageMetadataProvider
/GitLabPackageMetadataProvider
only needs to be initialized once.hostToProviderMapping: [String: PackageMetadataProvider]
wouldn't be necessary I think, because we can doAlso, we should default
metadataProvider
toGitHubPackageMetadataProvider
to keep the original behavior--i.e., the generator always tries fetching metadata through GitHub APIs even if user doesn't provide any tokens and/or configuration.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my first try I implemented it this way, but afterwards I changed it to [String: String]. The main reason is the usability for the user. Currently, if the
input.json
contains a provider that is not implemented, the error-message will be"Provider \(provider) for host \(host) is not implemented"
. If the provider is anenum
, the decoding of the JSON will fail with a message like "Cannot initialize PackageMetadataProviderType from invalid String value bitbucket". This wouldn't guide the user what is really going wrong. Implementing theinit(from decoder: Decoder) throws
-method and throw a more descriptive error, like thisimproves the situation, as the user will at least get a message like "Provider 'bitbucket' is not implemented". This would be acceptable, but from my point of view it is not as good as the current message. And it doesn't simplify the implementation. Would you still prefer this implementation, or do you see another possibility that I didn't think of?
Another point where I wasn't sure about: In my current implementation, there is one instance of a
*MetaDataProvider
-class per Host, each one with it's own instance ofHTTPClient
. I'm not sure if this may cause any problems, if one instance would handle multiple hosts (I don't see any state in it on a first glance that could cause problems, but I haven't studied it enough to rule it out). Like, if there are three different hosts that make use of the gitlab-API - may it cause a problem, if all of them use the sameHTTPClient
-instance?Regarding making
GitHubPackageMetadataProvider
as the default: I prefer to not make an internal assumption of a default-provider. It would lead to confusing and inconsistent errors, if it tries to use the GitHub-API for other providers. The current error-message"Missing provider for host \(package.url.host ?? "invalid host"). Please specify it in the input-json in metadataProviders."
does not only give an error what is missing, but also offers a guidance where to add it. As the providers are already setup forgithub.com
, it would not change the current behaviour.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. What if we keep
metadataProviders
[String: String]
inPackageCollectionGeneratorInput
but introduce an intermediate, internal method that converts it to[String: PackageMetadataProviderType]
? The method can throw error with user-friendly message when it comes across a provider that it doesn't recognize.Yes, I don't think it's necessary to have one provider instance per host and that's why I suggest having
metadataProviderByType: [PackageMetadataProviderType: PackageMetadataProvider]
instead. I don't see a problem sharing the same provider instance across multiple hosts (HTTPClient
is not tied to a host), but then I also don't think there would be so many different hosts that we would end up getting into trouble with one instance per host.I do prefer singleton by provider type though.
How about we add an option (e.g.,
--default-metadata-provider
) that defaults tonil
(none), but user can set it to one ofPackageMetadataProviderType
if they choose to?