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
+130
View File
@@ -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)
}
}