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:
+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");
|
||||
}
|
||||
Reference in New Issue
Block a user