From 07fb08cb6bccbec62ace65b065624360c1d7997c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 00:23:12 +0100 Subject: [PATCH] feat(outlinekit): add REST client package for Outline API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- OutlineKit/.gitignore | 3 + OutlineKit/Package.swift | 22 ++ .../OutlineKit/Auth/KeychainTokenStore.swift | 56 ++++ .../OutlineKit/Auth/TokenStoring.swift | 14 + .../OutlineKit/Core/OutlineAPIClient.swift | 24 ++ .../OutlineKit/Core/OutlineAPIError.swift | 10 + .../Core/OutlineConfiguration.swift | 10 + .../OutlineKit/LiveOutlineAPIClient.swift | 136 ++++++++++ .../Models/DocumentSearchTypes.swift | 36 +++ .../OutlineKit/Models/OutlineAuthInfo.swift | 12 + .../OutlineKit/Models/OutlineCollection.swift | 29 +++ .../OutlineKit/Models/OutlineDocument.swift | 47 ++++ .../OutlineKit/Models/OutlineEnvelope.swift | 20 ++ .../OutlineKit/Models/OutlineTeam.swift | 26 ++ .../OutlineKit/Models/OutlineUser.swift | 23 ++ .../OutlineKit/Networking/HTTPClient.swift | 22 ++ .../Requests/CreateDocumentRequest.swift | 23 ++ .../Requests/DocumentSearchRequest.swift | 45 ++++ .../DocumentSearchTitlesRequest.swift | 39 +++ .../Requests/UpdateCollectionRequest.swift | 17 ++ .../Requests/UpdateDocumentRequest.swift | 20 ++ .../LiveOutlineAPIClientTests.swift | 241 ++++++++++++++++++ 22 files changed, 875 insertions(+) create mode 100644 OutlineKit/.gitignore create mode 100644 OutlineKit/Package.swift create mode 100644 OutlineKit/Sources/OutlineKit/Auth/KeychainTokenStore.swift create mode 100644 OutlineKit/Sources/OutlineKit/Auth/TokenStoring.swift create mode 100644 OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift create mode 100644 OutlineKit/Sources/OutlineKit/Core/OutlineAPIError.swift create mode 100644 OutlineKit/Sources/OutlineKit/Core/OutlineConfiguration.swift create mode 100644 OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/DocumentSearchTypes.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineAuthInfo.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineCollection.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineEnvelope.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineTeam.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift create mode 100644 OutlineKit/Sources/OutlineKit/Networking/HTTPClient.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/DocumentSearchRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/DocumentSearchTitlesRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift create mode 100644 OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift diff --git a/OutlineKit/.gitignore b/OutlineKit/.gitignore new file mode 100644 index 0000000..29a1e94 --- /dev/null +++ b/OutlineKit/.gitignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +.DS_Store diff --git a/OutlineKit/Package.swift b/OutlineKit/Package.swift new file mode 100644 index 0000000..8a929b8 --- /dev/null +++ b/OutlineKit/Package.swift @@ -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"] + ) + ] +) diff --git a/OutlineKit/Sources/OutlineKit/Auth/KeychainTokenStore.swift b/OutlineKit/Sources/OutlineKit/Auth/KeychainTokenStore.swift new file mode 100644 index 0000000..83c283f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Auth/KeychainTokenStore.swift @@ -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 + ] + } +} diff --git a/OutlineKit/Sources/OutlineKit/Auth/TokenStoring.swift b/OutlineKit/Sources/OutlineKit/Auth/TokenStoring.swift new file mode 100644 index 0000000..90a6c1e --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Auth/TokenStoring.swift @@ -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) +} diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift new file mode 100644 index 0000000..f41182b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -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 +} diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIError.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIError.swift new file mode 100644 index 0000000..be4a50a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIError.swift @@ -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 +} diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineConfiguration.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineConfiguration.swift new file mode 100644 index 0000000..8bdc965 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineConfiguration.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift new file mode 100644 index 0000000..8cf4414 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Talks to Outline's RPC-style REST API (`POST /api/.`, 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(_ 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.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 {} diff --git a/OutlineKit/Sources/OutlineKit/Models/DocumentSearchTypes.swift b/OutlineKit/Sources/OutlineKit/Models/DocumentSearchTypes.swift new file mode 100644 index 0000000..203ca6d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/DocumentSearchTypes.swift @@ -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 } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineAuthInfo.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineAuthInfo.swift new file mode 100644 index 0000000..7ee726b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineAuthInfo.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineCollection.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineCollection.swift new file mode 100644 index 0000000..e842e1a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineCollection.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift new file mode 100644 index 0000000..596d165 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineEnvelope.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineEnvelope.swift new file mode 100644 index 0000000..5be0bed --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineEnvelope.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Outline wraps every successful RPC response as `{ "data": ..., "pagination": ... }`. +struct OutlineEnvelope: 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? +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineTeam.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineTeam.swift new file mode 100644 index 0000000..47e6249 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineTeam.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift new file mode 100644 index 0000000..b85a6ca --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Networking/HTTPClient.swift b/OutlineKit/Sources/OutlineKit/Networking/HTTPClient.swift new file mode 100644 index 0000000..f0f8024 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Networking/HTTPClient.swift @@ -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) + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift new file mode 100644 index 0000000..07de5ea --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchRequest.swift new file mode 100644 index 0000000..60ee656 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchTitlesRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchTitlesRequest.swift new file mode 100644 index 0000000..f798a34 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchTitlesRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift new file mode 100644 index 0000000..7b094f1 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift new file mode 100644 index 0000000..cd3b733 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift new file mode 100644 index 0000000..81913e8 --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -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 + } + } +}