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:
@@ -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 = """
|
||||||
|
|||||||
Reference in New Issue
Block a user