feat(outlinekit): add apiKeys.list and installation.info plumbing
Read-only for now, per instruction to defer key creation/revocation. Both confirmed against live network captures.
This commit is contained in:
@@ -375,6 +375,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await live.listApiKeys(request)
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await live.installationInfo()
|
||||
}
|
||||
|
||||
// MARK: - Sync management (Settings surface)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
|
||||
@@ -94,4 +94,11 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
|
||||
/// Settings → API & Access. Creation/revocation aren't wrapped yet —
|
||||
/// read-only for this pass.
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
|
||||
|
||||
/// Settings → Installation. Self-hosted server version info.
|
||||
func installationInfo() async throws -> OutlineInstallationInfo
|
||||
}
|
||||
|
||||
@@ -310,6 +310,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await post("apiKeys.list", body: request)
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await post("installation.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// A personal API key (Settings → API & Access). Only the last 4 characters
|
||||
/// of the actual token are ever returned by the server — there's no way to
|
||||
/// see a full key again after creation, matching every other API-key UI
|
||||
/// convention.
|
||||
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let last4: String?
|
||||
public let scope: [String]?
|
||||
public let createdAt: Date
|
||||
public let expiresAt: Date?
|
||||
public let lastActiveAt: Date?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
last4: String? = nil,
|
||||
scope: [String]? = nil,
|
||||
createdAt: Date,
|
||||
expiresAt: Date? = nil,
|
||||
lastActiveAt: Date? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.last4 = last4
|
||||
self.scope = scope
|
||||
self.createdAt = createdAt
|
||||
self.expiresAt = expiresAt
|
||||
self.lastActiveAt = lastActiveAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// `installation.info` — the self-hosted server's own version, confirmed
|
||||
/// live. `policies` (a separate top-level array alongside `data` in the raw
|
||||
/// response) isn't modeled here — not used by this app's Installation
|
||||
/// settings page.
|
||||
public struct OutlineInstallationInfo: Decodable, Sendable {
|
||||
public let version: String
|
||||
public let latestVersion: String
|
||||
public let versionsBehind: Int
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// `apiKeys.list` — matches every other paginated `.list` endpoint's flat
|
||||
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
|
||||
/// `pagination` object that only appears in list *responses*.
|
||||
public struct ListApiKeysRequest: Encodable, Sendable {
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
|
||||
public init(offset: Int = 0, limit: Int = 25) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func deleteAccount() async throws { throw NotStubbed() }
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||
}
|
||||
|
||||
private struct StubTransportError: Error {}
|
||||
|
||||
@@ -1229,6 +1229,63 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertNil(sentBody?["eventType"])
|
||||
}
|
||||
|
||||
func testListApiKeysDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"pagination": { "limit": 25, "offset": 0 },
|
||||
"data": [
|
||||
{
|
||||
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
|
||||
"name": "Outpost",
|
||||
"scope": null,
|
||||
"last4": "dl1h",
|
||||
"createdAt": "2026-08-12T18:44:32.467Z",
|
||||
"updatedAt": "2026-08-12T18:44:32.467Z",
|
||||
"expiresAt": null,
|
||||
"lastActiveAt": "2026-08-18T01:01:36.553Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let keys = try await client.listApiKeys(ListApiKeysRequest())
|
||||
|
||||
XCTAssertEqual(keys.count, 1)
|
||||
XCTAssertEqual(keys.first?.name, "Outpost")
|
||||
XCTAssertEqual(keys.first?.last4, "dl1h")
|
||||
XCTAssertNil(keys.first?.expiresAt)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
|
||||
}
|
||||
|
||||
func testInstallationInfoDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
|
||||
"policies": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let info = try await client.installationInfo()
|
||||
|
||||
XCTAssertEqual(info.version, "1.9.2")
|
||||
XCTAssertEqual(info.versionsBehind, 0)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
|
||||
}
|
||||
|
||||
func testDeleteAccountSendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
|
||||
Reference in New Issue
Block a user