feat(outlinekit): add apiKeys.create/delete

value is only ever present in the create response (confirmed live —
apiKeys.list never includes it), so the model and call sites treat it
as a one-time reveal, not persisted state. expiresAt omitted (not null)
for a non-expiring key, matches synthesized Encodable's default
encodeIfPresent behavior.
This commit is contained in:
2026-08-18 16:45:18 +01:00
parent fa22ac616b
commit 044a706bd4
7 changed files with 136 additions and 3 deletions
@@ -379,6 +379,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.listApiKeys(request) try await live.listApiKeys(request)
} }
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await live.createApiKey(request)
}
public func deleteApiKey(id: String) async throws {
try await live.deleteApiKey(id: id)
}
public func installationInfo() async throws -> OutlineInstallationInfo { public func installationInfo() async throws -> OutlineInstallationInfo {
try await live.installationInfo() try await live.installationInfo()
} }
@@ -95,9 +95,13 @@ public protocol OutlineAPIClient: Sendable {
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
/// Settings API & Access. Creation/revocation aren't wrapped yet /// Settings API & Access.
/// read-only for this pass.
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
/// The returned `OutlineAPIKey.value` is the only time the full
/// plaintext key is ever available the caller is responsible for
/// displaying it once and then discarding it.
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
func deleteApiKey(id: String) async throws
/// Settings Installation. Self-hosted server version info. /// Settings Installation. Self-hosted server version info.
func installationInfo() async throws -> OutlineInstallationInfo func installationInfo() async throws -> OutlineInstallationInfo
@@ -314,6 +314,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("apiKeys.list", body: request) try await post("apiKeys.list", body: request)
} }
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await post("apiKeys.create", body: request)
}
public func deleteApiKey(id: String) async throws {
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
}
public func installationInfo() async throws -> OutlineInstallationInfo { public func installationInfo() async throws -> OutlineInstallationInfo {
try await post("installation.info", body: EmptyParams()) try await post("installation.info", body: EmptyParams())
} }
@@ -12,6 +12,11 @@ public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
public let createdAt: Date public let createdAt: Date
public let expiresAt: Date? public let expiresAt: Date?
public let lastActiveAt: Date? public let lastActiveAt: Date?
/// The full plaintext key present *only* in `apiKeys.create`'s
/// response, confirmed live: `apiKeys.list` never includes it, matching
/// "shown once at creation" being enforced server-side, not just a
/// client-side UI convention this app has to uphold on its own.
public let value: String?
public init( public init(
id: String, id: String,
@@ -20,7 +25,8 @@ public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
scope: [String]? = nil, scope: [String]? = nil,
createdAt: Date, createdAt: Date,
expiresAt: Date? = nil, expiresAt: Date? = nil,
lastActiveAt: Date? = nil lastActiveAt: Date? = nil,
value: String? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -29,5 +35,6 @@ public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
self.createdAt = createdAt self.createdAt = createdAt
self.expiresAt = expiresAt self.expiresAt = expiresAt
self.lastActiveAt = lastActiveAt self.lastActiveAt = lastActiveAt
self.value = value
} }
} }
@@ -0,0 +1,21 @@
import Foundation
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
/// literal `null`) confirmed live to mean no expiration Swift's
/// synthesized `Encodable` already omits `nil` optionals via
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
/// `UpdateUserAvatarRequest` needed one for the opposite case.
public struct CreateApiKeyRequest: Encodable, Sendable {
public let name: String
public let expiresAt: Date?
/// `nil`/omitted grants full access confirmed live (every key created
/// without a scope came back with unrestricted access). A specific
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
public let scope: [String]?
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
self.name = name
self.expiresAt = expiresAt
self.scope = scope
}
}
@@ -100,6 +100,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() } func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
func unsubscribeFromNotifications(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 listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
func deleteApiKey(id: String) async throws { throw NotStubbed() }
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() } func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
} }
@@ -1286,6 +1286,89 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
} }
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
"name": "test",
"scope": null,
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
"last4": "0DGv",
"createdAt": "2026-08-18T15:39:29.872Z",
"expiresAt": null,
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
XCTAssertNil(key.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["name"] as? String, "test")
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
XCTAssertNil(sentBody?["scope"])
}
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
"name": "Test",
"scope": null,
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
"last4": "cLts",
"createdAt": "2026-08-18T15:38:22.928Z",
"expiresAt": "2026-11-16T23:59:59.999Z",
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNotNil(sentBody?["expiresAt"])
}
func testDeleteApiKeySendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
}
func testDeleteAccountSendsRequest() async throws { func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """