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