Add Outline API client library foundation
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
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
mod common;
|
||||
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{body_json, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn doc(id: &str) -> serde_json::Value {
|
||||
json!({ "id": id, "title": id })
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paginator_advances_offset_and_stops_on_short_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, "offset": 0 })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": [doc("doc-1"), doc("doc-2")],
|
||||
"pagination": { "limit": 2, "offset": 0 }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/documents.list"))
|
||||
.and(body_json(json!({ "limit": 2, "offset": 2 })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": [doc("doc-3")],
|
||||
"pagination": { "limit": 2, "offset": 2 }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let items = client
|
||||
.documents()
|
||||
.list()
|
||||
.limit(2)
|
||||
.paginate()
|
||||
.collect_all()
|
||||
.await
|
||||
.unwrap();
|
||||
let ids: Vec<_> = items
|
||||
.into_iter()
|
||||
.map(|d| d.id.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["doc-1", "doc-2", "doc-3"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paginator_stops_immediately_on_empty_first_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": 25, "offset": 0 })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": [],
|
||||
"pagination": { "limit": 25, "offset": 0 }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut paginator = client.documents().list().paginate();
|
||||
let page = paginator
|
||||
.next_page()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("first page always returned");
|
||||
assert!(page.items.is_empty());
|
||||
assert!(paginator.next_page().await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "stream")]
|
||||
#[tokio::test]
|
||||
async fn into_stream_yields_items_across_pages() {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
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, "offset": 0 })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": [doc("doc-1"), doc("doc-2")],
|
||||
"pagination": { "limit": 2, "offset": 0 }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/documents.list"))
|
||||
.and(body_json(json!({ "limit": 2, "offset": 2 })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": [doc("doc-3")],
|
||||
"pagination": { "limit": 2, "offset": 2 }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let stream = client.documents().list().limit(2).paginate().into_stream();
|
||||
let items: Vec<_> = stream.collect().await;
|
||||
assert_eq!(items.len(), 3);
|
||||
assert!(items.iter().all(|item| item.is_ok()));
|
||||
}
|
||||
Reference in New Issue
Block a user