157 lines
8.0 KiB
Swift
157 lines
8.0 KiB
Swift
import Foundation
|
|
|
|
actor SonarQubeClient {
|
|
let server: SonarServer
|
|
private let token: String
|
|
private let session: URLSession
|
|
|
|
init(server: SonarServer, token: String) {
|
|
self.server = server
|
|
self.token = token
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.timeoutIntervalForRequest = 30
|
|
config.timeoutIntervalForResource = 60
|
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
config.urlCache = nil
|
|
config.httpCookieStorage = nil
|
|
config.httpShouldSetCookies = false
|
|
config.httpAdditionalHeaders = ["Accept": "application/json"]
|
|
self.session = URLSession(configuration: config)
|
|
}
|
|
|
|
func serverStatus() async throws -> ServerStatusResponse { try await get("api/system/status") }
|
|
|
|
func projects(page: Int = 1, pageSize: Int = 100) async throws -> ProjectsResponse {
|
|
try await get("api/components/search", query: ["qualifiers":"TRK", "p":"\(page)", "ps":"\(pageSize)"])
|
|
}
|
|
|
|
func measures(projectKey: String) async throws -> MeasuresResponse {
|
|
let metrics = ["bugs","vulnerabilities","code_smells","security_hotspots","coverage","duplicated_lines_density","ncloc","complexity","reliability_rating","security_rating","sqale_rating"].joined(separator: ",")
|
|
return try await get("api/measures/component", query: ["component": projectKey, "metricKeys": metrics])
|
|
}
|
|
|
|
func qualityGate(projectKey: String) async throws -> QualityGateResponse {
|
|
try await get("api/qualitygates/project_status", query: ["projectKey": projectKey])
|
|
}
|
|
|
|
func createProject(name: String, key: String, visibility: String? = nil) async throws {
|
|
var fields = ["name": name, "project": key]
|
|
if let visibility, !visibility.isEmpty { fields["visibility"] = visibility }
|
|
try await postForm("api/projects/create", fields: fields)
|
|
}
|
|
|
|
func generateProjectAnalysisToken(projectKey: String, tokenName: String) async throws -> TokenGenerateResponse {
|
|
try await postForm("api/user_tokens/generate", fields: [
|
|
"name": tokenName,
|
|
"type": "PROJECT_ANALYSIS_TOKEN",
|
|
"projectKey": projectKey
|
|
])
|
|
}
|
|
|
|
func issues(projectKey: String? = nil, type: String? = nil, page: Int = 1, pageSize: Int = 25) async throws -> IssuesResponse {
|
|
var q = ["p":"\(page)", "ps":"\(pageSize)", "resolved":"false", "s":"CREATION_DATE", "asc":"false"]
|
|
if let projectKey { q["componentKeys"] = projectKey }
|
|
if let type { q["types"] = type }
|
|
return try await get("api/issues/search", query: q)
|
|
}
|
|
|
|
private func postForm<T: Decodable>(_ path: String, fields: [String: String]) async throws -> T {
|
|
let data = try await postFormData(path, fields: fields)
|
|
do { return try JSONDecoder().decode(T.self, from: data) }
|
|
catch { throw APIError.decoding(error.localizedDescription) }
|
|
}
|
|
|
|
private func postForm(_ path: String, fields: [String: String]) async throws {
|
|
_ = try await postFormData(path, fields: fields)
|
|
}
|
|
|
|
private func postFormData(_ path: String, fields: [String: String]) async throws -> Data {
|
|
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
guard let url = URL(string: base + "/" + path) else { throw APIError.invalidURL }
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
var components = URLComponents()
|
|
components.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) }
|
|
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
|
if (200...299).contains(http.statusCode) { return data }
|
|
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
|
|
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let errors = object["errors"] as? [[String: Any]],
|
|
let message = errors.first?["msg"] as? String { throw APIError.serverMessage(message) }
|
|
throw APIError.http(http.statusCode)
|
|
}
|
|
|
|
private func get<T: Decodable>(_ path: String, query: [String:String] = [:]) async throws -> T {
|
|
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
guard var components = URLComponents(string: base + "/" + path),
|
|
let scheme = components.scheme?.lowercased(),
|
|
scheme == "https" || (scheme == "http" && Self.isLocalDevelopmentHost(components.host)) else {
|
|
throw APIError.insecureURL
|
|
}
|
|
if !query.isEmpty { components.queryItems = query.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } }
|
|
guard let url = components.url else { throw APIError.invalidURL }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
var lastError: Error?
|
|
for attempt in 0..<3 {
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
|
if (200...299).contains(http.statusCode) {
|
|
do { return try JSONDecoder().decode(T.self, from: data) }
|
|
catch { throw APIError.decoding(error.localizedDescription) }
|
|
}
|
|
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
|
|
guard (http.statusCode == 429 || (500...599).contains(http.statusCode)), attempt < 2 else {
|
|
throw APIError.http(http.statusCode)
|
|
}
|
|
let delay = UInt64(500_000_000 * (attempt + 1))
|
|
try await Task.sleep(nanoseconds: delay)
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch let error as APIError {
|
|
switch error {
|
|
case .authentication, .decoding, .invalidResponse, .invalidURL, .insecureURL: throw error
|
|
default: lastError = error
|
|
}
|
|
} catch {
|
|
lastError = error
|
|
guard attempt < 2 else { break }
|
|
try await Task.sleep(nanoseconds: UInt64(500_000_000 * (attempt + 1)))
|
|
}
|
|
}
|
|
throw lastError ?? APIError.invalidResponse
|
|
}
|
|
|
|
private static func isLocalDevelopmentHost(_ host: String?) -> Bool {
|
|
guard let host = host?.lowercased() else { return false }
|
|
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
|
}
|
|
}
|
|
|
|
enum APIError: LocalizedError {
|
|
case invalidURL, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured, serverMessage(String)
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "The server URL is invalid."
|
|
case .insecureURL: return "Use an HTTPS SonarQube URL. HTTP is allowed only for localhost development."
|
|
case .invalidResponse: return "The server returned an invalid response."
|
|
case .authentication: return "SonarQube rejected the saved token. Edit the server and enter a valid token."
|
|
case .http(let code): return "SonarQube returned HTTP \(code)."
|
|
case .decoding(let text): return "Could not read the SonarQube response: \(text)"
|
|
case .notConfigured: return "Configure a SonarQube server in Settings."
|
|
case .serverMessage(let message): return message
|
|
}
|
|
}
|
|
}
|