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,70 @@
|
||||
mod common;
|
||||
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{body_json, header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_info_returns_user_and_team() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/auth.info"))
|
||||
.and(header(
|
||||
"authorization",
|
||||
"Bearer ol_api_test000000000000000000000000000000",
|
||||
))
|
||||
.and(body_json(json!({})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"status": 200,
|
||||
"data": {
|
||||
"user": {
|
||||
"id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2",
|
||||
"name": "Jane Doe",
|
||||
"email": "jane@example.com",
|
||||
"role": "admin",
|
||||
"isSuspended": false
|
||||
},
|
||||
"team": {
|
||||
"id": "9d3d1b2e-3c9a-4c67-8a2a-7f5d3f0e9c11",
|
||||
"name": "Acme Inc",
|
||||
"sharing": true
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth = client.auth().info().await.unwrap();
|
||||
assert_eq!(auth.user.name, "Jane Doe");
|
||||
assert_eq!(auth.team.name, "Acme Inc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_config_requires_no_authorization_header() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/auth.config"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"status": 200,
|
||||
"data": {
|
||||
"name": "Acme Inc",
|
||||
"hostname": "acme-inc.getoutline.com",
|
||||
"services": [
|
||||
{ "id": "slack", "name": "Slack", "authUrl": "https://acme-inc.getoutline.com/auth/slack" }
|
||||
]
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let config = client.auth().config().await.unwrap();
|
||||
assert_eq!(config.name.as_deref(), Some("Acme Inc"));
|
||||
assert_eq!(config.services.len(), 1);
|
||||
assert_eq!(config.services[0].id.as_deref(), Some("slack"));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
mod common;
|
||||
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{body_json, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn collections_info_returns_collection() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/collections.info"))
|
||||
.and(body_json(json!({ "id": "col-1" })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": { "id": "col-1", "name": "Human Resources", "sharing": false }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let collection = client.collections().info("col-1").await.unwrap();
|
||||
assert_eq!(collection.name, "Human Resources");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collections_list_returns_page_with_policies() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/collections.list"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": [{ "id": "col-1", "name": "Human Resources", "sharing": false }],
|
||||
"pagination": { "limit": 25, "offset": 0 },
|
||||
"policies": [{ "id": "col-1", "abilities": { "update": true, "delete": false } }]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let page = client.collections().list().send().await.unwrap();
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(page.policies.len(), 1);
|
||||
assert!(page.policies[0].can("update"));
|
||||
assert!(!page.policies[0].can("delete"));
|
||||
assert!(!page.policies[0].can("archive"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collections_documents_returns_nested_tree() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/collections.documents"))
|
||||
.and(body_json(json!({ "id": "col-1" })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "doc-1",
|
||||
"title": "Parent",
|
||||
"children": [
|
||||
{ "id": "doc-2", "title": "Child", "children": [] }
|
||||
]
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tree = client.collections().documents("col-1").await.unwrap();
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert_eq!(tree[0].title, "Parent");
|
||||
assert_eq!(tree[0].children.len(), 1);
|
||||
assert_eq!(tree[0].children[0].title, "Child");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use outline::Client;
|
||||
use wiremock::MockServer;
|
||||
|
||||
/// Spins up a mock Outline API server and a [`Client`] pointed at it.
|
||||
pub async fn client_for(server: &MockServer) -> Client {
|
||||
Client::builder()
|
||||
.base_url(server.uri())
|
||||
.api_key("ol_api_test000000000000000000000000000000")
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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");
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
mod common;
|
||||
|
||||
use outline::ErrorKind;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn maps_401_to_unauthenticated() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/auth.info"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client.auth().info().await.unwrap_err();
|
||||
assert_eq!(err.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(err.status(), Some(http::StatusCode::UNAUTHORIZED));
|
||||
assert!(!err.is_retryable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maps_404_to_not_found() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/collections.info"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
|
||||
"ok": false,
|
||||
"status": 404,
|
||||
"error": "not_found",
|
||||
"message": "Collection not found"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client.collections().info("missing-id").await.unwrap_err();
|
||||
assert!(err.is_not_found());
|
||||
assert_eq!(err.kind(), ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maps_429_to_rate_limited_and_parses_retry_after() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/documents.list"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(429)
|
||||
.insert_header("Retry-After", "12")
|
||||
.set_body_json(json!({
|
||||
"ok": false,
|
||||
"status": 429,
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Rate limit exceeded"
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client.documents().list().send().await.unwrap_err();
|
||||
assert_eq!(err.kind(), ErrorKind::RateLimited);
|
||||
assert!(err.is_retryable());
|
||||
assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(12)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maps_5xx_to_server_error() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/auth.info"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client.auth().info().await.unwrap_err();
|
||||
assert_eq!(err.kind(), ErrorKind::Server);
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_success_body_yields_decode_error() {
|
||||
let server = MockServer::start().await;
|
||||
let client = common::client_for(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/auth.info"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client.auth().info().await.unwrap_err();
|
||||
assert_eq!(err.kind(), ErrorKind::Decode);
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2",
|
||||
"collectionId": "9d3d1b2e-3c9a-4c67-8a2a-7f5d3f0e9c11",
|
||||
"parentDocumentId": null,
|
||||
"title": "Welcome to Acme Inc",
|
||||
"fullWidth": false,
|
||||
"icon": "🎉",
|
||||
"color": null,
|
||||
"text": "# Welcome\n\nThis is the text of the document.",
|
||||
"url": "/doc/welcome-to-acme-inc-hDYep1TPAM",
|
||||
"urlId": "hDYep1TPAM",
|
||||
"collaboratorIds": ["5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2"],
|
||||
"tasks": { "completed": 1, "total": 4 },
|
||||
"revision": 12,
|
||||
"createdAt": "2024-01-15T10:00:00.000Z",
|
||||
"createdBy": {
|
||||
"id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2",
|
||||
"name": "Jane Doe",
|
||||
"email": "jane@example.com",
|
||||
"role": "admin",
|
||||
"isSuspended": false
|
||||
},
|
||||
"updatedAt": "2024-02-20T09:30:00.000Z",
|
||||
"publishedAt": "2024-01-15T11:00:00.000Z",
|
||||
"archivedAt": null,
|
||||
"deletedAt": null
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Smoke tests against the real Outline API.
|
||||
//!
|
||||
//! Ignored by default. Run with:
|
||||
//! `OUTLINE_API_KEY=ol_api_... cargo test -- --ignored`
|
||||
|
||||
use outline::Client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn auth_info_and_collections_list_succeed() {
|
||||
let client =
|
||||
Client::from_env().expect("OUTLINE_API_KEY (and optionally OUTLINE_URL) must be set");
|
||||
|
||||
let auth = client
|
||||
.auth()
|
||||
.info()
|
||||
.await
|
||||
.expect("auth.info should succeed with valid credentials");
|
||||
println!("Signed in as {} ({})", auth.user.name, auth.team.name);
|
||||
|
||||
let page = client
|
||||
.collections()
|
||||
.list()
|
||||
.limit(5)
|
||||
.send()
|
||||
.await
|
||||
.expect("collections.list should succeed");
|
||||
println!("Fetched {} collection(s)", page.items.len());
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
use outline::models::{Ability, DocumentTasks, NavigationNode, Policy, User, UserRole};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn deserializes_full_document_fixture() {
|
||||
let raw = include_str!("fixtures/document.json");
|
||||
let document: outline::models::Document = serde_json::from_str(raw).unwrap();
|
||||
|
||||
assert_eq!(document.title, "Welcome to Acme Inc");
|
||||
assert_eq!(document.url_id.as_deref(), Some("hDYep1TPAM"));
|
||||
assert_eq!(
|
||||
document.tasks,
|
||||
Some(DocumentTasks {
|
||||
completed: 1,
|
||||
total: 4
|
||||
})
|
||||
);
|
||||
assert!(document.parent_document_id.is_none());
|
||||
assert!(document.archived_at.is_none());
|
||||
assert_eq!(document.created_by.unwrap().name, "Jane Doe");
|
||||
}
|
||||
|
||||
/// A server (self-hosted, newer than this crate) adding a brand new field to
|
||||
/// a response must not break deserialization of the rest of the object.
|
||||
#[test]
|
||||
fn unknown_top_level_field_is_ignored() {
|
||||
let raw = json!({
|
||||
"id": "doc-1",
|
||||
"title": "Still works",
|
||||
"aBrandNewFieldFromTheFuture": { "nested": true },
|
||||
});
|
||||
|
||||
let document: outline::models::Document = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(document.title, "Still works");
|
||||
}
|
||||
|
||||
/// A server returning a role value this crate doesn't know about yet must
|
||||
/// round-trip instead of failing to deserialize the whole `User`.
|
||||
#[test]
|
||||
fn unknown_enum_value_round_trips_via_unknown_variant() {
|
||||
let raw = json!({
|
||||
"id": "user-1",
|
||||
"name": "Future User",
|
||||
"role": "super_admin",
|
||||
});
|
||||
|
||||
let user: User = serde_json::from_value(raw).unwrap();
|
||||
match &user.role {
|
||||
Some(UserRole::Unknown(value)) => assert_eq!(value.0, "super_admin"),
|
||||
other => panic!("expected UserRole::Unknown, got {other:?}"),
|
||||
}
|
||||
|
||||
let round_tripped = serde_json::to_value(&user).unwrap();
|
||||
assert_eq!(round_tripped["role"], json!("super_admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_enum_value_deserializes_normally() {
|
||||
let raw = json!({ "id": "user-1", "name": "Jane", "role": "member" });
|
||||
let user: User = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(user.role, Some(UserRole::Member));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_ability_accepts_boolean_or_membership_list() {
|
||||
let raw = json!({
|
||||
"id": "doc-1",
|
||||
"abilities": {
|
||||
"update": true,
|
||||
"delete": false,
|
||||
"restrictedUpdate": ["group-a", "group-b"],
|
||||
}
|
||||
});
|
||||
|
||||
let policy: Policy = serde_json::from_value(raw).unwrap();
|
||||
assert!(policy.can("update"));
|
||||
assert!(!policy.can("delete"));
|
||||
assert!(policy.can("restrictedUpdate"));
|
||||
assert!(!policy.can("missingAbility"));
|
||||
assert_eq!(
|
||||
policy.abilities["restrictedUpdate"],
|
||||
Ability::Memberships(vec!["group-a".into(), "group-b".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_node_deserializes_recursively() {
|
||||
let raw = json!({
|
||||
"id": "doc-1",
|
||||
"title": "Parent",
|
||||
"children": [
|
||||
{ "id": "doc-2", "title": "Child", "children": [
|
||||
{ "id": "doc-3", "title": "Grandchild" }
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
let node: NavigationNode = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(node.children.len(), 1);
|
||||
assert_eq!(node.children[0].children[0].title, "Grandchild");
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Verifies that every RPC method name used by this crate still exists as a
|
||||
//! path in the vendored OpenAPI spec, catching typos and upstream renames.
|
||||
|
||||
const IMPLEMENTED_METHODS: &[&str] = &[
|
||||
"auth.info",
|
||||
"auth.config",
|
||||
"documents.info",
|
||||
"documents.list",
|
||||
"documents.search",
|
||||
"documents.create",
|
||||
"documents.update",
|
||||
"documents.delete",
|
||||
"collections.list",
|
||||
"collections.info",
|
||||
"collections.documents",
|
||||
"users.list",
|
||||
"users.info",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn implemented_methods_exist_in_vendored_spec() {
|
||||
let spec_raw = include_str!("../spec/spec3.json");
|
||||
let spec: serde_json::Value = serde_json::from_str(spec_raw).unwrap();
|
||||
let paths = spec["paths"]
|
||||
.as_object()
|
||||
.expect("spec should have a `paths` object");
|
||||
|
||||
let missing: Vec<&str> = IMPLEMENTED_METHODS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|method| !paths.contains_key(&format!("/{method}")))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"methods missing from vendored spec: {missing:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user