Implements a first vertical slice of the Outline RPC API (auth, documents, collections, users) covering the structural patterns used across the whole API: single object, paginated list, tree, create/update, delete, search. Chosen as the basis for future CLI and GUI clients built on top of this crate. - Handwritten client (not codegen) against the vendored OpenAPI spec, since Outline's API is uniformly POST /api/<resource>.<action> with JSON bodies and inline/anonymous schemas that generators handle poorly - Async (reqwest + tokio) Client, cheaply cloneable, no &mut self methods - thiserror-based Error with ErrorKind classification and boxed API error context; forward-compatible models (unknown fields ignored, unknown string-enum values preserved via a catch-all variant) since the API is unversioned and self-hosted instances vary in age - Pagination via Page<T>/Paginator with next_page/collect_all/into_stream - wiremock-based test suite plus a spec-coverage test guarding against typos in RPC method names
156 lines
4.6 KiB
Rust
156 lines
4.6 KiB
Rust
mod common;
|
|
|
|
use serde_json::json;
|
|
use wiremock::matchers::{body_json, method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn sample_document(id: &str, title: &str) -> serde_json::Value {
|
|
json!({
|
|
"id": id,
|
|
"collectionId": "col-1",
|
|
"title": title,
|
|
"text": "Hello world",
|
|
"urlId": "abc123",
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn documents_info_by_id() {
|
|
let server = MockServer::start().await;
|
|
let client = common::client_for(&server).await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/documents.info"))
|
|
.and(body_json(json!({ "id": "doc-1" })))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"data": sample_document("doc-1", "Welcome")
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let document = client.documents().info("doc-1").await.unwrap();
|
|
assert_eq!(document.title, "Welcome");
|
|
assert_eq!(document.collection_id.unwrap().as_str(), "col-1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn documents_list_returns_page() {
|
|
let server = MockServer::start().await;
|
|
let client = common::client_for(&server).await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/documents.list"))
|
|
.and(body_json(json!({ "limit": 2, "collectionId": "col-1" })))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"data": [sample_document("doc-1", "One"), sample_document("doc-2", "Two")],
|
|
"pagination": { "limit": 2, "offset": 0 }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let page = client
|
|
.documents()
|
|
.list()
|
|
.collection_id("col-1")
|
|
.limit(2)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(page.items.len(), 2);
|
|
assert_eq!(page.items[0].title, "One");
|
|
assert_eq!(page.pagination.limit, Some(2));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn documents_create_sends_title_and_collection() {
|
|
let server = MockServer::start().await;
|
|
let client = common::client_for(&server).await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/documents.create"))
|
|
.and(body_json(
|
|
json!({ "title": "New Doc", "collectionId": "col-1", "publish": true }),
|
|
))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"data": sample_document("doc-3", "New Doc")
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let document = client
|
|
.documents()
|
|
.create("New Doc")
|
|
.collection_id("col-1")
|
|
.publish(true)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(document.id.as_str(), "doc-3");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn documents_update_sends_only_set_fields() {
|
|
let server = MockServer::start().await;
|
|
let client = common::client_for(&server).await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/documents.update"))
|
|
.and(body_json(json!({ "id": "doc-1", "title": "Renamed" })))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"data": sample_document("doc-1", "Renamed")
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let document = client
|
|
.documents()
|
|
.update("doc-1")
|
|
.title("Renamed")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(document.title, "Renamed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn documents_delete_ignores_response_shape() {
|
|
let server = MockServer::start().await;
|
|
let client = common::client_for(&server).await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/documents.delete"))
|
|
.and(body_json(json!({ "id": "doc-1", "permanent": false })))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "success": true })))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
client.documents().delete("doc-1", false).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn documents_search_returns_ranked_results() {
|
|
let server = MockServer::start().await;
|
|
let client = common::client_for(&server).await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/documents.search"))
|
|
.and(body_json(json!({ "query": "hiring" })))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"data": [
|
|
{ "context": "our hiring practices", "ranking": 1.5, "document": sample_document("doc-4", "Hiring Guide") }
|
|
],
|
|
"pagination": { "limit": 25, "offset": 0 }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let page = client.documents().search("hiring").send().await.unwrap();
|
|
assert_eq!(page.items.len(), 1);
|
|
assert_eq!(page.items[0].document.title, "Hiring Guide");
|
|
}
|