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