feat(outlinekit): add collection delete, export, and star endpoints

Also adds a postForSuccess helper for endpoints shaped
{ "success": true } instead of the usual { "data": ... } envelope
(collections.delete needed it).
This commit is contained in:
2026-08-14 00:56:23 +01:00
parent 502888dfde
commit 7f921ba006
7 changed files with 200 additions and 0 deletions
@@ -19,6 +19,11 @@ public protocol OutlineAPIClient: Sendable {
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
func collectionInfo(id: String) async throws -> OutlineCollection
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
func deleteCollection(id: String) async throws
/// Kicks off an async export job this only wraps the trigger, not polling
/// for completion or downloading the resulting file.
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar
func currentUser() async throws -> OutlineUser
}
@@ -69,6 +69,19 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("collections.update", body: request)
}
public func deleteCollection(id: String) async throws {
try await postForSuccess("collections.delete", body: CollectionInfoParams(id: id))
}
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
let result: ExportCollectionResponse = try await post("collections.export", body: request)
return result.fileOperation
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
try await post("stars.create", body: request)
}
public func currentUser() async throws -> OutlineUser {
try await post("users.info", body: EmptyParams())
}
@@ -112,6 +125,61 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
throw OutlineAPIError.decoding(error)
}
}
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
/// (e.g. `collections.delete`) `post(_:body:)`'s envelope decode doesn't fit.
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
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 {
let envelope = try decoder.decode(SuccessEnvelope.self, from: data)
guard envelope.success else {
throw OutlineAPIError.server(status: response.statusCode, message: nil)
}
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.decoding(error)
}
}
}
private struct ExportCollectionResponse: Decodable {
let fileOperation: OutlineFileOperation
}
private struct SuccessEnvelope: Decodable {
let success: Bool
}
private struct DocumentInfoParams: Encodable {
@@ -0,0 +1,8 @@
import Foundation
/// A tracked async job collection/document export runs server-side and
/// this is the handle to check on it (progress polling isn't wrapped yet).
public struct OutlineFileOperation: Decodable, Sendable {
public let id: String
public let state: String?
}
@@ -0,0 +1,9 @@
import Foundation
/// A bookmark on a document or collection, shown in the user's sidebar.
public struct OutlineStar: Decodable, Sendable {
public let id: String
public let index: String?
public let documentId: String?
public let collectionId: String?
}
@@ -0,0 +1,17 @@
import Foundation
public enum CollectionExportFormat: String, Encodable, Sendable, CaseIterable {
case markdown = "outline-markdown"
case json
case html
}
public struct ExportCollectionRequest: Encodable, Sendable {
public let id: String
public let format: CollectionExportFormat
public init(id: String, format: CollectionExportFormat = .markdown) {
self.id = id
self.format = format
}
}
@@ -0,0 +1,9 @@
import Foundation
public struct StarCollectionRequest: Encodable, Sendable {
public let collectionId: String
public init(collectionId: String) {
self.collectionId = collectionId
}
}
@@ -35,6 +35,90 @@ private final class StaticTokenStore: TokenStoring, @unchecked Sendable {
}
final class LiveOutlineAPIClientTests: XCTestCase {
func testDeleteCollectionSucceedsOnSuccessTrue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteCollection(id: "col-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.delete")
}
func testDeleteCollectionThrowsOnSuccessFalse() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": false }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
do {
try await client.deleteCollection(id: "col-1")
XCTFail("Expected an error when success is false")
} catch {
// expected
}
}
func testExportCollectionDecodesFileOperation() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"fileOperation": { "id": "op-1", "state": "creating" }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let operation = try await client.exportCollection(ExportCollectionRequest(id: "col-1"))
XCTAssertEqual(operation.id, "op-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.export")
}
func testStarCollectionDecodesStar() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "star-1",
"collectionId": "col-1",
"documentId": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let star = try await client.starCollection(StarCollectionRequest(collectionId: "col-1"))
XCTAssertEqual(star.id, "star-1")
XCTAssertEqual(star.collectionId, "col-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.create")
}
func testSearchDocumentsDecodesContextAndRanking() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """