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,22 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- run: cargo fmt --check
|
||||||
|
- run: cargo clippy --all-features --all-targets -- -D warnings
|
||||||
|
- run: cargo test --all-features
|
||||||
|
- run: cargo doc --no-deps --all-features
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
[package]
|
||||||
|
name = "outline-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "Async Rust client for the Outline (getoutline.com) knowledge base API"
|
||||||
|
keywords = ["outline", "wiki", "api", "client", "knowledge-base"]
|
||||||
|
categories = ["api-bindings", "asynchronous"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "outline"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["rustls-tls", "stream"]
|
||||||
|
rustls-tls = ["reqwest/rustls"]
|
||||||
|
native-tls = ["reqwest/native-tls"]
|
||||||
|
stream = ["dep:futures-core", "dep:futures-util", "reqwest/stream"]
|
||||||
|
multipart = ["reqwest/multipart"]
|
||||||
|
tracing = ["dep:tracing"]
|
||||||
|
uuid = ["dep:uuid"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "http2", "charset"] }
|
||||||
|
serde = { version = "1.0.181", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
url = "2"
|
||||||
|
http = "1"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["serde", "std", "clock"] }
|
||||||
|
secrecy = "0.10"
|
||||||
|
futures-core = { version = "0.3", optional = true }
|
||||||
|
futures-util = { version = "0.3", optional = true, default-features = false }
|
||||||
|
bytes = "1"
|
||||||
|
tracing = { version = "0.1", optional = true }
|
||||||
|
uuid = { version = "1", optional = true, features = ["serde"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
wiremock = "0.6"
|
||||||
|
serde_json = "1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
http = "1"
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# outline
|
||||||
|
|
||||||
|
An async Rust client library for the [Outline](https://www.getoutline.com) knowledge base API.
|
||||||
|
|
||||||
|
This crate is the foundation for building Outline clients (CLI, GUI, ...) on
|
||||||
|
top of a shared, well-tested API layer. It only talks to the API — no CLI or
|
||||||
|
GUI code lives here.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use outline::Client;
|
||||||
|
|
||||||
|
# async fn run() -> outline::Result<()> {
|
||||||
|
let client = Client::new("ol_api_...")?;
|
||||||
|
|
||||||
|
let me = client.auth().info().await?;
|
||||||
|
println!("Signed in as {} ({})", me.user.name, me.team.name);
|
||||||
|
|
||||||
|
let mut documents = client.documents().list().collection_id("col_123").paginate();
|
||||||
|
while let Some(page) = documents.next_page().await? {
|
||||||
|
for document in page.items {
|
||||||
|
println!("{}", document.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
For a self-hosted instance or an OAuth access token, use [`Client::builder`]:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use std::time::Duration;
|
||||||
|
use outline::Client;
|
||||||
|
|
||||||
|
# fn run() -> outline::Result<()> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.base_url("https://wiki.example.com")
|
||||||
|
.access_token("...")
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.build()?;
|
||||||
|
# let _ = client;
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
The Outline API is RPC-style: every endpoint is `POST /api/<resource>.<action>`
|
||||||
|
with a JSON body and a `{ok, data, pagination, policies}` envelope. This crate
|
||||||
|
mirrors that with a single internal request primitive; resources are exposed
|
||||||
|
as scoped accessors (`client.documents()`, `client.collections()`, ...).
|
||||||
|
|
||||||
|
Because the API is unversioned and self-hosted instances vary in age, models
|
||||||
|
are deliberately forward-compatible: unknown fields are ignored and unknown
|
||||||
|
string-enum values are preserved rather than causing deserialization to fail.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This crate currently covers a first vertical slice of the API — `auth`,
|
||||||
|
`documents`, `collections`, `users` — chosen to validate the request/response
|
||||||
|
patterns (single object, paginated list, tree, create/update, delete, search)
|
||||||
|
used across the rest of the API. Broader endpoint coverage, automatic retry
|
||||||
|
on rate limiting, and streaming exports are planned but not yet implemented.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT OR Apache-2.0
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! Lists every document in a collection, following pagination automatically.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! OUTLINE_API_KEY=ol_api_... cargo run --example list_documents -- <collection-id>
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use outline::Client;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> outline::Result<()> {
|
||||||
|
let collection_id = env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("usage: list_documents <collection-id>");
|
||||||
|
let client = Client::from_env()?;
|
||||||
|
|
||||||
|
let mut documents = client
|
||||||
|
.documents()
|
||||||
|
.list()
|
||||||
|
.collection_id(collection_id)
|
||||||
|
.paginate();
|
||||||
|
while let Some(page) = documents.next_page().await? {
|
||||||
|
for document in page.items {
|
||||||
|
println!("{}\t{}", document.id, document.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! Runs a full-text search and prints the top results with a short snippet.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! OUTLINE_API_KEY=ol_api_... cargo run --example search -- "hiring practices"
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use outline::Client;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> outline::Result<()> {
|
||||||
|
let query = env::args().nth(1).expect("usage: search <query>");
|
||||||
|
let client = Client::from_env()?;
|
||||||
|
|
||||||
|
let page = client.documents().search(query).limit(10).send().await?;
|
||||||
|
for result in page.items {
|
||||||
|
let context = result.context.as_deref().unwrap_or("");
|
||||||
|
println!("{}\n {}\n", result.document.title, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
edition = "2024"
|
||||||
+10544
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::client::{Client, MethodDef};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{AuthConfig, AuthInfo};
|
||||||
|
|
||||||
|
const INFO: MethodDef = MethodDef {
|
||||||
|
name: "auth.info",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
const CONFIG: MethodDef = MethodDef {
|
||||||
|
name: "auth.config",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Access to the `auth.*` endpoints.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AuthApi<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> AuthApi<'a> {
|
||||||
|
pub(crate) fn new(client: &'a Client) -> Self {
|
||||||
|
AuthApi { client }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the user and workspace associated with the current credentials.
|
||||||
|
pub async fn info(&self) -> Result<AuthInfo> {
|
||||||
|
self.client.rpc(INFO, &json!({})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves authentication configuration (available SSO services) for this instance.
|
||||||
|
///
|
||||||
|
/// Unlike other endpoints, this one requires no credentials.
|
||||||
|
pub async fn config(&self) -> Result<AuthConfig> {
|
||||||
|
self.client.rpc(CONFIG, &json!({})).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::client::{Client, MethodDef};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{Collection, CollectionStatus, Id, NavigationNode, SortDirection};
|
||||||
|
use crate::page::{DEFAULT_PAGE_SIZE, Page, PageParams, Paginated, Paginator};
|
||||||
|
|
||||||
|
const LIST: MethodDef = MethodDef {
|
||||||
|
name: "collections.list",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
const INFO: MethodDef = MethodDef {
|
||||||
|
name: "collections.info",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
const DOCUMENTS: MethodDef = MethodDef {
|
||||||
|
name: "collections.documents",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Access to the `collections.*` endpoints.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CollectionsApi<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CollectionsApi<'a> {
|
||||||
|
pub(crate) fn new(client: &'a Client) -> Self {
|
||||||
|
CollectionsApi { client }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a single collection by id.
|
||||||
|
pub async fn info(&self, id: impl Into<Id>) -> Result<Collection> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Params {
|
||||||
|
id: Id,
|
||||||
|
}
|
||||||
|
self.client.rpc(INFO, &Params { id: id.into() }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the full document tree (nested navigation) for a collection.
|
||||||
|
///
|
||||||
|
/// Unlike most list endpoints this is not paginated: the whole tree is
|
||||||
|
/// returned in one call.
|
||||||
|
pub async fn documents(&self, id: impl Into<Id>) -> Result<Vec<NavigationNode>> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Params {
|
||||||
|
id: Id,
|
||||||
|
}
|
||||||
|
self.client.rpc(DOCUMENTS, &Params { id: id.into() }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts building a `collections.list` request.
|
||||||
|
pub fn list(&self) -> ListCollections<'a> {
|
||||||
|
ListCollections::new(self.client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request parameters for `collections.list`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListCollectionsParams {
|
||||||
|
/// Pagination parameters.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub page: PageParams,
|
||||||
|
/// Filters results by collection name.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub query: Option<String>,
|
||||||
|
/// Restricts results to collections with the given statuses.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub status_filter: Vec<CollectionStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Paginated for ListCollectionsParams {
|
||||||
|
fn page_params_mut(&mut self) -> &mut PageParams {
|
||||||
|
&mut self.page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A builder for `collections.list`, obtained from [`CollectionsApi::list`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ListCollections<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
params: ListCollectionsParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ListCollections<'a> {
|
||||||
|
fn new(client: &'a Client) -> Self {
|
||||||
|
ListCollections {
|
||||||
|
client,
|
||||||
|
params: ListCollectionsParams::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters results by collection name.
|
||||||
|
pub fn query(mut self, query: impl Into<String>) -> Self {
|
||||||
|
self.params.query = Some(query.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to collections with the given statuses.
|
||||||
|
pub fn status_filter(mut self, statuses: impl IntoIterator<Item = CollectionStatus>) -> Self {
|
||||||
|
self.params.status_filter = statuses.into_iter().collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorts results by the given field (e.g. `"updatedAt"`) and direction.
|
||||||
|
pub fn sort(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
|
||||||
|
self.params.page.sort = Some(field.into());
|
||||||
|
self.params.page.direction = Some(direction);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the maximum number of results per page (server default: 25).
|
||||||
|
pub fn limit(mut self, limit: u32) -> Self {
|
||||||
|
self.params.page.limit = Some(limit);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of results to skip.
|
||||||
|
pub fn offset(mut self, offset: u32) -> Self {
|
||||||
|
self.params.page.offset = Some(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single page of results.
|
||||||
|
pub async fn send(self) -> Result<Page<Collection>> {
|
||||||
|
self.client.rpc_list(LIST, &self.params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [`Paginator`] that fetches successive pages, using [`Self::limit`]
|
||||||
|
/// as the page size (server default: 25) if it was set.
|
||||||
|
pub fn paginate(self) -> Paginator<'a, ListCollectionsParams, Collection> {
|
||||||
|
let page_size = self.params.page.limit.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||||
|
Paginator::new(self.client, LIST, self.params, page_size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::client::{Client, MethodDef};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{Document, DocumentStatus, Id, SearchResult, SortDirection, TextEditMode};
|
||||||
|
use crate::page::{DEFAULT_PAGE_SIZE, PageParams, Paginated, Paginator};
|
||||||
|
|
||||||
|
const INFO: MethodDef = MethodDef {
|
||||||
|
name: "documents.info",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
const LIST: MethodDef = MethodDef {
|
||||||
|
name: "documents.list",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
const SEARCH: MethodDef = MethodDef {
|
||||||
|
name: "documents.search",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
const CREATE: MethodDef = MethodDef {
|
||||||
|
name: "documents.create",
|
||||||
|
idempotent: false,
|
||||||
|
};
|
||||||
|
const UPDATE: MethodDef = MethodDef {
|
||||||
|
name: "documents.update",
|
||||||
|
idempotent: false,
|
||||||
|
};
|
||||||
|
const DELETE: MethodDef = MethodDef {
|
||||||
|
name: "documents.delete",
|
||||||
|
idempotent: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A way to look up a single document: by id, by its short `urlId`, or by a share id.
|
||||||
|
///
|
||||||
|
/// Outline's `documents.info` accepts a UUID or `urlId` interchangeably in
|
||||||
|
/// its `id` field, so both are represented by [`DocumentRef::Id`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DocumentRef {
|
||||||
|
/// Look up by UUID or `urlId`.
|
||||||
|
Id(Id),
|
||||||
|
/// Look up the document associated with a share link.
|
||||||
|
ShareId(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentRef {
|
||||||
|
/// Looks up the document associated with a share link.
|
||||||
|
pub fn share_id(id: impl Into<Id>) -> Self {
|
||||||
|
DocumentRef::ShareId(id.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for DocumentRef {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
DocumentRef::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Id> for DocumentRef {
|
||||||
|
fn from(id: &Id) -> Self {
|
||||||
|
DocumentRef::Id(id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for DocumentRef {
|
||||||
|
fn from(id: &str) -> Self {
|
||||||
|
DocumentRef::Id(Id::from(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for DocumentRef {
|
||||||
|
fn from(id: String) -> Self {
|
||||||
|
DocumentRef::Id(Id::from(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct InfoParams {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
id: Option<Id>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
share_id: Option<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DocumentRef> for InfoParams {
|
||||||
|
fn from(reference: DocumentRef) -> Self {
|
||||||
|
match reference {
|
||||||
|
DocumentRef::Id(id) => InfoParams {
|
||||||
|
id: Some(id),
|
||||||
|
share_id: None,
|
||||||
|
},
|
||||||
|
DocumentRef::ShareId(id) => InfoParams {
|
||||||
|
id: None,
|
||||||
|
share_id: Some(id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access to the `documents.*` endpoints.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DocumentsApi<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> DocumentsApi<'a> {
|
||||||
|
pub(crate) fn new(client: &'a Client) -> Self {
|
||||||
|
DocumentsApi { client }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a single document by id, `urlId`, or share id.
|
||||||
|
pub async fn info(&self, reference: impl Into<DocumentRef>) -> Result<Document> {
|
||||||
|
let params: InfoParams = reference.into().into();
|
||||||
|
self.client.rpc(INFO, ¶ms).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently or soft-deletes a document.
|
||||||
|
///
|
||||||
|
/// By default the document is moved to the trash; pass `permanent: true`
|
||||||
|
/// to destroy it immediately with no way to recover it.
|
||||||
|
pub async fn delete(&self, id: impl Into<Id>, permanent: bool) -> Result<()> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Params {
|
||||||
|
id: Id,
|
||||||
|
permanent: bool,
|
||||||
|
}
|
||||||
|
self.client
|
||||||
|
.rpc_ok(
|
||||||
|
DELETE,
|
||||||
|
&Params {
|
||||||
|
id: id.into(),
|
||||||
|
permanent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts building a `documents.list` request.
|
||||||
|
pub fn list(&self) -> ListDocuments<'a> {
|
||||||
|
ListDocuments::new(self.client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts building a `documents.search` request for the given full-text query.
|
||||||
|
pub fn search(&self, query: impl Into<String>) -> SearchDocuments<'a> {
|
||||||
|
SearchDocuments::new(self.client, query.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts building a `documents.create` request for a new document titled `title`.
|
||||||
|
pub fn create(&self, title: impl Into<String>) -> CreateDocument<'a> {
|
||||||
|
CreateDocument::new(self.client, title.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts building a `documents.update` request for the document identified by `id`.
|
||||||
|
pub fn update(&self, id: impl Into<Id>) -> UpdateDocument<'a> {
|
||||||
|
UpdateDocument::new(self.client, id.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request parameters for `documents.list`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListDocumentsParams {
|
||||||
|
/// Pagination parameters.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub page: PageParams,
|
||||||
|
/// Restricts results to a specific collection.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub collection_id: Option<Id>,
|
||||||
|
/// Restricts results to direct children of a specific document.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_document_id: Option<Id>,
|
||||||
|
/// Restricts results to documents created by a specific user.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub user_id: Option<Id>,
|
||||||
|
/// Restricts results to documents with the given publication statuses.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub status_filter: Vec<DocumentStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Paginated for ListDocumentsParams {
|
||||||
|
fn page_params_mut(&mut self) -> &mut PageParams {
|
||||||
|
&mut self.page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A builder for `documents.list`, obtained from [`DocumentsApi::list`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ListDocuments<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
params: ListDocumentsParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ListDocuments<'a> {
|
||||||
|
fn new(client: &'a Client) -> Self {
|
||||||
|
ListDocuments {
|
||||||
|
client,
|
||||||
|
params: ListDocumentsParams::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to documents in the given collection.
|
||||||
|
pub fn collection_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.collection_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to direct children of the given document.
|
||||||
|
pub fn parent_document_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.parent_document_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to documents created by the given user.
|
||||||
|
pub fn user_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.user_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to documents with the given publication statuses.
|
||||||
|
pub fn status_filter(mut self, statuses: impl IntoIterator<Item = DocumentStatus>) -> Self {
|
||||||
|
self.params.status_filter = statuses.into_iter().collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorts results by the given field (e.g. `"updatedAt"`) and direction.
|
||||||
|
pub fn sort(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
|
||||||
|
self.params.page.sort = Some(field.into());
|
||||||
|
self.params.page.direction = Some(direction);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the maximum number of results per page (server default: 25).
|
||||||
|
pub fn limit(mut self, limit: u32) -> Self {
|
||||||
|
self.params.page.limit = Some(limit);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of results to skip.
|
||||||
|
pub fn offset(mut self, offset: u32) -> Self {
|
||||||
|
self.params.page.offset = Some(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the full request parameters directly, overriding any prior builder calls.
|
||||||
|
pub fn params(mut self, params: ListDocumentsParams) -> Self {
|
||||||
|
self.params = params;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single page of results.
|
||||||
|
pub async fn send(self) -> Result<crate::page::Page<Document>> {
|
||||||
|
self.client.rpc_list(LIST, &self.params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [`Paginator`] that fetches successive pages, using [`Self::limit`]
|
||||||
|
/// as the page size (server default: 25) if it was set.
|
||||||
|
pub fn paginate(self) -> Paginator<'a, ListDocumentsParams, Document> {
|
||||||
|
let page_size = self.params.page.limit.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||||
|
Paginator::new(self.client, LIST, self.params, page_size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request parameters for `documents.search`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchDocumentsParams {
|
||||||
|
/// Pagination parameters.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub page: PageParams,
|
||||||
|
/// The full-text search query.
|
||||||
|
pub query: String,
|
||||||
|
/// Restricts results to a specific collection.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub collection_id: Option<Id>,
|
||||||
|
/// Restricts results to within a specific document.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub document_id: Option<Id>,
|
||||||
|
/// Restricts results to documents edited by a specific user.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub user_id: Option<Id>,
|
||||||
|
/// Restricts results to documents with the given publication statuses.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub status_filter: Vec<DocumentStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Paginated for SearchDocumentsParams {
|
||||||
|
fn page_params_mut(&mut self) -> &mut PageParams {
|
||||||
|
&mut self.page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A builder for `documents.search`, obtained from [`DocumentsApi::search`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchDocuments<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
params: SearchDocumentsParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SearchDocuments<'a> {
|
||||||
|
fn new(client: &'a Client, query: String) -> Self {
|
||||||
|
SearchDocuments {
|
||||||
|
client,
|
||||||
|
params: SearchDocumentsParams {
|
||||||
|
query,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to documents in the given collection.
|
||||||
|
pub fn collection_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.collection_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to within the given document.
|
||||||
|
pub fn document_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.document_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to documents edited by the given user.
|
||||||
|
pub fn user_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.user_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to documents with the given publication statuses.
|
||||||
|
pub fn status_filter(mut self, statuses: impl IntoIterator<Item = DocumentStatus>) -> Self {
|
||||||
|
self.params.status_filter = statuses.into_iter().collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the maximum number of results per page (server default: 25).
|
||||||
|
pub fn limit(mut self, limit: u32) -> Self {
|
||||||
|
self.params.page.limit = Some(limit);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of results to skip.
|
||||||
|
pub fn offset(mut self, offset: u32) -> Self {
|
||||||
|
self.params.page.offset = Some(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single page of results.
|
||||||
|
pub async fn send(self) -> Result<crate::page::Page<SearchResult>> {
|
||||||
|
self.client.rpc_list(SEARCH, &self.params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [`Paginator`] that fetches successive pages, using [`Self::limit`]
|
||||||
|
/// as the page size (server default: 25) if it was set.
|
||||||
|
pub fn paginate(self) -> Paginator<'a, SearchDocumentsParams, SearchResult> {
|
||||||
|
let page_size = self.params.page.limit.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||||
|
Paginator::new(self.client, SEARCH, self.params, page_size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request parameters for `documents.create`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateDocumentParams {
|
||||||
|
/// The title of the new document.
|
||||||
|
pub title: String,
|
||||||
|
/// The markdown body of the new document.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
/// The collection to publish the document into.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub collection_id: Option<Id>,
|
||||||
|
/// The parent document to nest the new document under.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_document_id: Option<Id>,
|
||||||
|
/// Whether to immediately publish the document.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub publish: Option<bool>,
|
||||||
|
/// A caller-chosen id for the new document, making the call idempotent.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A builder for `documents.create`, obtained from [`DocumentsApi::create`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CreateDocument<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
params: CreateDocumentParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CreateDocument<'a> {
|
||||||
|
fn new(client: &'a Client, title: String) -> Self {
|
||||||
|
CreateDocument {
|
||||||
|
client,
|
||||||
|
params: CreateDocumentParams {
|
||||||
|
title,
|
||||||
|
text: None,
|
||||||
|
collection_id: None,
|
||||||
|
parent_document_id: None,
|
||||||
|
publish: None,
|
||||||
|
id: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the markdown body of the document.
|
||||||
|
pub fn text(mut self, text: impl Into<String>) -> Self {
|
||||||
|
self.params.text = Some(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the collection to publish this document into.
|
||||||
|
pub fn collection_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.collection_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the parent document this should be nested under.
|
||||||
|
pub fn parent_document_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.parent_document_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to immediately publish the document (default: left as a draft).
|
||||||
|
pub fn publish(mut self, publish: bool) -> Self {
|
||||||
|
self.params.publish = Some(publish);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a caller-chosen id for the new document.
|
||||||
|
///
|
||||||
|
/// Supplying a fixed id makes the create call idempotent: retrying with
|
||||||
|
/// the same id will not create a duplicate document.
|
||||||
|
pub fn id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the create request.
|
||||||
|
pub async fn send(self) -> Result<Document> {
|
||||||
|
self.client.rpc(CREATE, &self.params).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request parameters for `documents.update`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateDocumentParams {
|
||||||
|
/// The document to update, by UUID or `urlId`.
|
||||||
|
pub id: Id,
|
||||||
|
/// The new title, if changing it.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// The markdown text to apply, according to `edit_mode`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
/// How `text` should be applied to the existing content.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub edit_mode: Option<TextEditMode>,
|
||||||
|
/// The text to find and replace, required when `edit_mode` is `patch`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub find_text: Option<String>,
|
||||||
|
/// A new collection to move the document to.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub collection_id: Option<Id>,
|
||||||
|
/// Whether to publish the document, if it was a draft.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub publish: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A builder for `documents.update`, obtained from [`DocumentsApi::update`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UpdateDocument<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
params: UpdateDocumentParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> UpdateDocument<'a> {
|
||||||
|
fn new(client: &'a Client, id: Id) -> Self {
|
||||||
|
UpdateDocument {
|
||||||
|
client,
|
||||||
|
params: UpdateDocumentParams {
|
||||||
|
id,
|
||||||
|
title: None,
|
||||||
|
text: None,
|
||||||
|
edit_mode: None,
|
||||||
|
find_text: None,
|
||||||
|
collection_id: None,
|
||||||
|
publish: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a new title.
|
||||||
|
pub fn title(mut self, title: impl Into<String>) -> Self {
|
||||||
|
self.params.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the markdown text to apply, according to `edit_mode` (default: replace).
|
||||||
|
pub fn text(mut self, text: impl Into<String>) -> Self {
|
||||||
|
self.params.text = Some(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controls how `text` is applied to the existing content.
|
||||||
|
///
|
||||||
|
/// When set to [`TextEditMode::Patch`], [`Self::find_text`] must also be set.
|
||||||
|
pub fn edit_mode(mut self, mode: TextEditMode) -> Self {
|
||||||
|
self.params.edit_mode = Some(mode);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text to find and replace when `edit_mode` is [`TextEditMode::Patch`].
|
||||||
|
pub fn find_text(mut self, text: impl Into<String>) -> Self {
|
||||||
|
self.params.find_text = Some(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the document to a different collection.
|
||||||
|
pub fn collection_id(mut self, id: impl Into<Id>) -> Self {
|
||||||
|
self.params.collection_id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to publish the document, if it was a draft.
|
||||||
|
pub fn publish(mut self, publish: bool) -> Self {
|
||||||
|
self.params.publish = Some(publish);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the update request.
|
||||||
|
pub async fn send(self) -> Result<Document> {
|
||||||
|
self.client.rpc(UPDATE, &self.params).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Resource-scoped access to the Outline API, e.g. `client.documents().list()`.
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod collections;
|
||||||
|
mod documents;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
pub use auth::AuthApi;
|
||||||
|
pub use collections::{CollectionsApi, ListCollections, ListCollectionsParams};
|
||||||
|
pub use documents::{
|
||||||
|
CreateDocument, CreateDocumentParams, DocumentRef, DocumentsApi, ListDocuments,
|
||||||
|
ListDocumentsParams, SearchDocuments, SearchDocumentsParams, UpdateDocument,
|
||||||
|
UpdateDocumentParams,
|
||||||
|
};
|
||||||
|
pub use users::{ListUsers, ListUsersParams, UsersApi};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::client::{Client, MethodDef};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{Id, SortDirection, User, UserRole};
|
||||||
|
use crate::page::{DEFAULT_PAGE_SIZE, Page, PageParams, Paginated, Paginator};
|
||||||
|
|
||||||
|
const LIST: MethodDef = MethodDef {
|
||||||
|
name: "users.list",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
const INFO: MethodDef = MethodDef {
|
||||||
|
name: "users.info",
|
||||||
|
idempotent: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Access to the `users.*` endpoints.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UsersApi<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> UsersApi<'a> {
|
||||||
|
pub(crate) fn new(client: &'a Client) -> Self {
|
||||||
|
UsersApi { client }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a single user by id.
|
||||||
|
pub async fn info(&self, id: impl Into<Id>) -> Result<User> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Params {
|
||||||
|
id: Id,
|
||||||
|
}
|
||||||
|
self.client.rpc(INFO, &Params { id: id.into() }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts building a `users.list` request.
|
||||||
|
pub fn list(&self) -> ListUsers<'a> {
|
||||||
|
ListUsers::new(self.client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request parameters for `users.list`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListUsersParams {
|
||||||
|
/// Pagination parameters.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub page: PageParams,
|
||||||
|
/// Filters results by name or email.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub query: Option<String>,
|
||||||
|
/// Restricts results to the given email addresses.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub emails: Vec<String>,
|
||||||
|
/// Restricts results to users with the given role.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub role: Option<UserRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Paginated for ListUsersParams {
|
||||||
|
fn page_params_mut(&mut self) -> &mut PageParams {
|
||||||
|
&mut self.page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A builder for `users.list`, obtained from [`UsersApi::list`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ListUsers<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
params: ListUsersParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ListUsers<'a> {
|
||||||
|
fn new(client: &'a Client) -> Self {
|
||||||
|
ListUsers {
|
||||||
|
client,
|
||||||
|
params: ListUsersParams::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters results by name or email.
|
||||||
|
pub fn query(mut self, query: impl Into<String>) -> Self {
|
||||||
|
self.params.query = Some(query.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to the given email addresses.
|
||||||
|
pub fn emails(mut self, emails: impl IntoIterator<Item = String>) -> Self {
|
||||||
|
self.params.emails = emails.into_iter().collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts results to users with the given role.
|
||||||
|
pub fn role(mut self, role: UserRole) -> Self {
|
||||||
|
self.params.role = Some(role);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorts results by the given field (e.g. `"name"`) and direction.
|
||||||
|
pub fn sort(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
|
||||||
|
self.params.page.sort = Some(field.into());
|
||||||
|
self.params.page.direction = Some(direction);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the maximum number of results per page (server default: 25).
|
||||||
|
pub fn limit(mut self, limit: u32) -> Self {
|
||||||
|
self.params.page.limit = Some(limit);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of results to skip.
|
||||||
|
pub fn offset(mut self, offset: u32) -> Self {
|
||||||
|
self.params.page.offset = Some(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single page of results.
|
||||||
|
pub async fn send(self) -> Result<Page<User>> {
|
||||||
|
self.client.rpc_list(LIST, &self.params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [`Paginator`] that fetches successive pages, using [`Self::limit`]
|
||||||
|
/// as the page size (server default: 25) if it was set.
|
||||||
|
pub fn paginate(self) -> Paginator<'a, ListUsersParams, User> {
|
||||||
|
let page_size = self.params.page.limit.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||||
|
Paginator::new(self.client, LIST, self.params, page_size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
|
||||||
|
/// Credentials used to authenticate requests to the Outline API.
|
||||||
|
///
|
||||||
|
/// Both an API key (created under Settings → API & Apps) and an OAuth 2.0
|
||||||
|
/// access token are sent the same way (`Authorization: Bearer <token>`), so
|
||||||
|
/// this type simply distinguishes them for documentation purposes.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum Auth {
|
||||||
|
/// A personal or workspace API key, in the form `ol_api_...`.
|
||||||
|
ApiKey(SecretString),
|
||||||
|
/// An OAuth 2.0 access token obtained via the authorization code flow.
|
||||||
|
AccessToken(SecretString),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Auth {
|
||||||
|
/// Creates credentials from an API key.
|
||||||
|
pub fn api_key(key: impl Into<String>) -> Self {
|
||||||
|
Auth::ApiKey(SecretString::from(key.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates credentials from an OAuth 2.0 access token.
|
||||||
|
pub fn access_token(token: impl Into<String>) -> Self {
|
||||||
|
Auth::AccessToken(SecretString::from(token.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bearer_header_value(&self) -> String {
|
||||||
|
let secret = match self {
|
||||||
|
Auth::ApiKey(secret) | Auth::AccessToken(secret) => secret,
|
||||||
|
};
|
||||||
|
format!("Bearer {}", secret.expose_secret())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Auth {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Auth::ApiKey(_) => f.write_str("Auth::ApiKey(***)"),
|
||||||
|
Auth::AccessToken(_) => f.write_str("Auth::AccessToken(***)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::auth::Auth;
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: &str = "https://app.getoutline.com";
|
||||||
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Builds a configured [`Client`].
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// # fn main() -> outline::Result<()> {
|
||||||
|
/// let client = outline::Client::builder()
|
||||||
|
/// .api_key("ol_api_...")
|
||||||
|
/// .build()?;
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ClientBuilder {
|
||||||
|
base_url: String,
|
||||||
|
auth: Option<Auth>,
|
||||||
|
timeout: Duration,
|
||||||
|
connect_timeout: Option<Duration>,
|
||||||
|
user_agent: String,
|
||||||
|
http_client: Option<reqwest::Client>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClientBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
ClientBuilder {
|
||||||
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
|
auth: None,
|
||||||
|
timeout: DEFAULT_TIMEOUT,
|
||||||
|
connect_timeout: None,
|
||||||
|
user_agent: concat!("outline-rs/", env!("CARGO_PKG_VERSION")).to_string(),
|
||||||
|
http_client: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientBuilder {
|
||||||
|
/// Creates a new builder with defaults (Outline Cloud, 30s timeout, no credentials).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the base URL of the Outline instance to talk to.
|
||||||
|
///
|
||||||
|
/// Accepts the instance root (`https://app.getoutline.com`), with or
|
||||||
|
/// without a trailing slash, or an already-suffixed `/api` path — all
|
||||||
|
/// forms are normalized to the same request base. Defaults to Outline
|
||||||
|
/// Cloud. Non-`https` URLs are only accepted for `localhost`/`127.0.0.1`
|
||||||
|
/// (useful for pointing at a local mock server in tests).
|
||||||
|
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = base_url.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticates using an Outline API key (`ol_api_...`).
|
||||||
|
pub fn api_key(mut self, key: impl Into<String>) -> Self {
|
||||||
|
self.auth = Some(Auth::api_key(key));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticates using an OAuth 2.0 access token.
|
||||||
|
pub fn access_token(mut self, token: impl Into<String>) -> Self {
|
||||||
|
self.auth = Some(Auth::access_token(token));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the credentials directly.
|
||||||
|
pub fn auth(mut self, auth: Auth) -> Self {
|
||||||
|
self.auth = Some(auth);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the per-request timeout (default: 30 seconds).
|
||||||
|
pub fn timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the connection timeout.
|
||||||
|
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.connect_timeout = Some(timeout);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the `User-Agent` header sent with every request.
|
||||||
|
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
|
||||||
|
self.user_agent = user_agent.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplies a preconfigured [`reqwest::Client`] instead of building one
|
||||||
|
/// from `timeout`/`connect_timeout`/`user_agent`.
|
||||||
|
///
|
||||||
|
/// Useful for injecting a client with custom proxy or TLS settings.
|
||||||
|
pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
|
||||||
|
self.http_client = Some(http_client);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the [`Client`], validating the base URL and credentials.
|
||||||
|
pub fn build(self) -> Result<Client> {
|
||||||
|
let base = normalize_base_url(&self.base_url)?;
|
||||||
|
let auth = self.auth.ok_or_else(|| {
|
||||||
|
Error::Config(
|
||||||
|
"missing credentials: call `.api_key(...)` or `.access_token(...)`".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let http = match self.http_client {
|
||||||
|
Some(http) => http,
|
||||||
|
None => {
|
||||||
|
let mut http_builder = reqwest::Client::builder()
|
||||||
|
.timeout(self.timeout)
|
||||||
|
.user_agent(self.user_agent)
|
||||||
|
// The API only redirects on `attachments.redirect` /
|
||||||
|
// `fileOperations.redirect`, where the `Location` header
|
||||||
|
// itself is the useful result — never the redirect target.
|
||||||
|
.redirect(reqwest::redirect::Policy::none());
|
||||||
|
if let Some(connect_timeout) = self.connect_timeout {
|
||||||
|
http_builder = http_builder.connect_timeout(connect_timeout);
|
||||||
|
}
|
||||||
|
http_builder.build().map_err(|source| {
|
||||||
|
Error::Config(format!("failed to build HTTP client: {source}"))
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Client::from_parts(http, base, auth))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes a user-supplied base URL to an absolute request base ending in
|
||||||
|
/// `/api/`, e.g. `https://app.getoutline.com` -> `https://app.getoutline.com/api/`.
|
||||||
|
///
|
||||||
|
/// The trailing slash matters: [`Url::join`] replaces the last path segment
|
||||||
|
/// of a base URL that doesn't end in `/`, which would otherwise silently
|
||||||
|
/// drop `/api` from every request.
|
||||||
|
fn normalize_base_url(input: &str) -> Result<Url> {
|
||||||
|
let mut url = Url::parse(input)
|
||||||
|
.map_err(|source| Error::Config(format!("invalid base url `{input}`: {source}")))?;
|
||||||
|
|
||||||
|
let is_local = matches!(
|
||||||
|
url.host_str(),
|
||||||
|
Some("localhost") | Some("127.0.0.1") | Some("::1")
|
||||||
|
);
|
||||||
|
if url.scheme() != "https" && !is_local {
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"base url `{input}` must use https (non-https is only allowed for localhost)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = url.path().trim_end_matches('/');
|
||||||
|
let with_api = if trimmed.ends_with("/api") || trimmed == "api" {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{trimmed}/api")
|
||||||
|
};
|
||||||
|
url.set_path(&format!("{with_api}/"));
|
||||||
|
url.set_query(None);
|
||||||
|
url.set_fragment(None);
|
||||||
|
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_bare_host() {
|
||||||
|
let url = normalize_base_url("https://app.getoutline.com").unwrap();
|
||||||
|
assert_eq!(url.as_str(), "https://app.getoutline.com/api/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_trailing_slash() {
|
||||||
|
let url = normalize_base_url("https://wiki.example.com/").unwrap();
|
||||||
|
assert_eq!(url.as_str(), "https://wiki.example.com/api/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_existing_api_suffix() {
|
||||||
|
let url = normalize_base_url("https://wiki.example.com/api").unwrap();
|
||||||
|
assert_eq!(url.as_str(), "https://wiki.example.com/api/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_existing_api_suffix_with_trailing_slash() {
|
||||||
|
let url = normalize_base_url("https://wiki.example.com/api/").unwrap();
|
||||||
|
assert_eq!(url.as_str(), "https://wiki.example.com/api/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_plain_http_for_localhost() {
|
||||||
|
let url = normalize_base_url("http://127.0.0.1:38211").unwrap();
|
||||||
|
assert_eq!(url.as_str(), "http://127.0.0.1:38211/api/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_plain_http_for_remote_hosts() {
|
||||||
|
let err = normalize_base_url("http://wiki.example.com").unwrap_err();
|
||||||
|
assert!(matches!(err, Error::Config(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_produces_expected_method_url() {
|
||||||
|
let base = normalize_base_url("https://app.getoutline.com").unwrap();
|
||||||
|
let joined = base.join("documents.list").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
joined.as_str(),
|
||||||
|
"https://app.getoutline.com/api/documents.list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_requires_credentials() {
|
||||||
|
let err = ClientBuilder::new().build().unwrap_err();
|
||||||
|
assert!(matches!(err, Error::Config(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::api::{AuthApi, CollectionsApi, DocumentsApi, UsersApi};
|
||||||
|
use crate::auth::Auth;
|
||||||
|
use crate::builder::ClientBuilder;
|
||||||
|
use crate::envelope::Envelope;
|
||||||
|
use crate::error::{ApiError, Error, Result};
|
||||||
|
use crate::page::Page;
|
||||||
|
use crate::rate_limit::RateLimit;
|
||||||
|
|
||||||
|
/// An RPC method on the Outline API, e.g. `documents.list`.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct MethodDef {
|
||||||
|
/// The method name, used as the request path (`POST /api/<name>`).
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Whether this call is safe to retry automatically.
|
||||||
|
///
|
||||||
|
/// Currently informational only — used once automatic retries land.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub idempotent: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client for the Outline API.
|
||||||
|
///
|
||||||
|
/// Cheaply cloneable: internally reference-counted, so cloning shares the
|
||||||
|
/// same connection pool and configuration. All methods take `&self`, making
|
||||||
|
/// `Client` safe to share across threads and hold in application state
|
||||||
|
/// (CLI or GUI) without wrapping it in a mutex.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Client {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Inner {
|
||||||
|
pub(crate) http: reqwest::Client,
|
||||||
|
pub(crate) base: Url,
|
||||||
|
pub(crate) auth: Auth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Client {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Client")
|
||||||
|
.field("base_url", &self.inner.base.as_str())
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Creates a client for the Outline Cloud API (`https://app.getoutline.com`)
|
||||||
|
/// authenticated with the given API key.
|
||||||
|
///
|
||||||
|
/// For self-hosted instances or OAuth access tokens, use [`Client::builder`].
|
||||||
|
pub fn new(api_key: impl Into<String>) -> Result<Self> {
|
||||||
|
ClientBuilder::new().api_key(api_key).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a client from the `OUTLINE_API_KEY` (required) and `OUTLINE_URL`
|
||||||
|
/// (optional, defaults to Outline Cloud) environment variables.
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let api_key = std::env::var("OUTLINE_API_KEY")
|
||||||
|
.map_err(|_| Error::Config("OUTLINE_API_KEY environment variable is not set".into()))?;
|
||||||
|
let mut builder = ClientBuilder::new().api_key(api_key);
|
||||||
|
if let Ok(url) = std::env::var("OUTLINE_URL") {
|
||||||
|
builder = builder.base_url(url);
|
||||||
|
}
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts building a client with custom configuration (base URL, timeouts, ...).
|
||||||
|
pub fn builder() -> ClientBuilder {
|
||||||
|
ClientBuilder::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The normalized base URL requests are sent to, e.g. `https://app.getoutline.com/api/`.
|
||||||
|
pub fn base_url(&self) -> &Url {
|
||||||
|
&self.inner.base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access to the `auth.*` endpoints.
|
||||||
|
pub fn auth(&self) -> AuthApi<'_> {
|
||||||
|
AuthApi::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access to the `documents.*` endpoints.
|
||||||
|
pub fn documents(&self) -> DocumentsApi<'_> {
|
||||||
|
DocumentsApi::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access to the `collections.*` endpoints.
|
||||||
|
pub fn collections(&self) -> CollectionsApi<'_> {
|
||||||
|
CollectionsApi::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access to the `users.*` endpoints.
|
||||||
|
pub fn users(&self) -> UsersApi<'_> {
|
||||||
|
UsersApi::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_parts(http: reqwest::Client, base: Url, auth: Auth) -> Self {
|
||||||
|
Client {
|
||||||
|
inner: Arc::new(Inner { http, base, auth }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls an RPC method that returns a single object, and returns its `data` field.
|
||||||
|
pub(crate) async fn rpc<P, R>(&self, method: MethodDef, params: &P) -> Result<R>
|
||||||
|
where
|
||||||
|
P: Serialize + ?Sized,
|
||||||
|
R: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let body = self.send(method, params).await?;
|
||||||
|
serde_json::from_slice::<Envelope<R>>(&body)
|
||||||
|
.map(|envelope| envelope.data)
|
||||||
|
.map_err(|source| Error::decode(method.name, source, &body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls an RPC list method, returning a [`Page`] with items, pagination and policies.
|
||||||
|
pub(crate) async fn rpc_list<P, R>(&self, method: MethodDef, params: &P) -> Result<Page<R>>
|
||||||
|
where
|
||||||
|
P: Serialize + ?Sized,
|
||||||
|
R: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let body = self.send(method, params).await?;
|
||||||
|
let envelope = serde_json::from_slice::<Envelope<Vec<R>>>(&body)
|
||||||
|
.map_err(|source| Error::decode(method.name, source, &body))?;
|
||||||
|
Ok(Page {
|
||||||
|
items: envelope.data,
|
||||||
|
pagination: envelope.pagination.unwrap_or_default(),
|
||||||
|
policies: envelope.policies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls an RPC method for its side effect only, ignoring the shape of its response body.
|
||||||
|
///
|
||||||
|
/// Used for endpoints such as `documents.delete` whose success payload
|
||||||
|
/// isn't a `{data: ...}` envelope.
|
||||||
|
pub(crate) async fn rpc_ok<P>(&self, method: MethodDef, params: &P) -> Result<()>
|
||||||
|
where
|
||||||
|
P: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
self.send(method, params).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the request and returns the raw response body, translating a
|
||||||
|
/// non-2xx status into [`Error::Api`].
|
||||||
|
async fn send<P>(&self, method: MethodDef, params: &P) -> Result<bytes::Bytes>
|
||||||
|
where
|
||||||
|
P: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
let url = self.inner.base.join(method.name).map_err(|source| {
|
||||||
|
Error::Config(format!("invalid method name `{}`: {source}", method.name))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http
|
||||||
|
.post(url)
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
self.inner.auth.bearer_header_value(),
|
||||||
|
)
|
||||||
|
.json(params)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::Transport {
|
||||||
|
method: method.name,
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let rate_limit = RateLimit::from_headers(response.headers());
|
||||||
|
let body = response.bytes().await.map_err(|source| Error::Transport {
|
||||||
|
method: method.name,
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
Ok(body)
|
||||||
|
} else {
|
||||||
|
let api = serde_json::from_slice::<ApiError>(&body).unwrap_or_else(|_| ApiError {
|
||||||
|
error: status
|
||||||
|
.canonical_reason()
|
||||||
|
.unwrap_or("unknown_error")
|
||||||
|
.to_string(),
|
||||||
|
message: Some(String::from_utf8_lossy(&body).into_owned()),
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
Err(Error::Api(Box::new(crate::error::ApiErrorContext {
|
||||||
|
method: method.name,
|
||||||
|
status,
|
||||||
|
api,
|
||||||
|
rate_limit,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::models::Policy;
|
||||||
|
use crate::page::Pagination;
|
||||||
|
|
||||||
|
/// The success envelope returned by the Outline API (`ok: true`).
|
||||||
|
///
|
||||||
|
/// `ok` and `status` are intentionally not modeled here: the HTTP status
|
||||||
|
/// code and success/failure are already known from the transport response
|
||||||
|
/// by the time this is deserialized.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct Envelope<T> {
|
||||||
|
pub data: T,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pagination: Option<Pagination>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub policies: Vec<Policy>,
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Result type used throughout this crate.
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// The error type returned by all fallible operations in this crate.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Error {
|
||||||
|
/// The client was misconfigured (e.g. an invalid base URL).
|
||||||
|
#[error("invalid client configuration: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
/// The HTTP request itself failed (connection, TLS, timeout, ...).
|
||||||
|
#[error("HTTP transport error calling `{method}`: {source}")]
|
||||||
|
Transport {
|
||||||
|
/// The Outline RPC method that was being called, e.g. `documents.info`.
|
||||||
|
method: &'static str,
|
||||||
|
/// The underlying transport error.
|
||||||
|
#[source]
|
||||||
|
source: reqwest::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The response body could not be decoded as the expected shape.
|
||||||
|
#[error("failed to decode response of `{method}`: {source}")]
|
||||||
|
Decode {
|
||||||
|
/// The Outline RPC method that was being called.
|
||||||
|
method: &'static str,
|
||||||
|
/// The underlying JSON deserialization error.
|
||||||
|
#[source]
|
||||||
|
source: serde_json::Error,
|
||||||
|
/// The raw response body (truncated), for debugging.
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The Outline API returned an error envelope (`ok: false`).
|
||||||
|
#[error("{0}")]
|
||||||
|
Api(Box<ApiErrorContext>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The details behind [`Error::Api`], boxed to keep [`Error`] itself small.
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct ApiErrorContext {
|
||||||
|
/// The Outline RPC method that was being called.
|
||||||
|
pub method: &'static str,
|
||||||
|
/// The HTTP status code of the response.
|
||||||
|
pub status: http::StatusCode,
|
||||||
|
/// The parsed error envelope.
|
||||||
|
pub api: ApiError,
|
||||||
|
/// Rate limit information, if present on the response.
|
||||||
|
pub rate_limit: Option<crate::rate_limit::RateLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiErrorContext {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"Outline API error {} on `{}`: {}",
|
||||||
|
self.status, self.method, self.api
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The maximum number of bytes of a response body kept for [`Error::Decode`].
|
||||||
|
const MAX_DEBUG_BODY_LEN: usize = 4096;
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub(crate) fn decode(method: &'static str, source: serde_json::Error, body: &[u8]) -> Self {
|
||||||
|
let mut body = String::from_utf8_lossy(body).into_owned();
|
||||||
|
if body.len() > MAX_DEBUG_BODY_LEN {
|
||||||
|
body.truncate(MAX_DEBUG_BODY_LEN);
|
||||||
|
body.push_str("... (truncated)");
|
||||||
|
}
|
||||||
|
Error::Decode {
|
||||||
|
method,
|
||||||
|
source,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A coarse classification of this error, useful for `match`-free handling.
|
||||||
|
pub fn kind(&self) -> ErrorKind {
|
||||||
|
match self {
|
||||||
|
Error::Config(_) => ErrorKind::Config,
|
||||||
|
Error::Transport { .. } => ErrorKind::Transport,
|
||||||
|
Error::Decode { .. } => ErrorKind::Decode,
|
||||||
|
Error::Api(ctx) => match ctx.status {
|
||||||
|
http::StatusCode::UNAUTHORIZED => ErrorKind::Unauthenticated,
|
||||||
|
http::StatusCode::FORBIDDEN => ErrorKind::Unauthorized,
|
||||||
|
http::StatusCode::NOT_FOUND => ErrorKind::NotFound,
|
||||||
|
http::StatusCode::BAD_REQUEST => ErrorKind::Validation,
|
||||||
|
http::StatusCode::TOO_MANY_REQUESTS => ErrorKind::RateLimited,
|
||||||
|
s if s.is_server_error() => ErrorKind::Server,
|
||||||
|
_ => match ctx.api.error.as_str() {
|
||||||
|
"rate_limit_exceeded" => ErrorKind::RateLimited,
|
||||||
|
"authentication_required" => ErrorKind::Unauthenticated,
|
||||||
|
"permission_required" => ErrorKind::Unauthorized,
|
||||||
|
"not_found" => ErrorKind::NotFound,
|
||||||
|
"validation_error" => ErrorKind::Validation,
|
||||||
|
_ => ErrorKind::Other,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HTTP status code associated with this error, if any.
|
||||||
|
pub fn status(&self) -> Option<http::StatusCode> {
|
||||||
|
match self {
|
||||||
|
Error::Api(ctx) => Some(ctx.status),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this error represents a "not found" response.
|
||||||
|
pub fn is_not_found(&self) -> bool {
|
||||||
|
self.kind() == ErrorKind::NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The duration the caller should wait before retrying, if the server indicated one.
|
||||||
|
pub fn retry_after(&self) -> Option<Duration> {
|
||||||
|
match self {
|
||||||
|
Error::Api(ctx) => ctx.rate_limit.as_ref().and_then(|rl| rl.retry_after),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this error is generally safe to retry.
|
||||||
|
pub fn is_retryable(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.kind(),
|
||||||
|
ErrorKind::RateLimited | ErrorKind::Server | ErrorKind::Transport
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A coarse classification of [`Error`] variants.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ErrorKind {
|
||||||
|
/// No or invalid credentials were supplied.
|
||||||
|
Unauthenticated,
|
||||||
|
/// The credentials were valid but lack permission for this action.
|
||||||
|
Unauthorized,
|
||||||
|
/// The requested resource does not exist.
|
||||||
|
NotFound,
|
||||||
|
/// The request was malformed or failed server-side validation.
|
||||||
|
Validation,
|
||||||
|
/// The client is being rate limited.
|
||||||
|
RateLimited,
|
||||||
|
/// The server encountered an internal error.
|
||||||
|
Server,
|
||||||
|
/// The HTTP transport failed (connection, TLS, timeout, ...).
|
||||||
|
Transport,
|
||||||
|
/// The response body could not be decoded.
|
||||||
|
Decode,
|
||||||
|
/// The client was misconfigured.
|
||||||
|
Config,
|
||||||
|
/// Any other API error not covered above.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error envelope returned by the Outline API when `ok` is `false`.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct ApiError {
|
||||||
|
/// A short machine-readable error code, e.g. `"rate_limit_exceeded"`.
|
||||||
|
pub error: String,
|
||||||
|
/// A human-readable error message.
|
||||||
|
#[serde(default)]
|
||||||
|
pub message: Option<String>,
|
||||||
|
/// Additional error-specific data, if any.
|
||||||
|
#[serde(default)]
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match &self.message {
|
||||||
|
Some(message) => write!(f, "{} ({message})", self.error),
|
||||||
|
None => write!(f, "{}", self.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs, missing_debug_implementations, clippy::all)]
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod auth;
|
||||||
|
mod builder;
|
||||||
|
mod client;
|
||||||
|
mod envelope;
|
||||||
|
mod error;
|
||||||
|
mod rate_limit;
|
||||||
|
|
||||||
|
pub mod models;
|
||||||
|
pub mod page;
|
||||||
|
|
||||||
|
pub use api::{
|
||||||
|
AuthApi, CollectionsApi, CreateDocument, CreateDocumentParams, DocumentRef, DocumentsApi,
|
||||||
|
ListCollections, ListCollectionsParams, ListDocuments, ListDocumentsParams, ListUsers,
|
||||||
|
ListUsersParams, SearchDocuments, SearchDocumentsParams, UpdateDocument, UpdateDocumentParams,
|
||||||
|
UsersApi,
|
||||||
|
};
|
||||||
|
pub use auth::Auth;
|
||||||
|
pub use builder::ClientBuilder;
|
||||||
|
pub use client::Client;
|
||||||
|
pub use error::{ApiError, ApiErrorContext, Error, ErrorKind, Result};
|
||||||
|
pub use rate_limit::RateLimit;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::common::{Id, Permission, Timestamp, UnknownVariant};
|
||||||
|
use super::user::User;
|
||||||
|
|
||||||
|
/// A collection: a top-level grouping of documents in Outline.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct Collection {
|
||||||
|
/// Unique identifier for the collection.
|
||||||
|
pub id: Id,
|
||||||
|
/// The relative URL path at which the collection can be accessed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// A short unique identifier that can be used in place of the UUID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url_id: Option<String>,
|
||||||
|
/// The name of the collection.
|
||||||
|
pub name: String,
|
||||||
|
/// A description of the collection, may contain markdown formatting.
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// The position of the collection in the sidebar.
|
||||||
|
#[serde(default)]
|
||||||
|
pub index: Option<String>,
|
||||||
|
/// A color representing the collection, in `#RRGGBB` format.
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
/// An icon name or emoji associated with the collection.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
/// The sharing permission level for this collection.
|
||||||
|
#[serde(default)]
|
||||||
|
pub permission: Option<Permission>,
|
||||||
|
/// Whether public document sharing is enabled in this collection.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sharing: bool,
|
||||||
|
/// Whether commenting is enabled in this collection.
|
||||||
|
#[serde(default)]
|
||||||
|
pub commenting: Option<bool>,
|
||||||
|
/// The date and time this collection was created.
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<Timestamp>,
|
||||||
|
/// The date and time this collection was last changed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<Timestamp>,
|
||||||
|
/// The date and time this collection was archived, if applicable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<Timestamp>,
|
||||||
|
/// The user who archived this collection, if applicable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_by: Option<User>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status a collection may be filtered by in `collections.list`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum CollectionStatus {
|
||||||
|
/// The collection has been archived.
|
||||||
|
Archived,
|
||||||
|
/// A value not recognized by this version of the crate.
|
||||||
|
#[serde(untagged)]
|
||||||
|
Unknown(UnknownVariant),
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A timestamp as returned by the Outline API (ISO 8601 / RFC 3339, UTC).
|
||||||
|
pub type Timestamp = chrono::DateTime<chrono::Utc>;
|
||||||
|
|
||||||
|
/// An opaque Outline resource identifier.
|
||||||
|
///
|
||||||
|
/// Outline generally uses UUIDs for `id` fields, but some fields
|
||||||
|
/// (`urlId`, `shareId`) use a different, shorter format. Modeling `Id` as a
|
||||||
|
/// transparent string newtype (rather than [`uuid::Uuid`]) means an
|
||||||
|
/// unexpected value from a self-hosted or future Outline version cannot
|
||||||
|
/// fail deserialization of an entire response.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct Id(String);
|
||||||
|
|
||||||
|
impl Id {
|
||||||
|
/// Creates a new [`Id`] from any string-like value.
|
||||||
|
pub fn new(value: impl Into<String>) -> Self {
|
||||||
|
Id(value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identifier as a string slice.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses this identifier as a [`uuid::Uuid`], if it is one.
|
||||||
|
#[cfg(feature = "uuid")]
|
||||||
|
pub fn as_uuid(&self) -> Option<uuid::Uuid> {
|
||||||
|
self.0.parse().ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Id {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Id(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Id {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Id(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Id> for Id {
|
||||||
|
fn from(value: &Id) -> Self {
|
||||||
|
value.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Id {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sharing/collection permission level.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Permission {
|
||||||
|
/// Read-only access.
|
||||||
|
Read,
|
||||||
|
/// Read and write access.
|
||||||
|
ReadWrite,
|
||||||
|
/// A value not recognized by this version of the crate.
|
||||||
|
#[serde(untagged)]
|
||||||
|
Unknown(UnknownVariant),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A string value that did not match any known enum variant.
|
||||||
|
///
|
||||||
|
/// Carrying the original string (rather than silently discarding it via
|
||||||
|
/// `#[serde(other)]`) keeps the API forward-compatible with server versions
|
||||||
|
/// newer than this crate: unrecognized values round-trip instead of causing
|
||||||
|
/// a hard deserialization failure.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct UnknownVariant(pub String);
|
||||||
|
|
||||||
|
impl fmt::Display for UnknownVariant {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort direction used by list endpoints.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
|
pub enum SortDirection {
|
||||||
|
/// Ascending order.
|
||||||
|
Asc,
|
||||||
|
/// Descending order.
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either a boolean or a list of membership names granting an ability.
|
||||||
|
///
|
||||||
|
/// The Outline API models `Policy.abilities` values as `oneOf [boolean, string[]]`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Ability {
|
||||||
|
/// The ability is simply allowed or denied.
|
||||||
|
Allowed(bool),
|
||||||
|
/// The ability is granted through the listed memberships.
|
||||||
|
Memberships(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ability {
|
||||||
|
/// Whether this ability is granted, treating a non-empty membership list as `true`.
|
||||||
|
pub fn is_allowed(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Ability::Allowed(allowed) => *allowed,
|
||||||
|
Ability::Memberships(memberships) => !memberships.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describes which actions the current actor may perform on a resource.
|
||||||
|
///
|
||||||
|
/// Returned alongside `data` on most endpoints. `policy.id` matches the
|
||||||
|
/// `id` of the resource it describes.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Policy {
|
||||||
|
/// The id of the resource this policy describes.
|
||||||
|
pub id: Id,
|
||||||
|
/// A map of ability name (e.g. `"update"`, `"delete"`) to its status.
|
||||||
|
#[serde(default)]
|
||||||
|
pub abilities: BTreeMap<String, Ability>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Policy {
|
||||||
|
/// Whether the current actor is allowed to perform `ability` on this resource.
|
||||||
|
///
|
||||||
|
/// Returns `false` if the ability is not present in the map at all.
|
||||||
|
pub fn can(&self, ability: &str) -> bool {
|
||||||
|
self.abilities.get(ability).is_some_and(Ability::is_allowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node in a collection's document tree, as returned by `collections.documents`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct NavigationNode {
|
||||||
|
/// The document id.
|
||||||
|
pub id: Id,
|
||||||
|
/// The short, URL-friendly id of the document.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url_id: Option<String>,
|
||||||
|
/// The document title.
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
/// Child documents nested under this one.
|
||||||
|
#[serde(default)]
|
||||||
|
pub children: Vec<NavigationNode>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::common::{Id, Timestamp, UnknownVariant};
|
||||||
|
use super::user::User;
|
||||||
|
|
||||||
|
/// A document within a collection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct Document {
|
||||||
|
/// Unique identifier for the document.
|
||||||
|
pub id: Id,
|
||||||
|
/// The collection this document belongs to, if published.
|
||||||
|
#[serde(default)]
|
||||||
|
pub collection_id: Option<Id>,
|
||||||
|
/// The document this is a child of, if any.
|
||||||
|
#[serde(default)]
|
||||||
|
pub parent_document_id: Option<Id>,
|
||||||
|
/// The title of the document.
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
/// Whether this document is displayed in a full-width view.
|
||||||
|
#[serde(default)]
|
||||||
|
pub full_width: bool,
|
||||||
|
/// An emoji or icon associated with the document.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
/// The color of the document icon, in `#RRGGBB` format.
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
/// The text content of the document, in markdown.
|
||||||
|
#[serde(default)]
|
||||||
|
pub text: Option<String>,
|
||||||
|
/// A URL path at which the document can be accessed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// A short unique id that can be used in place of the UUID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url_id: Option<String>,
|
||||||
|
/// Identifiers of users who have edited the document.
|
||||||
|
#[serde(default)]
|
||||||
|
pub collaborator_ids: Vec<Id>,
|
||||||
|
/// Task completion counts for the document, if it contains checklists.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tasks: Option<DocumentTasks>,
|
||||||
|
/// The revision number, incremented on every save.
|
||||||
|
#[serde(default)]
|
||||||
|
pub revision: Option<f64>,
|
||||||
|
/// The date and time this document was created.
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<Timestamp>,
|
||||||
|
/// The user who created this document.
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: Option<User>,
|
||||||
|
/// The date and time this document was last changed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<Timestamp>,
|
||||||
|
/// The user who last updated this document.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_by: Option<User>,
|
||||||
|
/// The date and time this document was published, if applicable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub published_at: Option<Timestamp>,
|
||||||
|
/// The date and time this document was archived, if applicable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<Timestamp>,
|
||||||
|
/// The date and time this document was deleted, if applicable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deleted_at: Option<Timestamp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Task completion counts for a document containing checklists.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DocumentTasks {
|
||||||
|
/// The number of completed tasks.
|
||||||
|
pub completed: u32,
|
||||||
|
/// The total number of tasks.
|
||||||
|
pub total: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The publication status a document may be filtered by.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum DocumentStatus {
|
||||||
|
/// The document has not been published yet.
|
||||||
|
Draft,
|
||||||
|
/// The document has been archived.
|
||||||
|
Archived,
|
||||||
|
/// The document is published and visible to the workspace.
|
||||||
|
Published,
|
||||||
|
/// A value not recognized by this version of the crate.
|
||||||
|
#[serde(untagged)]
|
||||||
|
Unknown(UnknownVariant),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the `text` field of `documents.update` should be applied.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum TextEditMode {
|
||||||
|
/// Append `text` to the end of the existing content.
|
||||||
|
Append,
|
||||||
|
/// Prepend `text` to the start of the existing content.
|
||||||
|
Prepend,
|
||||||
|
/// Replace the existing content with `text` (the default).
|
||||||
|
Replace,
|
||||||
|
/// Replace the first occurrence of `find_text` with `text`.
|
||||||
|
Patch,
|
||||||
|
/// A value not recognized by this version of the crate.
|
||||||
|
#[serde(untagged)]
|
||||||
|
Unknown(UnknownVariant),
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! Data models returned by the Outline API.
|
||||||
|
//!
|
||||||
|
//! Types in this module are intentionally decoupled from [`crate::Client`] so
|
||||||
|
//! that they can be stored, cloned and passed around independently of any
|
||||||
|
//! network connection (e.g. held in the state of a GUI application).
|
||||||
|
|
||||||
|
mod collection;
|
||||||
|
mod common;
|
||||||
|
mod document;
|
||||||
|
mod search;
|
||||||
|
mod team;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
pub use collection::*;
|
||||||
|
pub use common::*;
|
||||||
|
pub use document::*;
|
||||||
|
pub use search::*;
|
||||||
|
pub use team::*;
|
||||||
|
pub use user::*;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::document::Document;
|
||||||
|
|
||||||
|
/// A single result from `documents.search`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct SearchResult {
|
||||||
|
/// A short snippet of context from the document that includes the search query.
|
||||||
|
#[serde(default)]
|
||||||
|
pub context: Option<String>,
|
||||||
|
/// The relevance ranking used to order search results.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ranking: Option<f64>,
|
||||||
|
/// The matching document.
|
||||||
|
pub document: Document,
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::common::Id;
|
||||||
|
use super::user::{User, UserRole};
|
||||||
|
|
||||||
|
/// The payload returned by `auth.info`: the current API actor and their workspace.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct AuthInfo {
|
||||||
|
/// The user associated with the current API key or access token.
|
||||||
|
pub user: User,
|
||||||
|
/// The workspace the user belongs to.
|
||||||
|
pub team: Team,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An Outline workspace (formerly "team").
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct Team {
|
||||||
|
/// Unique identifier for the workspace.
|
||||||
|
pub id: Id,
|
||||||
|
/// The name of the workspace.
|
||||||
|
pub name: String,
|
||||||
|
/// A short description of the workspace.
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// The URL of the workspace's avatar image, if any.
|
||||||
|
#[serde(default)]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
/// Whether this workspace has share links globally enabled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sharing: bool,
|
||||||
|
/// The default role assigned to new members.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_user_role: Option<UserRole>,
|
||||||
|
/// The fully qualified URL at which this workspace can be accessed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// The subdomain at which this workspace can be accessed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub subdomain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication configuration for an Outline instance (`auth.config`).
|
||||||
|
///
|
||||||
|
/// This endpoint requires no authentication and is useful for discovering
|
||||||
|
/// available sign-in methods before a client has credentials.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
/// The name of the workspace.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// The hostname at which this workspace can be accessed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
/// Available single sign-on services.
|
||||||
|
#[serde(default)]
|
||||||
|
pub services: Vec<AuthService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single sign-on service available for authentication.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct AuthService {
|
||||||
|
/// The service identifier, e.g. `"slack"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: Option<String>,
|
||||||
|
/// The human-readable service name, e.g. `"Slack"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// The URL to redirect to in order to authenticate with this service.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_url: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::common::{Id, Timestamp, UnknownVariant};
|
||||||
|
|
||||||
|
/// A member of an Outline workspace.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct User {
|
||||||
|
/// Unique identifier for the user.
|
||||||
|
pub id: Id,
|
||||||
|
/// The user's display name.
|
||||||
|
pub name: String,
|
||||||
|
/// The URL of the user's avatar image, if any.
|
||||||
|
#[serde(default)]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
/// A color representing the user, used for avatars without an image.
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
/// The user's email address.
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
/// The user's role within the workspace.
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: Option<UserRole>,
|
||||||
|
/// Whether the user has been suspended.
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_suspended: bool,
|
||||||
|
/// The last time this user made an API request.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_active_at: Option<Timestamp>,
|
||||||
|
/// The date and time this user first signed in or was invited.
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<Timestamp>,
|
||||||
|
/// The date and time this user was last updated.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<Timestamp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user's role within a workspace.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum UserRole {
|
||||||
|
/// Full administrative access.
|
||||||
|
Admin,
|
||||||
|
/// A regular workspace member.
|
||||||
|
Member,
|
||||||
|
/// Read-only access to shared content.
|
||||||
|
Viewer,
|
||||||
|
/// External guest access.
|
||||||
|
Guest,
|
||||||
|
/// A value not recognized by this version of the crate.
|
||||||
|
#[serde(untagged)]
|
||||||
|
Unknown(UnknownVariant),
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
//! Pagination types shared by all `*.list` endpoints.
|
||||||
|
|
||||||
|
#[cfg(feature = "stream")]
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::client::{Client, MethodDef};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{Policy, SortDirection};
|
||||||
|
|
||||||
|
/// The default page size used when a [`Paginator`] size is not overridden.
|
||||||
|
pub(crate) const DEFAULT_PAGE_SIZE: u32 = 25;
|
||||||
|
|
||||||
|
/// Pagination and sorting parameters accepted by Outline's list endpoints.
|
||||||
|
///
|
||||||
|
/// Embed this via `#[serde(flatten)]` in a request params struct.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PageParams {
|
||||||
|
/// The maximum number of items to return.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
/// The number of items to skip before starting to return results.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
/// The field to sort by, e.g. `"updatedAt"`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sort: Option<String>,
|
||||||
|
/// The sort direction.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub direction: Option<SortDirection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implemented by request params structs that embed [`PageParams`], so that
|
||||||
|
/// [`Paginator`] can advance `offset` between pages without knowing about the
|
||||||
|
/// rest of the params shape.
|
||||||
|
pub trait Paginated {
|
||||||
|
/// Returns a mutable reference to the embedded page parameters.
|
||||||
|
fn page_params_mut(&mut self) -> &mut PageParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pagination metadata echoed back by the Outline API alongside list results.
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct Pagination {
|
||||||
|
/// The `limit` that was applied to this request.
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
/// The `offset` that was applied to this request.
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
/// A relative path that can be used to fetch the next page, if any.
|
||||||
|
///
|
||||||
|
/// Not part of the published OpenAPI schema; treat as best-effort and do
|
||||||
|
/// not rely on its absence to detect the last page (use the returned
|
||||||
|
/// item count instead, see [`Paginator`]).
|
||||||
|
#[serde(default)]
|
||||||
|
pub next_path: Option<String>,
|
||||||
|
/// The total number of items available, if known.
|
||||||
|
#[serde(default)]
|
||||||
|
pub total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single page of results from a list endpoint.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct Page<T> {
|
||||||
|
/// The items returned on this page.
|
||||||
|
pub items: Vec<T>,
|
||||||
|
/// Pagination metadata for this page.
|
||||||
|
pub pagination: Pagination,
|
||||||
|
/// Access-control policies for each returned item, if the endpoint provides them.
|
||||||
|
pub policies: Vec<Policy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates over all pages of a list endpoint, advancing `offset` automatically.
|
||||||
|
///
|
||||||
|
/// Obtained from a resource's list builder, e.g. `client.documents().list().paginate()`.
|
||||||
|
pub struct Paginator<'a, P, T> {
|
||||||
|
client: &'a Client,
|
||||||
|
method: MethodDef,
|
||||||
|
params: P,
|
||||||
|
limit: u32,
|
||||||
|
done: bool,
|
||||||
|
_marker: std::marker::PhantomData<fn() -> T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, P, T> std::fmt::Debug for Paginator<'a, P, T> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Paginator")
|
||||||
|
.field("method", &self.method.name)
|
||||||
|
.field("limit", &self.limit)
|
||||||
|
.field("done", &self.done)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, P, T> Paginator<'a, P, T>
|
||||||
|
where
|
||||||
|
P: Paginated + Serialize + Clone,
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
pub(crate) fn new(client: &'a Client, method: MethodDef, mut params: P, limit: u32) -> Self {
|
||||||
|
{
|
||||||
|
let page = params.page_params_mut();
|
||||||
|
page.limit = Some(limit);
|
||||||
|
if page.offset.is_none() {
|
||||||
|
page.offset = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Paginator {
|
||||||
|
client,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
limit,
|
||||||
|
done: false,
|
||||||
|
_marker: std::marker::PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the next page, or `None` if there are no more results.
|
||||||
|
///
|
||||||
|
/// A page is considered the last one whenever it returns fewer items
|
||||||
|
/// than the requested `limit` (including zero) — the API's `nextPath`
|
||||||
|
/// hint is not part of the published schema and is not used here.
|
||||||
|
pub async fn next_page(&mut self) -> Result<Option<Page<T>>> {
|
||||||
|
if self.done {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let page = self
|
||||||
|
.client
|
||||||
|
.rpc_list::<P, T>(self.method, &self.params)
|
||||||
|
.await?;
|
||||||
|
let received = page.items.len() as u32;
|
||||||
|
if let Some(offset) = self.params.page_params_mut().offset {
|
||||||
|
self.params.page_params_mut().offset = Some(offset + received);
|
||||||
|
}
|
||||||
|
if received == 0 || received < self.limit {
|
||||||
|
self.done = true;
|
||||||
|
}
|
||||||
|
Ok(Some(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches every remaining page and collects all items into a single `Vec`.
|
||||||
|
pub async fn collect_all(mut self) -> Result<Vec<T>> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
while let Some(page) = self.next_page().await? {
|
||||||
|
items.extend(page.items);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns this paginator into a [`futures_core::Stream`] of individual items,
|
||||||
|
/// fetching further pages lazily as the stream is polled.
|
||||||
|
#[cfg(feature = "stream")]
|
||||||
|
pub fn into_stream(self) -> impl futures_core::Stream<Item = Result<T>> + 'a
|
||||||
|
where
|
||||||
|
P: 'a,
|
||||||
|
T: 'a,
|
||||||
|
{
|
||||||
|
futures_util::stream::unfold(
|
||||||
|
(self, VecDeque::new()),
|
||||||
|
|(mut paginator, mut buf)| async move {
|
||||||
|
loop {
|
||||||
|
if let Some(item) = buf.pop_front() {
|
||||||
|
return Some((Ok(item), (paginator, buf)));
|
||||||
|
}
|
||||||
|
match paginator.next_page().await {
|
||||||
|
Ok(Some(page)) => {
|
||||||
|
buf.extend(page.items);
|
||||||
|
if buf.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => return None,
|
||||||
|
Err(err) => return Some((Err(err), (paginator, buf))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use http::HeaderMap;
|
||||||
|
|
||||||
|
/// Rate limit information parsed from Outline API response headers.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct RateLimit {
|
||||||
|
/// How long to wait before retrying, parsed from the `Retry-After` header.
|
||||||
|
pub retry_after: Option<Duration>,
|
||||||
|
/// The maximum number of requests allowed in the current window (`RateLimit-Limit`).
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
/// The number of requests remaining in the current window (`RateLimit-Remaining`).
|
||||||
|
pub remaining: Option<u32>,
|
||||||
|
/// The raw value of the `RateLimit-Reset` header, if present.
|
||||||
|
///
|
||||||
|
/// Kept as a raw string rather than a parsed timestamp: Outline documents
|
||||||
|
/// this header only loosely, and getting the format wrong would be worse
|
||||||
|
/// than not parsing it at all.
|
||||||
|
pub reset: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
pub(crate) fn from_headers(headers: &HeaderMap) -> Option<Self> {
|
||||||
|
let header_str = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||||
|
|
||||||
|
let retry_after = header_str("retry-after")
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.map(Duration::from_secs);
|
||||||
|
let limit = header_str("ratelimit-limit").and_then(|s| s.parse::<u32>().ok());
|
||||||
|
let remaining = header_str("ratelimit-remaining").and_then(|s| s.parse::<u32>().ok());
|
||||||
|
let reset = header_str("ratelimit-reset").map(str::to_string);
|
||||||
|
|
||||||
|
if retry_after.is_none() && limit.is_none() && remaining.is_none() && reset.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(RateLimit {
|
||||||
|
retry_after,
|
||||||
|
limit,
|
||||||
|
remaining,
|
||||||
|
reset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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