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:
@@ -0,0 +1,3 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user