// // Copyright 2026 Element Creations Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Foundation /// The client for the HealthChat backend (member directory, own profile, invitations). /// Authenticates with an OpenID token that the backend verifies against the homeserver. final class HealthChatAPIService: HealthChatAPIServiceProtocol { private enum Method: String { case get = "GET" case put = "PUT" case post = "POST" } private let baseURL: URL private let tokenProvider: HealthChatOpenIDTokenProviderProtocol private let urlSession: URLSession init(baseURL: URL = HealthChatSettings.apiBaseURL, tokenProvider: HealthChatOpenIDTokenProviderProtocol, urlSession: URLSession = .shared) { self.baseURL = baseURL self.tokenProvider = tokenProvider self.urlSession = urlSession } func members(searchQuery: String?) async -> Result { var queryItems = [URLQueryItem]() if let searchQuery, !searchQuery.isEmpty { queryItems.append(.init(name: "search", value: searchQuery)) } return await request(.get, path: "api/usermatrix/users", queryItems: queryItems) } func ownProfile() async -> Result { await request(.get, path: "api/usermatrix/profile") } func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result { guard let body = try? JSONEncoder().encode(update) else { return .failure(.decodingFailure) } return await send(.put, path: "api/usermatrix/profile", body: body).map { _ in } } func profileCompleteness() async -> Result { await request(.get, path: "api/usermatrix/profile/completeness") } func inviteMember(email: String, personalNote: String) async -> Result { struct InvitationRequest: Encodable { let email: String let personalNote: String } guard let body = try? JSONEncoder().encode(InvitationRequest(email: email, personalNote: personalNote)) else { return .failure(.decodingFailure) } return await send(.post, path: "api/usermatrix/invite", body: body).map { _ in } } // MARK: - Private private func request(_ method: Method, path: String, queryItems: [URLQueryItem] = [], body: Data? = nil) async -> Result { switch await send(method, path: path, queryItems: queryItems, body: body) { case .success(let data): do { return try .success(JSONDecoder().decode(Response.self, from: data)) } catch { MXLog.error("Failed decoding the HealthChat API response from \(path) with error: \(error)") return .failure(.decodingFailure) } case .failure(let error): return .failure(error) } } private func send(_ method: Method, path: String, queryItems: [URLQueryItem] = [], body: Data? = nil) async -> Result { for attempt in 0...1 { let token: String switch await tokenProvider.validToken() { case .success(let validToken): token = validToken case .failure(let error): return .failure(error) } var url = baseURL.appending(path: path) if !queryItems.isEmpty { url.append(queryItems: queryItems) } var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") if let body { request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") } let data: Data let response: URLResponse do { (data, response) = try await urlSession.data(for: request) } catch { MXLog.error("The HealthChat API request to \(path) failed with error: \(error)") return .failure(.networkFailure) } guard let httpResponse = response as? HTTPURLResponse else { return .failure(.networkFailure) } switch httpResponse.statusCode { case 200...299: return .success(data) case 401 where attempt == 0: // The cached OpenID token may have expired server-side — retry once with a fresh one. tokenProvider.invalidateToken() case 401: return .failure(.unauthorized) default: MXLog.error("The HealthChat API request to \(path) failed with status: \(httpResponse.statusCode)") return .failure(.httpStatus(httpResponse.statusCode)) } } return .failure(.unauthorized) } }