mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
Add HealthChat API and extended profile services (#9)
Expose OpenID tokens and homeserver credentials from the client proxy, and add fork-only services for the HealthChat member directory backend (api.healthchat.ch) plus a service that reads MSC4133 extended profile fields directly from the homeserver. Groundwork for the members tab and the profile screens. Claude-Session: https://claude.ai/code/session_013qdTbAWsZj2quQzEkagXaS
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// 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<HealthChatMembersResponse, HealthChatAPIError> {
|
||||
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<HealthChatMember, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile")
|
||||
}
|
||||
|
||||
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError> {
|
||||
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<HealthChatProfileCompleteness, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile/completeness")
|
||||
}
|
||||
|
||||
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError> {
|
||||
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<Response: Decodable>(_ method: Method,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Data? = nil) async -> Result<Response, HealthChatAPIError> {
|
||||
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<Data, HealthChatAPIError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user