ForgejoKit/Sources/ForgejoKit/Services/ForgejoClient.swift
Stefan Hausotte b78c57ffd9 feat: add Attachment model and image upload support
- Add Attachment model with isImage computed property
- Add assets field to Issue, IssueComment, and PullRequest
- Add multipart/form-data upload support to ForgejoClient
- Add uploadIssueAttachment and uploadCommentAttachment to IssueService
2026-06-15 09:48:47 +02:00

597 lines
22 KiB
Swift

import Foundation
public final class ForgejoClient: Sendable {
public let serverURL: String
public let username: String
private enum AuthCredential {
case basic(base64: String)
case token(String)
case bearer(String)
}
private let credential: AuthCredential
private let serverHost: String?
private let urlSessionManager: URLSessionManager
private init(serverURL: String, username: String, credential: AuthCredential, allowSelfSignedCertificates: Bool) {
let normalized = Self.normalizeServerURL(serverURL)
self.serverURL = normalized
self.username = username
self.credential = credential
serverHost = URL(string: normalized)?.host
urlSessionManager = URLSessionManager(
allowSelfSignedCertificates: allowSelfSignedCertificates,
trustedHost: URL(string: normalized)?.host,
)
}
public convenience init(
serverURL: String, username: String, password: String,
allowSelfSignedCertificates: Bool = false,
) {
let credentials = "\(username):\(password)"
self.init(
serverURL: serverURL, username: username,
credential: .basic(base64: Data(credentials.utf8).base64EncodedString()),
allowSelfSignedCertificates: allowSelfSignedCertificates,
)
}
public convenience init(
serverURL: String, username: String, token: String,
allowSelfSignedCertificates: Bool = false,
) {
self.init(
serverURL: serverURL, username: username,
credential: .token(token),
allowSelfSignedCertificates: allowSelfSignedCertificates,
)
}
/// Creates a client authenticated with an OAuth2 access token.
///
/// OAuth2 access tokens are sent with the `Authorization: Bearer` scheme,
/// unlike personal access tokens which use `Authorization: token`.
public convenience init(
serverURL: String, username: String, bearerToken: String,
allowSelfSignedCertificates: Bool = false,
) {
self.init(
serverURL: serverURL, username: username,
credential: .bearer(bearerToken),
allowSelfSignedCertificates: allowSelfSignedCertificates,
)
}
public static func login(
serverURL: String, username: String, password: String,
allowSelfSignedCertificates: Bool = false,
) async throws -> LoginResult {
let basicClient = ForgejoClient(
serverURL: serverURL, username: username,
password: password, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
let user = try await basicClient.validateCredentials()
let token = try await basicClient.createAPIToken(otp: nil)
let tokenClient = ForgejoClient(
serverURL: serverURL, username: username,
token: token, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
return LoginResult(client: tokenClient, user: user, token: token)
}
public static func loginWithOTP(
serverURL: String, username: String, password: String, otp: String,
allowSelfSignedCertificates: Bool = false,
) async throws -> LoginResult {
let basicClient = ForgejoClient(
serverURL: serverURL, username: username,
password: password, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
let user = try await basicClient.validateCredentials(otp: otp)
let token = try await basicClient.createAPIToken(otp: otp)
let tokenClient = ForgejoClient(
serverURL: serverURL, username: username,
token: token, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
return LoginResult(client: tokenClient, user: user, token: token)
}
public func fetchCurrentUser() async throws -> User {
try await validateCredentials()
}
private static let certificateErrorCodes: Set<URLError.Code> = [
.serverCertificateUntrusted, .secureConnectionFailed,
.serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
]
private func validateCredentials(otp: String? = nil) async throws -> User {
guard let url = URL(string: "\(serverURL)/api/v1/user") else {
throw AuthenticationError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
applyAuthHeader(to: &request)
if let otp {
request.setValue(otp, forHTTPHeaderField: "X-Forgejo-OTP")
}
do {
let (data, response) = try await urlSessionManager.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthenticationError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return try decoder.decode(User.self, from: data)
default:
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
} catch let error as URLError where Self.certificateErrorCodes.contains(error.code) {
throw AuthenticationError.certificateError
}
}
private func applyAuthHeader(to request: inout URLRequest) {
request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization")
}
/// The `Authorization` header value for this client's credential.
/// Exposed internally so tests can assert the scheme without issuing a request.
var authorizationHeaderValue: String {
switch credential {
case let .basic(base64):
"Basic \(base64)"
case let .token(token):
"token \(token)"
case let .bearer(token):
"Bearer \(token)"
}
}
private static let tokenScopes = [
"read:user", "write:user",
"read:repository", "write:repository",
"read:issue", "write:issue",
"read:notification", "write:notification",
"read:organization",
]
private func createAPIToken(otp: String?) async throws -> String {
let encodedUsername = Self.encodedPathSegment(username)
let urlString = "\(serverURL)/api/v1/users/\(encodedUsername)/tokens"
guard let url = URL(string: urlString) else {
throw AuthenticationError.invalidURL
}
let suffix = String((0 ..< 6).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
let tokenName = "Forji-\(suffix)"
return try await postTokenRequest(url: url, name: tokenName, otp: otp)
}
private func postTokenRequest(url: URL, name: String, otp: String?, retryCount: Int = 0) async throws -> String {
guard case let .basic(base64) = credential else {
throw AuthenticationError.invalidResponse
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
if let otp { request.setValue(otp, forHTTPHeaderField: "X-Forgejo-OTP") }
request.httpBody = try JSONSerialization.data(
withJSONObject: ["name": name, "scopes": Self.tokenScopes] as [String: Any],
)
let (data, response) = try await urlSessionManager.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthenticationError.invalidResponse
}
switch httpResponse.statusCode {
case 201:
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let sha1 = json["sha1"] as? String
else { throw AuthenticationError.invalidResponse }
return sha1
case 400, 422:
// Token name already taken Forgejo returns 400, some versions use 422
let body = String(data: data, encoding: .utf8) ?? ""
guard body.contains("name") else {
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
guard retryCount < 3 else {
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
let retryName = "\(name)-\(Int(Date().timeIntervalSince1970))"
return try await postTokenRequest(
url: url, name: retryName, otp: otp, retryCount: retryCount + 1,
)
default:
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
}
func authenticatedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = method
if let serverHost, let requestHost = url.host,
serverHost.lowercased() == requestHost.lowercased()
{
applyAuthHeader(to: &request)
}
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func executeRequest(
url: URL, method: String = "GET", body: Data? = nil,
) async throws -> (Data, HTTPURLResponse) {
let request = authenticatedRequest(url: url, method: method, body: body)
let session = urlSessionManager.session
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
return (data, httpResponse)
}
private func validateStatus(_ httpResponse: HTTPURLResponse, data: Data) throws {
guard (200 ... 299).contains(httpResponse.statusCode) else {
let serverMessage = String(data: data, encoding: .utf8) ?? ""
throw ServiceError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
}
}
func performRequest<T: Decodable>(
url: URL, method: String = "GET",
body: Data? = nil, responseType _: T.Type,
) async throws -> T {
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
try validateStatus(httpResponse, data: data)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
throw ServiceError.decodingFailed(detail: describeDecodingError(error))
}
}
func performRequestNoContent(
url: URL, method: String = "GET",
body: Data? = nil, validateStatus shouldValidate: Bool = false,
) async throws -> Int {
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
if shouldValidate {
try validateStatus(httpResponse, data: data)
}
return httpResponse.statusCode
}
func performRequestRawText(url: URL, method: String = "GET") async throws -> String {
let (data, httpResponse) = try await executeRequest(url: url, method: method)
try validateStatus(httpResponse, data: data)
guard let text = String(data: data, encoding: .utf8) else {
throw ServiceError.invalidResponse
}
return text
}
func performRequestData(url: URL, method: String = "GET", body: Data? = nil) async throws -> Data {
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
try validateStatus(httpResponse, data: data)
return data
}
/// Issues a GET to `url` with redirects disabled and returns the absolute
/// `Location` header from the response, if any.
///
/// Used to discover server-side redirects (e.g. Forgejo's
/// `RedirectToLatestAttempt`) without consuming the redirect target.
func discoverRedirectLocation(url: URL) async throws -> URL? {
var request = URLRequest(url: url)
request.httpMethod = "GET"
if let serverHost, let requestHost = url.host,
serverHost.lowercased() == requestHost.lowercased()
{
applyAuthHeader(to: &request)
}
let delegate = NoRedirectDelegate()
let session = URLSession(
configuration: .default, delegate: delegate, delegateQueue: nil,
)
defer { session.finishTasksAndInvalidate() }
let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(300 ... 399).contains(httpResponse.statusCode),
let location = httpResponse.value(forHTTPHeaderField: "Location")
else {
return nil
}
return URL(string: location, relativeTo: url)?.absoluteURL
}
}
private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(
_: URLSession, task _: URLSessionTask,
willPerformHTTPRedirection _: HTTPURLResponse,
newRequest _: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void,
) {
completionHandler(nil)
}
}
extension ForgejoClient {
/// Characters allowed in a single URL path segment (`.urlPathAllowed` minus `/`).
private static let pathSegmentAllowed: CharacterSet = {
var allowed = CharacterSet.urlPathAllowed
allowed.remove(charactersIn: "/")
return allowed
}()
/// Percent-encodes a single path segment (e.g. owner or repo name) for safe URL interpolation.
static func encodedPathSegment(_ segment: String) -> String {
segment.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) ?? segment
}
func makeURL(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: "\(serverURL)\(path)") else {
throw ServiceError.invalidURL
}
if !queryItems.isEmpty { components.queryItems = queryItems }
guard let url = components.url else { throw ServiceError.invalidURL }
return url
}
func makeRepoURL(owner: String, repo: String, path: String, queryItems: [URLQueryItem] = []) throws -> URL {
let enc = (Self.encodedPathSegment(owner), Self.encodedPathSegment(repo))
return try makeURL(path: "/api/v1/repos/\(enc.0)/\(enc.1)\(path)", queryItems: queryItems)
}
func encodeRequestBody(_ body: some Encodable) throws -> Data {
try JSONEncoder().encode(body)
}
/// Builds a multipart/form-data body with a single file part named "attachment".
static func buildMultipartBody(
fileData: Data,
fileName: String,
mimeType: String,
boundary: String,
) -> Data {
var body = Data()
body.append(contentsOf: "--\(boundary)\r\n".utf8)
let disposition = "Content-Disposition: form-data; name=\"attachment\"; filename=\"\(fileName)\"\r\n"
body.append(contentsOf: disposition.utf8)
body.append(contentsOf: "Content-Type: \(mimeType)\r\n\r\n".utf8)
body.append(fileData)
body.append(contentsOf: "\r\n--\(boundary)--\r\n".utf8)
return body
}
func performMultipartRequest<T: Decodable>(
url: URL,
fileData: Data,
fileName: String,
mimeType: String,
boundary: String = UUID().uuidString,
responseType: T.Type,
) async throws -> T {
let body = Self.buildMultipartBody(
fileData: fileData, fileName: fileName, mimeType: mimeType, boundary: boundary,
)
var request = authenticatedRequest(url: url, method: "POST")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body
let (data, response) = try await urlSessionManager.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
try validateStatus(httpResponse, data: data)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
do {
return try decoder.decode(responseType, from: data)
} catch let error as DecodingError {
throw ServiceError.decodingFailed(detail: describeDecodingError(error))
}
}
public static func normalizeServerURL(_ url: String) -> String {
var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines)
while normalized.hasSuffix("/") {
normalized = String(normalized.dropLast())
}
if !normalized.hasPrefix("http://"), !normalized.hasPrefix("https://") {
normalized = "https://" + normalized
}
return normalized
}
}
public struct LoginResult: Sendable {
public let client: ForgejoClient
public let user: User
public let token: String
}
public enum HTTPErrorCategory: Equatable, Sendable {
case authentication
case permissionDenied
case notFound
case server
case other
public init(statusCode: Int) {
switch statusCode {
case 401:
self = .authentication
case 403:
self = .permissionDenied
case 404:
self = .notFound
case 500 ... 599:
self = .server
default:
self = .other
}
}
}
public enum AuthenticationError: LocalizedError, Sendable, Equatable {
case invalidURL
case invalidCredentials
case otpRequired
case serverNotFound
case invalidResponse
case certificateError
case unknownError(statusCode: Int)
static func from(statusCode: Int, body: String = "") -> AuthenticationError {
switch statusCode {
case 401:
body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials
case 404:
.serverNotFound
default:
.unknownError(statusCode: statusCode)
}
}
public var httpStatusCode: Int? {
switch self {
case .invalidCredentials, .otpRequired:
401
case .serverNotFound:
404
case let .unknownError(statusCode):
statusCode
case .invalidURL, .invalidResponse, .certificateError:
nil
}
}
public var httpErrorCategory: HTTPErrorCategory? {
guard let httpStatusCode else {
return nil
}
return HTTPErrorCategory(statusCode: httpStatusCode)
}
public var errorDescription: String? {
switch self {
case .invalidURL:
"Invalid server URL"
case .invalidCredentials:
"Invalid username or password"
case .otpRequired:
"Two-factor authentication code required"
case .serverNotFound:
"Server not found. Please check the URL."
case .invalidResponse:
"Invalid response from server"
case .certificateError:
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
case let .unknownError(statusCode):
switch HTTPErrorCategory(statusCode: statusCode) {
case .permissionDenied:
"Permission denied (Status: \(statusCode))"
case .server:
"Server error occurred (Status: \(statusCode))"
case .authentication, .notFound, .other:
"Unknown error occurred (Status: \(statusCode))"
}
}
}
}
public enum ServiceError: LocalizedError, Sendable, Equatable {
case noActiveInstance
case invalidURL
case invalidResponse
case httpError(statusCode: Int, message: String = "")
case notMergeable
case mergeConflict
case decodingFailed(detail: String)
public var httpStatusCode: Int? {
guard case let .httpError(statusCode, _) = self else {
return nil
}
return statusCode
}
public var httpErrorCategory: HTTPErrorCategory? {
guard let httpStatusCode else {
return nil
}
return HTTPErrorCategory(statusCode: httpStatusCode)
}
public var errorDescription: String? {
switch self {
case .noActiveInstance:
"No active Forgejo instance"
case .invalidURL:
"Invalid URL"
case .invalidResponse:
"Invalid response from server"
case let .httpError(statusCode, message):
if let message = Self.normalizedHTTPMessage(message) {
"HTTP \(statusCode): \(message)"
} else {
"HTTP error: \(statusCode)"
}
case .notMergeable:
"This pull request cannot be merged"
case .mergeConflict:
"There are merge conflicts that must be resolved"
case let .decodingFailed(detail):
"Decoding failed: \(detail)"
}
}
private struct ForgejoErrorBody: Decodable {
let message: String?
}
static func normalizedHTTPMessage(_ message: String) -> String? {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return nil
}
guard
let data = trimmed.data(using: .utf8),
let body = try? JSONDecoder().decode(ForgejoErrorBody.self, from: data)
else {
return trimmed
}
guard let jsonMessage = body.message else {
return trimmed
}
let trimmedJSONMessage = jsonMessage.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedJSONMessage.isEmpty ? nil : trimmedJSONMessage
}
}