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
+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>,
}