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