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:
2026-09-22 16:56:20 +02:00
committed by Sedat Qaja
parent ea07f76c60
commit e5bfc3a26d
19 changed files with 1807 additions and 0 deletions
@@ -871,6 +871,28 @@ class ClientProxy: ClientProxyProtocol {
}
}
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError> {
do {
return try await .success(.init(rustValue: client.requestOpenidToken()))
} catch {
MXLog.error("Failed requesting OpenID token with error: \(error)")
return .failure(.sdkError(error))
}
}
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError> {
do {
let session = try client.session()
guard let homeserverURL = URL(string: session.homeserverUrl) else {
return .failure(.invalidServerName)
}
return .success(.init(homeserverURL: homeserverURL, accessToken: session.accessToken))
} catch {
MXLog.error("Failed retrieving the session's credentials with error: \(error)")
return .failure(.sdkError(error))
}
}
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol {
RoomDirectorySearchProxy(roomDirectorySearch: client.roomDirectorySearch())
}
@@ -261,6 +261,14 @@ protocol ClientProxyProtocol: AnyObject {
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError>
/// An OpenID token for authenticating against services that trust the homeserver (MSC1961).
/// Short-lived — cache per call site.
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError>
/// The session's REST credentials for client-server API calls the SDK doesn't cover.
/// The access token can rotate after a refresh, so re-read for every request.
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError>
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol
func resolveRoomAlias(_ alias: String) async -> Result<ResolvedRoomAlias, ClientProxyError>
@@ -0,0 +1,21 @@
//
// 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 session's REST credentials for direct client-server API calls that the SDK doesn't cover.
/// The access token can rotate after a refresh, so re-read these for every request and never persist them.
nonisolated struct HomeserverCredentials: Equatable {
let homeserverURL: URL
let accessToken: String
}
extension HomeserverCredentials: CustomStringConvertible {
var description: String {
"HomeserverCredentials(homeserverURL: \(homeserverURL), accessToken: <redacted>)"
}
}
@@ -0,0 +1,36 @@
//
// 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
import MatrixRustSDK
/// An OpenID token that third-party services can use to verify the user's identity
/// against the homeserver (MSC1961). Short-lived — cache per call site.
nonisolated struct OpenIDToken: Equatable {
let accessToken: String
let matrixServerName: String
/// The interval after which the token expires, relative to when it was requested.
let expiresIn: TimeInterval
init(accessToken: String, matrixServerName: String, expiresIn: TimeInterval) {
self.accessToken = accessToken
self.matrixServerName = matrixServerName
self.expiresIn = expiresIn
}
init(rustValue: MatrixRustSDK.OpenIdToken) {
accessToken = rustValue.accessToken
matrixServerName = rustValue.matrixServerName
expiresIn = TimeInterval(rustValue.expiresInSeconds)
}
}
extension OpenIDToken: CustomStringConvertible {
var description: String {
"OpenIDToken(matrixServerName: \(matrixServerName), accessToken: <redacted>)"
}
}
@@ -0,0 +1,92 @@
//
// 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
/// A user's profile including the HealthChat extended profile fields (MSC4133),
/// as returned by `GET /_matrix/client/v3/profile/{userId}`.
nonisolated struct ExtendedProfile: Equatable {
let displayName: String?
let avatarURL: URL?
let title: String?
let firstName: String?
let lastName: String?
let specialty: String?
let practiceName: String?
let furtherInfo: String?
let businessEmail: String?
let businessTel: String?
let street: String?
let houseNumber: String?
let postalCode: String?
let city: String?
let website: String?
/// Whether any of the HealthChat-specific fields carry a value.
var hasContent: Bool {
[title, firstName, lastName, specialty, practiceName, furtherInfo,
businessEmail, businessTel, street, houseNumber, postalCode, city, website]
.contains { $0 != nil }
}
/// The postal address in the same "street nr, plz city" format the web client uses.
var formattedAddress: String? {
let streetLine = [street, houseNumber].compactMap { $0 }.joined(separator: " ")
let cityLine = [postalCode, city].compactMap { $0 }.joined(separator: " ")
let lines = [streetLine, cityLine].filter { !$0.isEmpty }
return lines.isEmpty ? nil : lines.joined(separator: ", ")
}
}
extension ExtendedProfile: Decodable {
private struct StringKey: CodingKey {
let stringValue: String
var intValue: Int? {
nil
}
init(stringValue: String) {
self.stringValue = stringValue
}
init?(intValue: Int) {
nil
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: StringKey.self)
// Presence of the canonical key suppresses the legacy key even when its value is
// empty, so a field the user cleared isn't resurrected by stale legacy data.
// Empty and non-string values read as nil.
func value(_ canonicalKey: String, legacyKey: String? = nil) -> String? {
for key in [canonicalKey, legacyKey].compactMap({ $0 }) {
guard container.contains(StringKey(stringValue: key)) else { continue }
let raw = try? container.decode(String.self, forKey: .init(stringValue: key))
return raw.flatMap { $0.isEmpty ? nil : $0 }
}
return nil
}
displayName = value("displayname")
avatarURL = value("avatar_url").flatMap(URL.init(string:))
title = value("io.element.profile.title")
firstName = value("io.element.profile.first_name")
lastName = value("io.element.profile.last_name")
specialty = value("io.element.profile.specialization")
practiceName = value("io.element.profile.practice_name")
furtherInfo = value("io.element.profile.further_info")
businessEmail = value("io.element.profile.business_email")
businessTel = value("io.element.profile.business_tel")
street = value("io.element.profile.business_street", legacyKey: "io.element.profile.street")
houseNumber = value("io.element.profile.business_street_nr", legacyKey: "io.element.profile.house_number")
postalCode = value("io.element.profile.business_plz", legacyKey: "io.element.profile.postal_code")
city = value("io.element.profile.business_city")
website = value("io.element.profile.website")
}
}
@@ -0,0 +1,55 @@
//
// 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
/// Reads profiles straight from the homeserver's profile endpoint, which — unlike the
/// Rust SDK's `getProfile` — includes the extended profile fields (MSC4133).
final class ExtendedProfileService: ExtendedProfileServiceProtocol {
private let clientProxy: ClientProxyProtocol
private let urlSession: URLSession
init(clientProxy: ClientProxyProtocol, urlSession: URLSession = .shared) {
self.clientProxy = clientProxy
self.urlSession = urlSession
}
func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError> {
guard case let .success(credentials) = clientProxy.homeserverCredentials() else {
return .failure(.missingCredentials)
}
let url = credentials.homeserverURL.appending(path: "_matrix/client/v3/profile/\(userID)")
var request = URLRequest(url: url)
request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization")
let data: Data
do {
let (responseData, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return .failure(.networkFailure)
}
guard httpResponse.statusCode == 200 else {
MXLog.error("Failed fetching the extended profile for \(userID) with status: \(httpResponse.statusCode)")
return .failure(.httpStatus(httpResponse.statusCode))
}
data = responseData
} catch {
MXLog.error("Failed fetching the extended profile for \(userID) with error: \(error)")
return .failure(.networkFailure)
}
do {
return try .success(JSONDecoder().decode(ExtendedProfile.self, from: data))
} catch {
MXLog.error("Failed decoding the extended profile for \(userID) with error: \(error)")
return .failure(.decodingFailure)
}
}
}
@@ -0,0 +1,20 @@
//
// 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
enum ExtendedProfileServiceError: Error, Equatable {
case missingCredentials
case networkFailure
case decodingFailure
case httpStatus(Int)
}
// sourcery: AutoMockable
protocol ExtendedProfileServiceProtocol {
func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError>
}
@@ -0,0 +1,134 @@
//
// 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
/// A member record as returned by the HealthChat API (`MatrixUser` in the backend).
/// Every field except the user ID is optional so additive backend changes never break decoding.
nonisolated struct HealthChatMember: Identifiable, Equatable {
let userID: String
let displayName: String?
let avatarURL: URL?
let title: String?
let firstName: String?
let lastName: String?
let specialty: String?
let practiceName: String?
let businessCity: String?
let businessEmail: String?
let businessTel: String?
let furtherInfo: String?
let street: String?
let houseNumber: String?
let postalCode: String?
let website: String?
let isApproved: Bool
var id: String {
userID
}
}
extension HealthChatMember: Decodable {
private enum CodingKeys: String, CodingKey {
case userID = "userId"
case avatarURL = "avatarMxcUrl"
case displayName, title, firstName, lastName, specialty, practiceName
case businessCity, businessEmail, businessTel, furtherInfo
case street, houseNumber, postalCode, website, isApproved
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
func value(_ key: CodingKeys) -> String? {
let raw = try? container.decodeIfPresent(String.self, forKey: key)
return (raw ?? nil).flatMap { $0.isEmpty ? nil : $0 }
}
userID = try container.decode(String.self, forKey: .userID)
displayName = value(.displayName)
avatarURL = value(.avatarURL).flatMap(URL.init(string:))
title = value(.title)
firstName = value(.firstName)
lastName = value(.lastName)
specialty = value(.specialty)
practiceName = value(.practiceName)
businessCity = value(.businessCity)
businessEmail = value(.businessEmail)
businessTel = value(.businessTel)
furtherInfo = value(.furtherInfo)
street = value(.street)
houseNumber = value(.houseNumber)
postalCode = value(.postalCode)
website = value(.website)
isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true
}
}
/// The response of `GET /api/usermatrix/users` (`MatrixUserSearchResult` in the backend).
nonisolated struct HealthChatMembersResponse: Equatable {
let users: [HealthChatMember]
let isApproved: Bool
/// The hint shown to accounts that haven't been approved yet.
let pendingMessage: String?
}
extension HealthChatMembersResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case users, isApproved, pendingMessage
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
users = try container.decodeIfPresent([HealthChatMember].self, forKey: .users) ?? []
isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true
let message = try container.decodeIfPresent(String.self, forKey: .pendingMessage)
pendingMessage = message.flatMap { $0.isEmpty ? nil : $0 }
}
}
/// A partial profile update for `PUT /api/usermatrix/profile` (`UpdateProfileRequest` in the backend).
/// `nil` leaves a field unchanged, an empty string clears it.
nonisolated struct HealthChatProfileUpdate: Encodable, Equatable {
var firstName: String?
var lastName: String?
var title: String?
var specialty: String?
var practiceName: String?
var businessCity: String?
var businessEmail: String?
var businessTel: String?
var furtherInfo: String?
var street: String?
var houseNumber: String?
var postalCode: String?
var website: String?
}
/// The response of `GET /api/usermatrix/profile/completeness`.
nonisolated struct HealthChatProfileCompleteness: Equatable {
let isComplete: Bool
let missingFields: [String]
let message: String?
let hasProfilePhoto: Bool
}
extension HealthChatProfileCompleteness: Decodable {
private enum CodingKeys: String, CodingKey {
case isComplete, missingFields, message, hasProfilePhoto
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false
missingFields = try container.decodeIfPresent([String].self, forKey: .missingFields) ?? []
let rawMessage = try container.decodeIfPresent(String.self, forKey: .message)
message = rawMessage.flatMap { $0.isEmpty ? nil : $0 }
hasProfilePhoto = try container.decodeIfPresent(Bool.self, forKey: .hasProfilePhoto) ?? false
}
}
@@ -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)
}
}
@@ -0,0 +1,29 @@
//
// 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
enum HealthChatAPIError: Error, Equatable {
case tokenAcquisitionFailed
case unauthorized
case networkFailure
case decodingFailure
case httpStatus(Int)
}
// sourcery: AutoMockable
protocol HealthChatAPIServiceProtocol {
func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError>
func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError>
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError>
func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError>
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError>
}
@@ -0,0 +1,50 @@
//
// 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
// sourcery: AutoMockable
protocol HealthChatOpenIDTokenProviderProtocol {
/// A valid OpenID access token, from cache when possible.
func validToken() async -> Result<String, HealthChatAPIError>
/// Drops the cached token, e.g. after the API rejected it.
func invalidateToken()
}
final class HealthChatOpenIDTokenProvider: HealthChatOpenIDTokenProviderProtocol {
private let clientProxy: ClientProxyProtocol
private let dateProvider: () -> Date
/// Tokens this close to expiry are refreshed early, matching the web client's buffer.
private static let expiryBuffer: TimeInterval = 60
private var cachedToken: (token: String, expiry: Date)?
init(clientProxy: ClientProxyProtocol, dateProvider: @escaping () -> Date = Date.init) {
self.clientProxy = clientProxy
self.dateProvider = dateProvider
}
func validToken() async -> Result<String, HealthChatAPIError> {
if let cachedToken, dateProvider().addingTimeInterval(Self.expiryBuffer) < cachedToken.expiry {
return .success(cachedToken.token)
}
switch await clientProxy.requestOpenIDToken() {
case .success(let token):
cachedToken = (token.accessToken, dateProvider().addingTimeInterval(token.expiresIn))
return .success(token.accessToken)
case .failure:
return .failure(.tokenAcquisitionFailed)
}
}
func invalidateToken() {
cachedToken = nil
}
}