Files
SonarDesk/SonarDesk-iOS/Shared/Services/SonarQubeClient.swift
2026-10-06 09:13:11 +01:00

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
}
}
}