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:
@@ -19,6 +19,11 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
||||||
func collectionInfo(id: String) async throws -> OutlineCollection
|
func collectionInfo(id: String) async throws -> OutlineCollection
|
||||||
func updateCollection(_ request: UpdateCollectionRequest) 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
|
func currentUser() async throws -> OutlineUser
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("collections.update", body: request)
|
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 {
|
public func currentUser() async throws -> OutlineUser {
|
||||||
try await post("users.info", body: EmptyParams())
|
try await post("users.info", body: EmptyParams())
|
||||||
}
|
}
|
||||||
@@ -112,6 +125,61 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
throw OutlineAPIError.decoding(error)
|
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 {
|
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 {
|
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 {
|
func testSearchDocumentsDecodesContextAndRanking() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
Reference in New Issue
Block a user