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,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>,
|
||||
}
|
||||
Reference in New Issue
Block a user