Add Outline API client library foundation

Implements a first vertical slice of the Outline RPC API (auth, documents,
collections, users) covering the structural patterns used across the whole
API: single object, paginated list, tree, create/update, delete, search.
Chosen as the basis for future CLI and GUI clients built on top of this crate.

- Handwritten client (not codegen) against the vendored OpenAPI spec, since
  Outline's API is uniformly POST /api/<resource>.<action> with JSON bodies
  and inline/anonymous schemas that generators handle poorly
- Async (reqwest + tokio) Client, cheaply cloneable, no &mut self methods
- thiserror-based Error with ErrorKind classification and boxed API error
  context; forward-compatible models (unknown fields ignored, unknown
  string-enum values preserved via a catch-all variant) since the API is
  unversioned and self-hosted instances vary in age
- Pagination via Page<T>/Paginator with next_page/collect_all/into_stream
- wiremock-based test suite plus a spec-coverage test guarding against typos
  in RPC method names
This commit is contained in:
2026-07-30 13:00:09 +02:00
commit dec8face54
38 changed files with 13760 additions and 0 deletions
+22
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/target
Cargo.lock
+47
View File
@@ -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
+68
View File
@@ -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
+30
View File
@@ -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(())
}
+23
View File
@@ -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(())
}
+1
View File
@@ -0,0 +1 @@
edition = "2024"
+10544
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -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
}
}
+137
View File
@@ -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)
}
}
+534
View File
@@ -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, &params).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
}
}
+15
View File
@@ -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};
+130
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})))
}
}
}
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+66
View File
@@ -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),
}
+162
View File
@@ -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>,
}
+113
View File
@@ -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),
}
+19
View File
@@ -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::*;
+18
View File
@@ -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,
}
+78
View File
@@ -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>,
}
+56
View File
@@ -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
View File
@@ -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))),
}
}
},
)
}
}
+45
View File
@@ -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,
})
}
}
}
+70
View File
@@ -0,0 +1,70 @@
mod common;
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn auth_info_returns_user_and_team() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.info"))
.and(header(
"authorization",
"Bearer ol_api_test000000000000000000000000000000",
))
.and(body_json(json!({})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"status": 200,
"data": {
"user": {
"id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "admin",
"isSuspended": false
},
"team": {
"id": "9d3d1b2e-3c9a-4c67-8a2a-7f5d3f0e9c11",
"name": "Acme Inc",
"sharing": true
}
}
})))
.mount(&server)
.await;
let auth = client.auth().info().await.unwrap();
assert_eq!(auth.user.name, "Jane Doe");
assert_eq!(auth.team.name, "Acme Inc");
}
#[tokio::test]
async fn auth_config_requires_no_authorization_header() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.config"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"status": 200,
"data": {
"name": "Acme Inc",
"hostname": "acme-inc.getoutline.com",
"services": [
{ "id": "slack", "name": "Slack", "authUrl": "https://acme-inc.getoutline.com/auth/slack" }
]
}
})))
.mount(&server)
.await;
let config = client.auth().config().await.unwrap();
assert_eq!(config.name.as_deref(), Some("Acme Inc"));
assert_eq!(config.services.len(), 1);
assert_eq!(config.services[0].id.as_deref(), Some("slack"));
}
+78
View File
@@ -0,0 +1,78 @@
mod common;
use serde_json::json;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn collections_info_returns_collection() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/collections.info"))
.and(body_json(json!({ "id": "col-1" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": { "id": "col-1", "name": "Human Resources", "sharing": false }
})))
.mount(&server)
.await;
let collection = client.collections().info("col-1").await.unwrap();
assert_eq!(collection.name, "Human Resources");
}
#[tokio::test]
async fn collections_list_returns_page_with_policies() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/collections.list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [{ "id": "col-1", "name": "Human Resources", "sharing": false }],
"pagination": { "limit": 25, "offset": 0 },
"policies": [{ "id": "col-1", "abilities": { "update": true, "delete": false } }]
})))
.mount(&server)
.await;
let page = client.collections().list().send().await.unwrap();
assert_eq!(page.items.len(), 1);
assert_eq!(page.policies.len(), 1);
assert!(page.policies[0].can("update"));
assert!(!page.policies[0].can("delete"));
assert!(!page.policies[0].can("archive"));
}
#[tokio::test]
async fn collections_documents_returns_nested_tree() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/collections.documents"))
.and(body_json(json!({ "id": "col-1" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [
{
"id": "doc-1",
"title": "Parent",
"children": [
{ "id": "doc-2", "title": "Child", "children": [] }
]
}
]
})))
.mount(&server)
.await;
let tree = client.collections().documents("col-1").await.unwrap();
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].title, "Parent");
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].title, "Child");
}
+11
View File
@@ -0,0 +1,11 @@
use outline::Client;
use wiremock::MockServer;
/// Spins up a mock Outline API server and a [`Client`] pointed at it.
pub async fn client_for(server: &MockServer) -> Client {
Client::builder()
.base_url(server.uri())
.api_key("ol_api_test000000000000000000000000000000")
.build()
.unwrap()
}
+155
View File
@@ -0,0 +1,155 @@
mod common;
use serde_json::json;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sample_document(id: &str, title: &str) -> serde_json::Value {
json!({
"id": id,
"collectionId": "col-1",
"title": title,
"text": "Hello world",
"urlId": "abc123",
})
}
#[tokio::test]
async fn documents_info_by_id() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.info"))
.and(body_json(json!({ "id": "doc-1" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": sample_document("doc-1", "Welcome")
})))
.mount(&server)
.await;
let document = client.documents().info("doc-1").await.unwrap();
assert_eq!(document.title, "Welcome");
assert_eq!(document.collection_id.unwrap().as_str(), "col-1");
}
#[tokio::test]
async fn documents_list_returns_page() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "collectionId": "col-1" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [sample_document("doc-1", "One"), sample_document("doc-2", "Two")],
"pagination": { "limit": 2, "offset": 0 }
})))
.mount(&server)
.await;
let page = client
.documents()
.list()
.collection_id("col-1")
.limit(2)
.send()
.await
.unwrap();
assert_eq!(page.items.len(), 2);
assert_eq!(page.items[0].title, "One");
assert_eq!(page.pagination.limit, Some(2));
}
#[tokio::test]
async fn documents_create_sends_title_and_collection() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.create"))
.and(body_json(
json!({ "title": "New Doc", "collectionId": "col-1", "publish": true }),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": sample_document("doc-3", "New Doc")
})))
.mount(&server)
.await;
let document = client
.documents()
.create("New Doc")
.collection_id("col-1")
.publish(true)
.send()
.await
.unwrap();
assert_eq!(document.id.as_str(), "doc-3");
}
#[tokio::test]
async fn documents_update_sends_only_set_fields() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.update"))
.and(body_json(json!({ "id": "doc-1", "title": "Renamed" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": sample_document("doc-1", "Renamed")
})))
.mount(&server)
.await;
let document = client
.documents()
.update("doc-1")
.title("Renamed")
.send()
.await
.unwrap();
assert_eq!(document.title, "Renamed");
}
#[tokio::test]
async fn documents_delete_ignores_response_shape() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.delete"))
.and(body_json(json!({ "id": "doc-1", "permanent": false })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "success": true })))
.mount(&server)
.await;
client.documents().delete("doc-1", false).await.unwrap();
}
#[tokio::test]
async fn documents_search_returns_ranked_results() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.search"))
.and(body_json(json!({ "query": "hiring" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [
{ "context": "our hiring practices", "ranking": 1.5, "document": sample_document("doc-4", "Hiring Guide") }
],
"pagination": { "limit": 25, "offset": 0 }
})))
.mount(&server)
.await;
let page = client.documents().search("hiring").send().await.unwrap();
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].document.title, "Hiring Guide");
}
+106
View File
@@ -0,0 +1,106 @@
mod common;
use outline::ErrorKind;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn maps_401_to_unauthenticated() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.info"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"ok": false,
"status": 401,
"error": "authentication_required",
"message": "Authentication required"
})))
.mount(&server)
.await;
let err = client.auth().info().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Unauthenticated);
assert_eq!(err.status(), Some(http::StatusCode::UNAUTHORIZED));
assert!(!err.is_retryable());
}
#[tokio::test]
async fn maps_404_to_not_found() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/collections.info"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"ok": false,
"status": 404,
"error": "not_found",
"message": "Collection not found"
})))
.mount(&server)
.await;
let err = client.collections().info("missing-id").await.unwrap_err();
assert!(err.is_not_found());
assert_eq!(err.kind(), ErrorKind::NotFound);
}
#[tokio::test]
async fn maps_429_to_rate_limited_and_parses_retry_after() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("Retry-After", "12")
.set_body_json(json!({
"ok": false,
"status": 429,
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded"
})),
)
.mount(&server)
.await;
let err = client.documents().list().send().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::RateLimited);
assert!(err.is_retryable());
assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(12)));
}
#[tokio::test]
async fn maps_5xx_to_server_error() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.info"))
.respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
.mount(&server)
.await;
let err = client.auth().info().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Server);
assert!(err.is_retryable());
}
#[tokio::test]
async fn malformed_success_body_yields_decode_error() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.info"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let err = client.auth().info().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Decode);
}
+27
View File
@@ -0,0 +1,27 @@
{
"id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2",
"collectionId": "9d3d1b2e-3c9a-4c67-8a2a-7f5d3f0e9c11",
"parentDocumentId": null,
"title": "Welcome to Acme Inc",
"fullWidth": false,
"icon": "🎉",
"color": null,
"text": "# Welcome\n\nThis is the text of the document.",
"url": "/doc/welcome-to-acme-inc-hDYep1TPAM",
"urlId": "hDYep1TPAM",
"collaboratorIds": ["5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2"],
"tasks": { "completed": 1, "total": 4 },
"revision": 12,
"createdAt": "2024-01-15T10:00:00.000Z",
"createdBy": {
"id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "admin",
"isSuspended": false
},
"updatedAt": "2024-02-20T09:30:00.000Z",
"publishedAt": "2024-01-15T11:00:00.000Z",
"archivedAt": null,
"deletedAt": null
}
+29
View File
@@ -0,0 +1,29 @@
//! Smoke tests against the real Outline API.
//!
//! Ignored by default. Run with:
//! `OUTLINE_API_KEY=ol_api_... cargo test -- --ignored`
use outline::Client;
#[tokio::test]
#[ignore]
async fn auth_info_and_collections_list_succeed() {
let client =
Client::from_env().expect("OUTLINE_API_KEY (and optionally OUTLINE_URL) must be set");
let auth = client
.auth()
.info()
.await
.expect("auth.info should succeed with valid credentials");
println!("Signed in as {} ({})", auth.user.name, auth.team.name);
let page = client
.collections()
.list()
.limit(5)
.send()
.await
.expect("collections.list should succeed");
println!("Fetched {} collection(s)", page.items.len());
}
+101
View File
@@ -0,0 +1,101 @@
use outline::models::{Ability, DocumentTasks, NavigationNode, Policy, User, UserRole};
use serde_json::json;
#[test]
fn deserializes_full_document_fixture() {
let raw = include_str!("fixtures/document.json");
let document: outline::models::Document = serde_json::from_str(raw).unwrap();
assert_eq!(document.title, "Welcome to Acme Inc");
assert_eq!(document.url_id.as_deref(), Some("hDYep1TPAM"));
assert_eq!(
document.tasks,
Some(DocumentTasks {
completed: 1,
total: 4
})
);
assert!(document.parent_document_id.is_none());
assert!(document.archived_at.is_none());
assert_eq!(document.created_by.unwrap().name, "Jane Doe");
}
/// A server (self-hosted, newer than this crate) adding a brand new field to
/// a response must not break deserialization of the rest of the object.
#[test]
fn unknown_top_level_field_is_ignored() {
let raw = json!({
"id": "doc-1",
"title": "Still works",
"aBrandNewFieldFromTheFuture": { "nested": true },
});
let document: outline::models::Document = serde_json::from_value(raw).unwrap();
assert_eq!(document.title, "Still works");
}
/// A server returning a role value this crate doesn't know about yet must
/// round-trip instead of failing to deserialize the whole `User`.
#[test]
fn unknown_enum_value_round_trips_via_unknown_variant() {
let raw = json!({
"id": "user-1",
"name": "Future User",
"role": "super_admin",
});
let user: User = serde_json::from_value(raw).unwrap();
match &user.role {
Some(UserRole::Unknown(value)) => assert_eq!(value.0, "super_admin"),
other => panic!("expected UserRole::Unknown, got {other:?}"),
}
let round_tripped = serde_json::to_value(&user).unwrap();
assert_eq!(round_tripped["role"], json!("super_admin"));
}
#[test]
fn known_enum_value_deserializes_normally() {
let raw = json!({ "id": "user-1", "name": "Jane", "role": "member" });
let user: User = serde_json::from_value(raw).unwrap();
assert_eq!(user.role, Some(UserRole::Member));
}
#[test]
fn policy_ability_accepts_boolean_or_membership_list() {
let raw = json!({
"id": "doc-1",
"abilities": {
"update": true,
"delete": false,
"restrictedUpdate": ["group-a", "group-b"],
}
});
let policy: Policy = serde_json::from_value(raw).unwrap();
assert!(policy.can("update"));
assert!(!policy.can("delete"));
assert!(policy.can("restrictedUpdate"));
assert!(!policy.can("missingAbility"));
assert_eq!(
policy.abilities["restrictedUpdate"],
Ability::Memberships(vec!["group-a".into(), "group-b".into()])
);
}
#[test]
fn navigation_node_deserializes_recursively() {
let raw = json!({
"id": "doc-1",
"title": "Parent",
"children": [
{ "id": "doc-2", "title": "Child", "children": [
{ "id": "doc-3", "title": "Grandchild" }
]}
]
});
let node: NavigationNode = serde_json::from_value(raw).unwrap();
assert_eq!(node.children.len(), 1);
assert_eq!(node.children[0].children[0].title, "Grandchild");
}
+113
View File
@@ -0,0 +1,113 @@
mod common;
use serde_json::json;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn doc(id: &str) -> serde_json::Value {
json!({ "id": id, "title": id })
}
#[tokio::test]
async fn paginator_advances_offset_and_stops_on_short_page() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 0 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-1"), doc("doc-2")],
"pagination": { "limit": 2, "offset": 0 }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 2 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-3")],
"pagination": { "limit": 2, "offset": 2 }
})))
.mount(&server)
.await;
let items = client
.documents()
.list()
.limit(2)
.paginate()
.collect_all()
.await
.unwrap();
let ids: Vec<_> = items
.into_iter()
.map(|d| d.id.as_str().to_string())
.collect();
assert_eq!(ids, vec!["doc-1", "doc-2", "doc-3"]);
}
#[tokio::test]
async fn paginator_stops_immediately_on_empty_first_page() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 25, "offset": 0 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [],
"pagination": { "limit": 25, "offset": 0 }
})))
.mount(&server)
.await;
let mut paginator = client.documents().list().paginate();
let page = paginator
.next_page()
.await
.unwrap()
.expect("first page always returned");
assert!(page.items.is_empty());
assert!(paginator.next_page().await.unwrap().is_none());
}
#[cfg(feature = "stream")]
#[tokio::test]
async fn into_stream_yields_items_across_pages() {
use futures_util::StreamExt;
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 0 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-1"), doc("doc-2")],
"pagination": { "limit": 2, "offset": 0 }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 2 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-3")],
"pagination": { "limit": 2, "offset": 2 }
})))
.mount(&server)
.await;
let stream = client.documents().list().limit(2).paginate().into_stream();
let items: Vec<_> = stream.collect().await;
assert_eq!(items.len(), 3);
assert!(items.iter().all(|item| item.is_ok()));
}
+38
View File
@@ -0,0 +1,38 @@
//! Verifies that every RPC method name used by this crate still exists as a
//! path in the vendored OpenAPI spec, catching typos and upstream renames.
const IMPLEMENTED_METHODS: &[&str] = &[
"auth.info",
"auth.config",
"documents.info",
"documents.list",
"documents.search",
"documents.create",
"documents.update",
"documents.delete",
"collections.list",
"collections.info",
"collections.documents",
"users.list",
"users.info",
];
#[test]
fn implemented_methods_exist_in_vendored_spec() {
let spec_raw = include_str!("../spec/spec3.json");
let spec: serde_json::Value = serde_json::from_str(spec_raw).unwrap();
let paths = spec["paths"]
.as_object()
.expect("spec should have a `paths` object");
let missing: Vec<&str> = IMPLEMENTED_METHODS
.iter()
.copied()
.filter(|method| !paths.contains_key(&format!("/{method}")))
.collect();
assert!(
missing.is_empty(),
"methods missing from vendored spec: {missing:?}"
);
}