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:
2026-07-30 13:00:09 +02:00
commit dec8face54
38 changed files with 13760 additions and 0 deletions
+70
View File
@@ -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"));
}
+78
View File
@@ -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");
}
+11
View File
@@ -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()
}
+155
View File
@@ -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
View File
@@ -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);
}
+27
View File
@@ -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
}
+29
View File
@@ -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
View File
@@ -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");
}
+113
View File
@@ -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()));
}
+38
View File
@@ -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:?}"
);
}