documents.list supports it directly (per the vendored spec) — backs fetching a document's direct children for the reader's sub-documents section, without a second round-trip through the whole collection's flat list.
216 lines
7.4 KiB
Swift
216 lines
7.4 KiB
Swift
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?,
|
|
parentDocumentId: String?,
|
|
offset: Int,
|
|
limit: Int
|
|
) async throws -> [OutlineDocument] {
|
|
try await post(
|
|
"documents.list",
|
|
body: DocumentListParams(
|
|
collectionId: collectionId,
|
|
parentDocumentId: parentDocumentId,
|
|
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 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())
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
let id: String
|
|
}
|
|
|
|
private struct DocumentListParams: Encodable {
|
|
let collectionId: String?
|
|
let parentDocumentId: 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 {}
|