Add Outline API client library foundation

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

- Handwritten client (not codegen) against the vendored OpenAPI spec, since
  Outline's API is uniformly POST /api/<resource>.<action> with JSON bodies
  and inline/anonymous schemas that generators handle poorly
- Async (reqwest + tokio) Client, cheaply cloneable, no &mut self methods
- thiserror-based Error with ErrorKind classification and boxed API error
  context; forward-compatible models (unknown fields ignored, unknown
  string-enum values preserved via a catch-all variant) since the API is
  unversioned and self-hosted instances vary in age
- Pagination via Page<T>/Paginator with next_page/collect_all/into_stream
- wiremock-based test suite plus a spec-coverage test guarding against typos
  in RPC method names
This commit is contained in:
2026-07-30 13:00:09 +02:00
commit dec8face54
38 changed files with 13760 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use super::common::{Id, Permission, Timestamp, UnknownVariant};
use super::user::User;
/// A collection: a top-level grouping of documents in Outline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Collection {
/// Unique identifier for the collection.
pub id: Id,
/// The relative URL path at which the collection can be accessed.
#[serde(default)]
pub url: Option<String>,
/// A short unique identifier that can be used in place of the UUID.
#[serde(default)]
pub url_id: Option<String>,
/// The name of the collection.
pub name: String,
/// A description of the collection, may contain markdown formatting.
#[serde(default)]
pub description: Option<String>,
/// The position of the collection in the sidebar.
#[serde(default)]
pub index: Option<String>,
/// A color representing the collection, in `#RRGGBB` format.
#[serde(default)]
pub color: Option<String>,
/// An icon name or emoji associated with the collection.
#[serde(default)]
pub icon: Option<String>,
/// The sharing permission level for this collection.
#[serde(default)]
pub permission: Option<Permission>,
/// Whether public document sharing is enabled in this collection.
#[serde(default)]
pub sharing: bool,
/// Whether commenting is enabled in this collection.
#[serde(default)]
pub commenting: Option<bool>,
/// The date and time this collection was created.
#[serde(default)]
pub created_at: Option<Timestamp>,
/// The date and time this collection was last changed.
#[serde(default)]
pub updated_at: Option<Timestamp>,
/// The date and time this collection was archived, if applicable.
#[serde(default)]
pub archived_at: Option<Timestamp>,
/// The user who archived this collection, if applicable.
#[serde(default)]
pub archived_by: Option<User>,
}
/// The status a collection may be filtered by in `collections.list`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CollectionStatus {
/// The collection has been archived.
Archived,
/// A value not recognized by this version of the crate.
#[serde(untagged)]
Unknown(UnknownVariant),
}
+162
View File
@@ -0,0 +1,162 @@
use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
/// A timestamp as returned by the Outline API (ISO 8601 / RFC 3339, UTC).
pub type Timestamp = chrono::DateTime<chrono::Utc>;
/// An opaque Outline resource identifier.
///
/// Outline generally uses UUIDs for `id` fields, but some fields
/// (`urlId`, `shareId`) use a different, shorter format. Modeling `Id` as a
/// transparent string newtype (rather than [`uuid::Uuid`]) means an
/// unexpected value from a self-hosted or future Outline version cannot
/// fail deserialization of an entire response.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Id(String);
impl Id {
/// Creates a new [`Id`] from any string-like value.
pub fn new(value: impl Into<String>) -> Self {
Id(value.into())
}
/// Returns the identifier as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
/// Parses this identifier as a [`uuid::Uuid`], if it is one.
#[cfg(feature = "uuid")]
pub fn as_uuid(&self) -> Option<uuid::Uuid> {
self.0.parse().ok()
}
}
impl From<&str> for Id {
fn from(value: &str) -> Self {
Id(value.to_string())
}
}
impl From<String> for Id {
fn from(value: String) -> Self {
Id(value)
}
}
impl From<&Id> for Id {
fn from(value: &Id) -> Self {
value.clone()
}
}
impl fmt::Display for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// A sharing/collection permission level.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Permission {
/// Read-only access.
Read,
/// Read and write access.
ReadWrite,
/// A value not recognized by this version of the crate.
#[serde(untagged)]
Unknown(UnknownVariant),
}
/// A string value that did not match any known enum variant.
///
/// Carrying the original string (rather than silently discarding it via
/// `#[serde(other)]`) keeps the API forward-compatible with server versions
/// newer than this crate: unrecognized values round-trip instead of causing
/// a hard deserialization failure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UnknownVariant(pub String);
impl fmt::Display for UnknownVariant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Sort direction used by list endpoints.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum SortDirection {
/// Ascending order.
Asc,
/// Descending order.
Desc,
}
/// Either a boolean or a list of membership names granting an ability.
///
/// The Outline API models `Policy.abilities` values as `oneOf [boolean, string[]]`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Ability {
/// The ability is simply allowed or denied.
Allowed(bool),
/// The ability is granted through the listed memberships.
Memberships(Vec<String>),
}
impl Ability {
/// Whether this ability is granted, treating a non-empty membership list as `true`.
pub fn is_allowed(&self) -> bool {
match self {
Ability::Allowed(allowed) => *allowed,
Ability::Memberships(memberships) => !memberships.is_empty(),
}
}
}
/// Describes which actions the current actor may perform on a resource.
///
/// Returned alongside `data` on most endpoints. `policy.id` matches the
/// `id` of the resource it describes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
/// The id of the resource this policy describes.
pub id: Id,
/// A map of ability name (e.g. `"update"`, `"delete"`) to its status.
#[serde(default)]
pub abilities: BTreeMap<String, Ability>,
}
impl Policy {
/// Whether the current actor is allowed to perform `ability` on this resource.
///
/// Returns `false` if the ability is not present in the map at all.
pub fn can(&self, ability: &str) -> bool {
self.abilities.get(ability).is_some_and(Ability::is_allowed)
}
}
/// A node in a collection's document tree, as returned by `collections.documents`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct NavigationNode {
/// The document id.
pub id: Id,
/// The short, URL-friendly id of the document.
#[serde(default)]
pub url_id: Option<String>,
/// The document title.
#[serde(default)]
pub title: String,
/// Child documents nested under this one.
#[serde(default)]
pub children: Vec<NavigationNode>,
}
+113
View File
@@ -0,0 +1,113 @@
use serde::{Deserialize, Serialize};
use super::common::{Id, Timestamp, UnknownVariant};
use super::user::User;
/// A document within a collection.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Document {
/// Unique identifier for the document.
pub id: Id,
/// The collection this document belongs to, if published.
#[serde(default)]
pub collection_id: Option<Id>,
/// The document this is a child of, if any.
#[serde(default)]
pub parent_document_id: Option<Id>,
/// The title of the document.
#[serde(default)]
pub title: String,
/// Whether this document is displayed in a full-width view.
#[serde(default)]
pub full_width: bool,
/// An emoji or icon associated with the document.
#[serde(default)]
pub icon: Option<String>,
/// The color of the document icon, in `#RRGGBB` format.
#[serde(default)]
pub color: Option<String>,
/// The text content of the document, in markdown.
#[serde(default)]
pub text: Option<String>,
/// A URL path at which the document can be accessed.
#[serde(default)]
pub url: Option<String>,
/// A short unique id that can be used in place of the UUID.
#[serde(default)]
pub url_id: Option<String>,
/// Identifiers of users who have edited the document.
#[serde(default)]
pub collaborator_ids: Vec<Id>,
/// Task completion counts for the document, if it contains checklists.
#[serde(default)]
pub tasks: Option<DocumentTasks>,
/// The revision number, incremented on every save.
#[serde(default)]
pub revision: Option<f64>,
/// The date and time this document was created.
#[serde(default)]
pub created_at: Option<Timestamp>,
/// The user who created this document.
#[serde(default)]
pub created_by: Option<User>,
/// The date and time this document was last changed.
#[serde(default)]
pub updated_at: Option<Timestamp>,
/// The user who last updated this document.
#[serde(default)]
pub updated_by: Option<User>,
/// The date and time this document was published, if applicable.
#[serde(default)]
pub published_at: Option<Timestamp>,
/// The date and time this document was archived, if applicable.
#[serde(default)]
pub archived_at: Option<Timestamp>,
/// The date and time this document was deleted, if applicable.
#[serde(default)]
pub deleted_at: Option<Timestamp>,
}
/// Task completion counts for a document containing checklists.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentTasks {
/// The number of completed tasks.
pub completed: u32,
/// The total number of tasks.
pub total: u32,
}
/// The publication status a document may be filtered by.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DocumentStatus {
/// The document has not been published yet.
Draft,
/// The document has been archived.
Archived,
/// The document is published and visible to the workspace.
Published,
/// A value not recognized by this version of the crate.
#[serde(untagged)]
Unknown(UnknownVariant),
}
/// How the `text` field of `documents.update` should be applied.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TextEditMode {
/// Append `text` to the end of the existing content.
Append,
/// Prepend `text` to the start of the existing content.
Prepend,
/// Replace the existing content with `text` (the default).
Replace,
/// Replace the first occurrence of `find_text` with `text`.
Patch,
/// A value not recognized by this version of the crate.
#[serde(untagged)]
Unknown(UnknownVariant),
}
+19
View File
@@ -0,0 +1,19 @@
//! Data models returned by the Outline API.
//!
//! Types in this module are intentionally decoupled from [`crate::Client`] so
//! that they can be stored, cloned and passed around independently of any
//! network connection (e.g. held in the state of a GUI application).
mod collection;
mod common;
mod document;
mod search;
mod team;
mod user;
pub use collection::*;
pub use common::*;
pub use document::*;
pub use search::*;
pub use team::*;
pub use user::*;
+18
View File
@@ -0,0 +1,18 @@
use serde::Deserialize;
use super::document::Document;
/// A single result from `documents.search`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SearchResult {
/// A short snippet of context from the document that includes the search query.
#[serde(default)]
pub context: Option<String>,
/// The relevance ranking used to order search results.
#[serde(default)]
pub ranking: Option<f64>,
/// The matching document.
pub document: Document,
}
+78
View File
@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use super::common::Id;
use super::user::{User, UserRole};
/// The payload returned by `auth.info`: the current API actor and their workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AuthInfo {
/// The user associated with the current API key or access token.
pub user: User,
/// The workspace the user belongs to.
pub team: Team,
}
/// An Outline workspace (formerly "team").
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Team {
/// Unique identifier for the workspace.
pub id: Id,
/// The name of the workspace.
pub name: String,
/// A short description of the workspace.
#[serde(default)]
pub description: Option<String>,
/// The URL of the workspace's avatar image, if any.
#[serde(default)]
pub avatar_url: Option<String>,
/// Whether this workspace has share links globally enabled.
#[serde(default)]
pub sharing: bool,
/// The default role assigned to new members.
#[serde(default)]
pub default_user_role: Option<UserRole>,
/// The fully qualified URL at which this workspace can be accessed.
#[serde(default)]
pub url: Option<String>,
/// The subdomain at which this workspace can be accessed.
#[serde(default)]
pub subdomain: Option<String>,
}
/// Authentication configuration for an Outline instance (`auth.config`).
///
/// This endpoint requires no authentication and is useful for discovering
/// available sign-in methods before a client has credentials.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AuthConfig {
/// The name of the workspace.
#[serde(default)]
pub name: Option<String>,
/// The hostname at which this workspace can be accessed.
#[serde(default)]
pub hostname: Option<String>,
/// Available single sign-on services.
#[serde(default)]
pub services: Vec<AuthService>,
}
/// A single sign-on service available for authentication.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AuthService {
/// The service identifier, e.g. `"slack"`.
#[serde(default)]
pub id: Option<String>,
/// The human-readable service name, e.g. `"Slack"`.
#[serde(default)]
pub name: Option<String>,
/// The URL to redirect to in order to authenticate with this service.
#[serde(default)]
pub auth_url: Option<String>,
}
+56
View File
@@ -0,0 +1,56 @@
use serde::{Deserialize, Serialize};
use super::common::{Id, Timestamp, UnknownVariant};
/// A member of an Outline workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct User {
/// Unique identifier for the user.
pub id: Id,
/// The user's display name.
pub name: String,
/// The URL of the user's avatar image, if any.
#[serde(default)]
pub avatar_url: Option<String>,
/// A color representing the user, used for avatars without an image.
#[serde(default)]
pub color: Option<String>,
/// The user's email address.
#[serde(default)]
pub email: Option<String>,
/// The user's role within the workspace.
#[serde(default)]
pub role: Option<UserRole>,
/// Whether the user has been suspended.
#[serde(default)]
pub is_suspended: bool,
/// The last time this user made an API request.
#[serde(default)]
pub last_active_at: Option<Timestamp>,
/// The date and time this user first signed in or was invited.
#[serde(default)]
pub created_at: Option<Timestamp>,
/// The date and time this user was last updated.
#[serde(default)]
pub updated_at: Option<Timestamp>,
}
/// A user's role within a workspace.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum UserRole {
/// Full administrative access.
Admin,
/// A regular workspace member.
Member,
/// Read-only access to shared content.
Viewer,
/// External guest access.
Guest,
/// A value not recognized by this version of the crate.
#[serde(untagged)]
Unknown(UnknownVariant),
}