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