feat(outlinekit): add REST client package for Outline API

Local SPM package wrapping Outline's RPC-style REST API — auth,
documents, collections, and search — behind a protocol boundary
(OutlineAPIClient) so the app never depends on the transport directly.
This commit is contained in:
2026-08-14 00:23:12 +01:00
parent 03a624a523
commit 07fb08cb6b
22 changed files with 875 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.build/
.swiftpm/
.DS_Store
+22
View File
@@ -0,0 +1,22 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "OutlineKit",
platforms: [
.iOS(.v17),
.macOS(.v14)
],
products: [
.library(name: "OutlineKit", targets: ["OutlineKit"])
],
targets: [
.target(
name: "OutlineKit"
),
.testTarget(
name: "OutlineKitTests",
dependencies: ["OutlineKit"]
)
]
)
@@ -0,0 +1,56 @@
import Foundation
import Security
public final class KeychainTokenStore: TokenStoring, @unchecked Sendable {
private let service: String
private let account: String
public init(service: String = "com.outpost.outlinekit", account: String = "outline-api-token") {
self.service = service
self.account = account
}
public func token() throws -> String {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else {
throw TokenStoreError.notFound
}
return token
}
public func store(_ token: String) throws {
let data = Data(token.utf8)
let query = baseQuery()
let existsStatus = SecItemCopyMatching(query as CFDictionary, nil)
if existsStatus == errSecSuccess {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) }
} else {
var addQuery = query
addQuery[kSecValueData as String] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) }
}
}
public func clear() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw TokenStoreError.deleteFailed(status)
}
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
}
}
@@ -0,0 +1,14 @@
import Foundation
/// Boundary over wherever the API bearer token lives. Tokens never touch UserDefaults or logs.
public protocol TokenStoring: Sendable {
func token() throws -> String
func store(_ token: String) throws
func clear() throws
}
public enum TokenStoreError: Error, Sendable {
case notFound
case storeFailed(OSStatus)
case deleteFailed(OSStatus)
}
@@ -0,0 +1,24 @@
import Foundation
/// REST-layer boundary. The CRDT/sync layer and editor UI layer depend only on this
/// protocol, never on `LiveOutlineAPIClient`, so the transport can be swapped or mocked
/// without touching callers.
public protocol OutlineAPIClient: Sendable {
/// Validates the current token and identifies the signed-in user/workspace. Backed by `auth.info`.
func authInfo() async throws -> OutlineAuthInfo
func documentInfo(id: String) async throws -> OutlineDocument
func listDocuments(collectionId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument]
/// Full-text search with snippets/ranking. Backed by `documents.search`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// Title-only search faster, no snippets. Backed by `documents.search_titles`.
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument]
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
func collectionInfo(id: String) async throws -> OutlineCollection
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
func currentUser() async throws -> OutlineUser
}
@@ -0,0 +1,10 @@
import Foundation
public enum OutlineAPIError: Error, Sendable {
case unauthorized
case notFound
case server(status: Int, message: String?)
case decoding(Error)
case transport(Error)
case tokenUnavailable
}
@@ -0,0 +1,10 @@
import Foundation
/// Points at one self-hosted Outline instance's API root, e.g. `https://outline.example.com`.
public struct OutlineConfiguration: Sendable {
public let baseURL: URL
public init(baseURL: URL) {
self.baseURL = baseURL
}
}
@@ -0,0 +1,136 @@
import Foundation
/// Talks to Outline's RPC-style REST API (`POST /api/<namespace>.<method>`, Bearer auth).
public actor LiveOutlineAPIClient: OutlineAPIClient {
private let baseURL: URL
private let httpClient: HTTPClient
private let tokenStore: TokenStoring
private let decoder: JSONDecoder
private let encoder: JSONEncoder
public init(
configuration: OutlineConfiguration,
tokenStore: TokenStoring,
httpClient: HTTPClient = URLSessionHTTPClient()
) {
self.baseURL = configuration.baseURL
self.tokenStore = tokenStore
self.httpClient = httpClient
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
self.encoder = encoder
}
public func authInfo() async throws -> OutlineAuthInfo {
try await post("auth.info", body: EmptyParams())
}
public func documentInfo(id: String) async throws -> OutlineDocument {
try await post("documents.info", body: DocumentInfoParams(id: id))
}
public func listDocuments(collectionId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await post(
"documents.list",
body: DocumentListParams(collectionId: collectionId, offset: offset, limit: limit)
)
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request)
}
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
try await post("documents.search_titles", body: request)
}
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
try await post("documents.create", body: request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
try await post("documents.update", body: request)
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
}
public func collectionInfo(id: String) async throws -> OutlineCollection {
try await post("collections.info", body: CollectionInfoParams(id: id))
}
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
try await post("collections.update", body: request)
}
public func currentUser() async throws -> OutlineUser {
try await post("users.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
}
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try encoder.encode(body)
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
throw OutlineAPIError.notFound
default:
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
do {
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
} catch {
throw OutlineAPIError.decoding(error)
}
}
}
private struct DocumentInfoParams: Encodable {
let id: String
}
private struct DocumentListParams: Encodable {
let collectionId: String?
let offset: Int
let limit: Int
}
private struct CollectionListParams: Encodable {
let offset: Int
let limit: Int
}
private struct CollectionInfoParams: Encodable {
let id: String
}
private struct EmptyParams: Encodable {}
@@ -0,0 +1,36 @@
import Foundation
public enum DocumentSearchDateFilter: String, Codable, Sendable, CaseIterable {
case day
case week
case month
case year
}
public enum DocumentSearchSort: String, Codable, Sendable, CaseIterable {
case relevance
case createdAt
case updatedAt
case title
}
public enum DocumentSearchDirection: String, Codable, Sendable, CaseIterable {
case ascending = "ASC"
case descending = "DESC"
}
public enum DocumentStatusFilter: String, Codable, Sendable, CaseIterable {
case draft
case archived
case published
}
/// One hit from `documents.search` includes the matched snippet and ranking
/// that `documents.search_titles` doesn't provide.
public struct OutlineDocumentSearchResult: Decodable, Sendable, Identifiable {
public let context: String
public let ranking: Double
public let document: OutlineDocument
public var id: String { document.id }
}
@@ -0,0 +1,12 @@
import Foundation
/// Result of `auth.info` confirms a token is valid and identifies the signed-in user/workspace.
public struct OutlineAuthInfo: Codable, Sendable {
public let user: OutlineUser
public let team: OutlineTeam
public init(user: OutlineUser, team: OutlineTeam) {
self.user = user
self.team = team
}
}
@@ -0,0 +1,29 @@
import Foundation
public struct OutlineCollection: Codable, Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let description: String?
public let color: String?
public let icon: String?
public let createdAt: Date
public let updatedAt: Date
public init(
id: String,
name: String,
description: String? = nil,
color: String? = nil,
icon: String? = nil,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.name = name
self.description = description
self.color = color
self.icon = icon
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
@@ -0,0 +1,47 @@
import Foundation
public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable {
public let id: String
public let title: String
public let text: String
public let emoji: String?
public let collectionId: String?
public let parentDocumentId: String?
public let url: String
public let revision: Int?
public let createdAt: Date
public let updatedAt: Date
public let publishedAt: Date?
public let archivedAt: Date?
public let deletedAt: Date?
public init(
id: String,
title: String,
text: String,
emoji: String? = nil,
collectionId: String? = nil,
parentDocumentId: String? = nil,
url: String,
revision: Int? = nil,
createdAt: Date,
updatedAt: Date,
publishedAt: Date? = nil,
archivedAt: Date? = nil,
deletedAt: Date? = nil
) {
self.id = id
self.title = title
self.text = text
self.emoji = emoji
self.collectionId = collectionId
self.parentDocumentId = parentDocumentId
self.url = url
self.revision = revision
self.createdAt = createdAt
self.updatedAt = updatedAt
self.publishedAt = publishedAt
self.archivedAt = archivedAt
self.deletedAt = deletedAt
}
}
@@ -0,0 +1,20 @@
import Foundation
/// Outline wraps every successful RPC response as `{ "data": ..., "pagination": ... }`.
struct OutlineEnvelope<T: Decodable>: Decodable {
let data: T
let pagination: OutlinePagination?
}
public struct OutlinePagination: Decodable, Sendable {
public let offset: Int
public let limit: Int
public let nextPath: String?
}
/// Outline error responses: `{ "ok": false, "error": "...", "message": "..." }`.
struct OutlineErrorEnvelope: Decodable {
let ok: Bool
let error: String?
let message: String?
}
@@ -0,0 +1,26 @@
import Foundation
public struct OutlineTeam: Codable, Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let avatarUrl: String?
public let url: String?
public let subdomain: String?
public let domain: String?
public init(
id: String,
name: String,
avatarUrl: String? = nil,
url: String? = nil,
subdomain: String? = nil,
domain: String? = nil
) {
self.id = id
self.name = name
self.avatarUrl = avatarUrl
self.url = url
self.subdomain = subdomain
self.domain = domain
}
}
@@ -0,0 +1,23 @@
import Foundation
public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let email: String?
public let avatarUrl: String?
public let role: String?
public init(
id: String,
name: String,
email: String? = nil,
avatarUrl: String? = nil,
role: String? = nil
) {
self.id = id
self.name = name
self.email = email
self.avatarUrl = avatarUrl
self.role = role
}
}
@@ -0,0 +1,22 @@
import Foundation
/// Thin seam over `URLSession` so `LiveOutlineAPIClient` can be tested without network access.
public protocol HTTPClient: Sendable {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
public final class URLSessionHTTPClient: HTTPClient, Sendable {
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw OutlineAPIError.transport(URLError(.badServerResponse))
}
return (data, httpResponse)
}
}
@@ -0,0 +1,23 @@
import Foundation
public struct CreateDocumentRequest: Encodable, Sendable {
public let title: String
public let text: String
public let collectionId: String
public let parentDocumentId: String?
public let publish: Bool
public init(
title: String,
text: String,
collectionId: String,
parentDocumentId: String? = nil,
publish: Bool = true
) {
self.title = title
self.text = text
self.collectionId = collectionId
self.parentDocumentId = parentDocumentId
self.publish = publish
}
}
@@ -0,0 +1,45 @@
import Foundation
/// Full-text search across a workspace, backed by `documents.search`.
public struct DocumentSearchRequest: Encodable, Sendable {
public let query: String
public let collectionId: String?
public let documentId: String?
public let userId: String?
public let statusFilter: [DocumentStatusFilter]?
public let dateFilter: DocumentSearchDateFilter?
public let sort: DocumentSearchSort?
public let direction: DocumentSearchDirection?
public let offset: Int
public let limit: Int
public let snippetMinWords: Int?
public let snippetMaxWords: Int?
public init(
query: String,
collectionId: String? = nil,
documentId: String? = nil,
userId: String? = nil,
statusFilter: [DocumentStatusFilter]? = nil,
dateFilter: DocumentSearchDateFilter? = nil,
sort: DocumentSearchSort? = nil,
direction: DocumentSearchDirection? = nil,
offset: Int = 0,
limit: Int = 25,
snippetMinWords: Int? = nil,
snippetMaxWords: Int? = nil
) {
self.query = query
self.collectionId = collectionId
self.documentId = documentId
self.userId = userId
self.statusFilter = statusFilter
self.dateFilter = dateFilter
self.sort = sort
self.direction = direction
self.offset = offset
self.limit = limit
self.snippetMinWords = snippetMinWords
self.snippetMaxWords = snippetMaxWords
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Fast title-only search, backed by `documents.search_titles`.
public struct DocumentSearchTitlesRequest: Encodable, Sendable {
public let query: String
public let collectionId: String?
public let documentId: String?
public let userId: String?
public let statusFilter: [DocumentStatusFilter]?
public let dateFilter: DocumentSearchDateFilter?
public let sort: DocumentSearchSort?
public let direction: DocumentSearchDirection?
public let offset: Int
public let limit: Int
public init(
query: String,
collectionId: String? = nil,
documentId: String? = nil,
userId: String? = nil,
statusFilter: [DocumentStatusFilter]? = nil,
dateFilter: DocumentSearchDateFilter? = nil,
sort: DocumentSearchSort? = nil,
direction: DocumentSearchDirection? = nil,
offset: Int = 0,
limit: Int = 25
) {
self.query = query
self.collectionId = collectionId
self.documentId = documentId
self.userId = userId
self.statusFilter = statusFilter
self.dateFilter = dateFilter
self.sort = sort
self.direction = direction
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,17 @@
import Foundation
public struct UpdateCollectionRequest: Encodable, Sendable {
public let id: String
public let name: String?
public let description: String?
public init(
id: String,
name: String? = nil,
description: String? = nil
) {
self.id = id
self.name = name
self.description = description
}
}
@@ -0,0 +1,20 @@
import Foundation
public struct UpdateDocumentRequest: Encodable, Sendable {
public let id: String
public let title: String?
public let text: String?
public let append: Bool?
public init(
id: String,
title: String? = nil,
text: String? = nil,
append: Bool? = nil
) {
self.id = id
self.title = title
self.text = text
self.append = append
}
}
@@ -0,0 +1,241 @@
import XCTest
@testable import OutlineKit
private final class MockHTTPClient: HTTPClient, @unchecked Sendable {
var lastRequest: URLRequest?
var responseData: Data = Data()
var statusCode: Int = 200
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
lastRequest = request
let response = HTTPURLResponse(
url: request.url!,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
return (responseData, response)
}
}
private final class StaticTokenStore: TokenStoring, @unchecked Sendable {
private var stored: String?
init(token: String? = "test-token") {
self.stored = token
}
func token() throws -> String {
guard let stored else { throw TokenStoreError.notFound }
return stored
}
func store(_ token: String) throws { stored = token }
func clear() throws { stored = nil }
}
final class LiveOutlineAPIClientTests: XCTestCase {
func testSearchDocumentsDecodesContextAndRanking() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"context": "At Acme Inc our hiring practices are inclusive",
"ranking": 1.1844109,
"document": {
"id": "doc-1",
"title": "Welcome to Acme Inc",
"text": "",
"url": "/doc/welcome-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let results = try await client.searchDocuments(DocumentSearchRequest(query: "hiring"))
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0].context, "At Acme Inc our hiring practices are inclusive")
XCTAssertEqual(results[0].document.title, "Welcome to Acme Inc")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.search")
}
func testSearchDocumentTitlesReturnsDocumentsDirectly() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "doc-1",
"title": "Welcome to Acme Inc",
"text": "",
"url": "/doc/welcome-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let results = try await client.searchDocumentTitles(DocumentSearchTitlesRequest(query: "Welcome"))
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0].title, "Welcome to Acme Inc")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.search_titles")
}
func testAuthInfoDecodesUserAndTeam() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"user": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "Jane Doe",
"email": "hello@example.com",
"role": "admin"
},
"team": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "Acme Inc",
"url": "https://acme-inc.getoutline.com",
"subdomain": "acme-inc"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let auth = try await client.authInfo()
XCTAssertEqual(auth.user.name, "Jane Doe")
XCTAssertEqual(auth.team.name, "Acme Inc")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/auth.info")
XCTAssertEqual(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token")
}
func testUpdateCollectionSendsDescriptionAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "col-1",
"name": "Engineering",
"description": "Updated overview text.",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let collection = try await client.updateCollection(
UpdateCollectionRequest(id: "col-1", description: "Updated overview text.")
)
XCTAssertEqual(collection.description, "Updated overview text.")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.update")
struct SentBody: Decodable {
let id: String
let description: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.id, "col-1")
XCTAssertEqual(decodedBody.description, "Updated overview text.")
}
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "doc-1",
"title": "Hello",
"text": "World",
"url": "/doc/hello-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let document = try await client.documentInfo(id: "doc-1")
XCTAssertEqual(document.id, "doc-1")
XCTAssertEqual(document.title, "Hello")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.info")
XCTAssertEqual(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token")
}
func testUnauthorizedStatusMapsToUnauthorizedError() async throws {
let httpClient = MockHTTPClient()
httpClient.statusCode = 401
httpClient.responseData = """
{ "ok": false, "error": "authentication_required", "message": "Authentication required" }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
do {
_ = try await client.documentInfo(id: "doc-1")
XCTFail("Expected unauthorized error")
} catch OutlineAPIError.unauthorized {
// expected
}
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(token: nil),
httpClient: httpClient
)
do {
_ = try await client.documentInfo(id: "doc-1")
XCTFail("Expected tokenUnavailable error")
} catch OutlineAPIError.tokenUnavailable {
// expected
}
}
}