From dec8face541f362cfbd082ef2037336d51f61777 Mon Sep 17 00:00:00 2001 From: Henning Oschwald Date: Thu, 30 Jul 2026 13:00:09 +0200 Subject: [PATCH] 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/. 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/Paginator with next_page/collect_all/into_stream - wiremock-based test suite plus a spec-coverage test guarding against typos in RPC method names --- .github/workflows/ci.yml | 22 + .gitignore | 2 + Cargo.toml | 47 + README.md | 68 + examples/list_documents.rs | 30 + examples/search.rs | 23 + rustfmt.toml | 1 + spec/spec3.json | 10544 +++++++++++++++++++++++++++++++++ src/api/auth.rs | 38 + src/api/collections.rs | 137 + src/api/documents.rs | 534 ++ src/api/mod.rs | 15 + src/api/users.rs | 130 + src/auth.rs | 44 + src/builder.rs | 228 + src/client.rs | 201 + src/envelope.rs | 18 + src/error.rs | 183 + src/lib.rs | 26 + src/models/collection.rs | 66 + src/models/common.rs | 162 + src/models/document.rs | 113 + src/models/mod.rs | 19 + src/models/search.rs | 18 + src/models/team.rs | 78 + src/models/user.rs | 56 + src/page.rs | 184 + src/rate_limit.rs | 45 + tests/auth.rs | 70 + tests/collections.rs | 78 + tests/common/mod.rs | 11 + tests/documents.rs | 155 + tests/errors.rs | 106 + tests/fixtures/document.json | 27 + tests/live.rs | 29 + tests/models.rs | 101 + tests/pagination.rs | 113 + tests/spec_coverage.rs | 38 + 38 files changed, 13760 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 examples/list_documents.rs create mode 100644 examples/search.rs create mode 100644 rustfmt.toml create mode 100644 spec/spec3.json create mode 100644 src/api/auth.rs create mode 100644 src/api/collections.rs create mode 100644 src/api/documents.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/users.rs create mode 100644 src/auth.rs create mode 100644 src/builder.rs create mode 100644 src/client.rs create mode 100644 src/envelope.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/models/collection.rs create mode 100644 src/models/common.rs create mode 100644 src/models/document.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/search.rs create mode 100644 src/models/team.rs create mode 100644 src/models/user.rs create mode 100644 src/page.rs create mode 100644 src/rate_limit.rs create mode 100644 tests/auth.rs create mode 100644 tests/collections.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/documents.rs create mode 100644 tests/errors.rs create mode 100644 tests/fixtures/document.json create mode 100644 tests/live.rs create mode 100644 tests/models.rs create mode 100644 tests/pagination.rs create mode 100644 tests/spec_coverage.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6f04f8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --check + - run: cargo clippy --all-features --all-targets -- -D warnings + - run: cargo test --all-features + - run: cargo doc --no-deps --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cb5535d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "outline-sdk" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "MIT OR Apache-2.0" +description = "Async Rust client for the Outline (getoutline.com) knowledge base API" +keywords = ["outline", "wiki", "api", "client", "knowledge-base"] +categories = ["api-bindings", "asynchronous"] +readme = "README.md" + +[lib] +name = "outline" + +[features] +default = ["rustls-tls", "stream"] +rustls-tls = ["reqwest/rustls"] +native-tls = ["reqwest/native-tls"] +stream = ["dep:futures-core", "dep:futures-util", "reqwest/stream"] +multipart = ["reqwest/multipart"] +tracing = ["dep:tracing"] +uuid = ["dep:uuid"] + +[dependencies] +reqwest = { version = "0.13", default-features = false, features = ["json", "http2", "charset"] } +serde = { version = "1.0.181", features = ["derive"] } +serde_json = "1" +thiserror = "2" +url = "2" +http = "1" +chrono = { version = "0.4", default-features = false, features = ["serde", "std", "clock"] } +secrecy = "0.10" +futures-core = { version = "0.3", optional = true } +futures-util = { version = "0.3", optional = true, default-features = false } +bytes = "1" +tracing = { version = "0.1", optional = true } +uuid = { version = "1", optional = true, features = ["serde"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +wiremock = "0.6" +serde_json = "1" +futures-util = "0.3" +http = "1" + +[package.metadata.docs.rs] +all-features = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..5108c66 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# outline + +An async Rust client library for the [Outline](https://www.getoutline.com) knowledge base API. + +This crate is the foundation for building Outline clients (CLI, GUI, ...) on +top of a shared, well-tested API layer. It only talks to the API — no CLI or +GUI code lives here. + +## Quickstart + +```rust,no_run +use outline::Client; + +# async fn run() -> outline::Result<()> { +let client = Client::new("ol_api_...")?; + +let me = client.auth().info().await?; +println!("Signed in as {} ({})", me.user.name, me.team.name); + +let mut documents = client.documents().list().collection_id("col_123").paginate(); +while let Some(page) = documents.next_page().await? { + for document in page.items { + println!("{}", document.title); + } +} +# Ok(()) +# } +``` + +For a self-hosted instance or an OAuth access token, use [`Client::builder`]: + +```rust,no_run +use std::time::Duration; +use outline::Client; + +# fn run() -> outline::Result<()> { +let client = Client::builder() + .base_url("https://wiki.example.com") + .access_token("...") + .timeout(Duration::from_secs(10)) + .build()?; +# let _ = client; +# Ok(()) +# } +``` + +## Design notes + +The Outline API is RPC-style: every endpoint is `POST /api/.` +with a JSON body and a `{ok, data, pagination, policies}` envelope. This crate +mirrors that with a single internal request primitive; resources are exposed +as scoped accessors (`client.documents()`, `client.collections()`, ...). + +Because the API is unversioned and self-hosted instances vary in age, models +are deliberately forward-compatible: unknown fields are ignored and unknown +string-enum values are preserved rather than causing deserialization to fail. + +## Status + +This crate currently covers a first vertical slice of the API — `auth`, +`documents`, `collections`, `users` — chosen to validate the request/response +patterns (single object, paginated list, tree, create/update, delete, search) +used across the rest of the API. Broader endpoint coverage, automatic retry +on rate limiting, and streaming exports are planned but not yet implemented. + +## License + +MIT OR Apache-2.0 diff --git a/examples/list_documents.rs b/examples/list_documents.rs new file mode 100644 index 0000000..22addd8 --- /dev/null +++ b/examples/list_documents.rs @@ -0,0 +1,30 @@ +//! Lists every document in a collection, following pagination automatically. +//! +//! ```text +//! OUTLINE_API_KEY=ol_api_... cargo run --example list_documents -- +//! ``` + +use std::env; + +use outline::Client; + +#[tokio::main] +async fn main() -> outline::Result<()> { + let collection_id = env::args() + .nth(1) + .expect("usage: list_documents "); + let client = Client::from_env()?; + + let mut documents = client + .documents() + .list() + .collection_id(collection_id) + .paginate(); + while let Some(page) = documents.next_page().await? { + for document in page.items { + println!("{}\t{}", document.id, document.title); + } + } + + Ok(()) +} diff --git a/examples/search.rs b/examples/search.rs new file mode 100644 index 0000000..dc7079e --- /dev/null +++ b/examples/search.rs @@ -0,0 +1,23 @@ +//! Runs a full-text search and prints the top results with a short snippet. +//! +//! ```text +//! OUTLINE_API_KEY=ol_api_... cargo run --example search -- "hiring practices" +//! ``` + +use std::env; + +use outline::Client; + +#[tokio::main] +async fn main() -> outline::Result<()> { + let query = env::args().nth(1).expect("usage: search "); + let client = Client::from_env()?; + + let page = client.documents().search(query).limit(10).send().await?; + for result in page.items { + let context = result.context.as_deref().unwrap_or(""); + println!("{}\n {}\n", result.document.title, context); + } + + Ok(()) +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/spec/spec3.json b/spec/spec3.json new file mode 100644 index 0000000..045f00e --- /dev/null +++ b/spec/spec3.json @@ -0,0 +1,10544 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Outline API", + "description": "# Introduction\n\nThe Outline API is structured in an RPC style. It enables you to\nprogramatically interact with all aspects of Outline’s data – in fact, the\nmain application is built on exactly the same API.\n\nThe API structure is available as an\n[openapi specification](https://github.com/outline/openapi) if that’s your\njam – it can be used to generate clients for most programming languages.\n\n# Making requests\n\nOutline’s API follows simple RPC style conventions where each API endpoint is\na `POST` method on `https://app.getoutline.com/api/:method`. Only HTTPS is\nsupported and all response payloads are JSON.\n\nWhen making `POST` requests, request parameters are parsed depending on\nContent-Type header. To make a call using JSON payload, you must pass\nContent-Type: application/json header, here’s an example using CURL:\n\n```\ncurl https://app.getoutline.com/api/documents.info \\\n-X 'POST' \\\n-H 'authorization: Bearer MY_API_KEY' \\\n-H 'content-type: application/json' \\\n-H 'accept: application/json' \\\n-d '{\"id\": \"outline-api-NTpezNwhUP\"}'\n```\n\nOr, with JavaScript:\n\n```javascript\nconst response = await fetch(\"https://app.getoutline.com/api/documents.info\", {\n method: \"POST\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n Authorization: \"Bearer MY_API_KEY\"\n }\n})\n\nconst body = await response.json();\nconst document = body.data;\n```\n\n# Authentication\n\n## API key\n\nYou can create new API keys under **Settings => API & Apps**. Be\ncareful when handling your keys as they allow full access to your data,\nyou should treat them like passwords and they should never be committed to\nsource control.\n\n### Usage\n\nTo authenticate with API, you should supply the API key as a \"Bearer\" token in the `Authorization` header\n(`Authorization: Bearer YOUR_API_KEY`).\n\nAPI keys can be revoked at any time by the creating user or an administrator of the workspace. If an API\nkey is revoked, any requests made with that key will return a `401 Unauthenticated` response.\n\n### Format\n\nAll API keys always begin with `ol_api_` followed by a random string of 38 letters and numbers.\n\n## OAuth 2.0\n\nOAuth 2.0 is a widely used protocol for authorization and authentication. It allows users\nto grant third-party _or_ internal applications access to their resources without sharing\ntheir credentials. To use OAuth 2.0 you need to follow these steps:\n\n1. Register your application under **Settings => Applications**\n2. Obtain an access token by exchanging the client credentials for an access token\n3. Use the access token to authenticate requests to the API\n\nSome API endpoints allow unauthenticated requests for public resources and\nthey can be called without authentication.\n\n# Scopes\n\nScopes are used to limit the access of an API key or application to specific resources. For example,\nan application may only need access to read documents, but not write them. Scopes can be global in\nthe case of `read` and `write` scopes, scoped to a namespace, scoped to an API endpoint, or use\nwildcard scopes like `documents.*`. Some examples of scopes that can be used are:\n\n## Global\n\n- `read`: Allows all read actions\n- `write`: Allows all read and write actions\n\n## Namespaced\n\n- `documents:read`: Allows all document read actions\n- `collections:write`: Allows all collection write actions\n\n## Endpoints\n\n- `documents.info`: Allows only one specific API method\n- `documents.*`: Allows all document API methods\n- `users.*`: Allows all user API methods\n\n# Errors\n\nAll successful API requests will be returned with a 200 or 201 status code\nand `ok: true` in the response payload. If there’s an error while making the\nrequest, the appropriate status code is returned with the error message:\n\n```\n{\n \"ok\": false,\n \"error\": \"Not Found\"\n}\n```\n\n# Pagination\n\nMost top-level API resources have support for \"list\" API methods. For instance,\nyou can list users, documents, and collections. These list methods share\ncommon parameters, taking both `limit` and `offset`.\n\nResponses will echo these parameters in the root `pagination` key, and also\ninclude a `nextPath` key which can be used as a handy shortcut to fetch the\nnext page of results. For example:\n\n```\n{\n ok: true,\n status: 200,\n data: […],\n pagination: {\n limit: 25,\n offset: 0,\n nextPath: \"/api/documents.list?limit=25&offset=25\"\n }\n}\n```\n\n# Rate limits\n\nLike most APIs, Outline has rate limits in place to prevent abuse. Endpoints\nthat mutate data are more restrictive than read-only endpoints. If you exceed\nthe rate limit for a given endpoint, you will receive a `429 Too Many Requests`\nstatus code.\n\nThe response will include a `Retry-After` header that indicates how many seconds\nyou should wait before making another request.\n\n# Policies\n\nMost API resources have associated \"policies\", these objects describe the\ncurrent authentications authorized actions related to an individual resource. It\nshould be noted that the policy \"id\" is identical to the resource it is\nrelated to, policies themselves do not have unique identifiers.\n\nFor most usecases of the API, policies can be safely ignored. Calling\nunauthorized methods will result in the appropriate response code – these can\nbe used in an interface to adjust which elements are visible.\n", + "version": "0.1.0", + "contact": { + "email": "hello@getoutline.com" + }, + "license": { + "name": "BSD-3-Clause", + "url": "https://github.com/outline/openapi/blob/main/LICENSE" + } + }, + "servers": [ + { + "url": "https://app.getoutline.com/api", + "description": "Cloud hosted" + }, + { + "url": "https://{domain}/api", + "description": "Self-hosted on your own server", + "variables": { + "domain": { + "default": "example.com" + } + } + } + ], + "security": [ + { + "BearerAuth": [] + }, + { + "OAuth2": [ + "read", + "write" + ] + } + ], + "tags": [ + { + "name": "AccessRequests", + "description": "`AccessRequests` represent a request by a user for access to a document\nthey do not currently have permission to view. The request can be approved\nor dismissed by a user with permission to share the document.\n" + }, + { + "name": "Attachments", + "description": "`Attachments` represent a file uploaded to cloud storage. They are created\nbefore the upload happens from the client and store all the meta information\nsuch as file type, size, and location.\n" + }, + { + "name": "Auth", + "description": "`Auth` represents the current API Keys authentication details. It can be\nused to check that a token is still valid and load the IDs for the current\nuser and workspace.\n" + }, + { + "name": "Collections", + "description": "`Collections` represent grouping of documents in the knowledge base, they\noffer a way to structure information in a nested hierarchy and a level\nat which read and write permissions can be granted to individual users or\ngroups of users.\n" + }, + { + "name": "Comments", + "description": "`Comments` represent a comment either on a selection of text in a document\nor on the document itself.\n" + }, + { + "name": "DataAttributes", + "description": "`DataAttributes` represent custom metadata fields that can be attached to\ndocuments. They allow workspaces to add structured data like status, priority,\nor any other custom properties to their documents.\n" + }, + { + "name": "Documents", + "description": "`Documents` are what everything else revolves around. A document represents\na single page of information and always returns the latest version of the\ncontent. Documents are stored in [Markdown](https://spec.commonmark.org/)\nformatting.\n" + }, + { + "name": "Events", + "description": "`Events` represent an artifact of an action. Whether it is creating a user,\nediting a document, changing permissions, or any other action – an event\nis created that can be used as an audit trail or activity stream.\n" + }, + { + "name": "FileOperations", + "description": "`FileOperations` represent background jobs for importing or exporting files.\nYou can query the file operation to find the state of progress and any\nresulting output.\n" + }, + { + "name": "Groups", + "description": "`Groups` represent a list of users that logically belong together, for\nexample there might be groups for each department in your organization.\nGroups can be granted access to collections with read or write permissions.\n" + }, + { + "name": "OAuthClients", + "description": "`OAuthClients` represent OAuth clients that can be used to authenticate\nusers with third-party services.\n" + }, + { + "name": "OAuthAuthentications", + "description": "`OAuthAuthentications` represent individual scoped authentications between\nOutline and an `OAuthClient`.\n" + }, + { + "name": "Revisions", + "description": "`Revisions` represent a snapshot of a document at a point in time. They\nare used to keep track of editing and collaboration history – a document\ncan also be restored to a previous revision if necessary.\n" + }, + { + "name": "Shares", + "description": "`Shares` represent authorization to view a document without being a member\nof the workspace. Shares are created in order to give access to documents publicly.\nEach user that shares a document will have a unique share object.\n" + }, + { + "name": "Stars", + "description": "`Stars` represent a favorited document or collection in the application sidebar.\nEach user has their own collection of starred items.\n" + }, + { + "name": "Users", + "description": "`Users` represent an individual with access to the knowledge base. Users\ncan be created automatically when signing in with SSO or when a user is\ninvited via email.\n" + }, + { + "name": "Templates", + "description": "`Templates` represent reusable document templates that can be used as a\nstarting point when creating new documents. Templates can be scoped to a\nspecific collection or available workspace-wide.\n" + }, + { + "name": "Views", + "description": "`Views` represent a compressed record of an individual users views of a\ndocument. Individual views are not recorded but a first, last and total\nis kept per user.\n" + } + ], + "paths": { + "/accessRequests.create": { + "post": { + "tags": [ + "AccessRequests" + ], + "summary": "Create an access request", + "description": "Request access to a document. The request will be sent to users with permission to share the document for approval or dismissal.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the document to request access to." + } + }, + "required": [ + "documentId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/AccessRequest" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "accessRequestsCreate" + } + }, + "/accessRequests.info": { + "post": { + "tags": [ + "AccessRequests" + ], + "summary": "Retrieve an access request", + "description": "Retrieve information about an access request by `id`, or the current user's pending request for a document by `documentId`. At least one of these parameters must be provided.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the access request." + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the document to find a pending request for the current user." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/AccessRequest" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "accessRequestsInfo" + } + }, + "/accessRequests.approve": { + "post": { + "tags": [ + "AccessRequests" + ], + "summary": "Approve an access request", + "description": "Approve a pending access request, granting the requesting user a membership on the document with the specified permission.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the access request." + }, + "permission": { + "type": "string", + "description": "The permission to grant the requesting user.", + "enum": [ + "read", + "read_write", + "admin" + ], + "default": "read" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/AccessRequest" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "accessRequestsApprove" + } + }, + "/accessRequests.dismiss": { + "post": { + "tags": [ + "AccessRequests" + ], + "summary": "Dismiss an access request", + "description": "Dismiss a pending access request without granting the requesting user access to the document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the access request." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/AccessRequest" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "accessRequestsDismiss" + } + }, + "/attachments.create": { + "post": { + "tags": [ + "Attachments" + ], + "summary": "Create an attachment", + "description": "Creating an attachment object creates a database record and returns the inputs needed to generate a signed url and upload the file from the client to cloud storage.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the file attachment.", + "example": "image.png" + }, + "documentId": { + "type": "string", + "description": "Identifier for the associated document, if any.", + "format": "uuid" + }, + "contentType": { + "type": "string", + "description": "MIME type of the file attachment.", + "example": "image/png" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "Size of the file attachment in bytes." + } + }, + "required": [ + "name", + "contentType", + "size" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "maxUploadSize": { + "type": "number" + }, + "mode": { + "type": "string", + "enum": [ + "post", + "put" + ], + "description": "Indicates which presigned upload method the server is configured to use. When `post`, the client should perform a multipart form POST using `uploadUrl` and `form`. When `put`, the client should perform a PUT request to `url` with the supplied `headers`." + }, + "uploadUrl": { + "type": "string", + "format": "uri", + "description": "Present when `mode` is `post`. The endpoint to POST a multipart form upload to." + }, + "form": { + "type": "object", + "description": "Present when `mode` is `post`. The form fields to include in the multipart upload, including signed credentials." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Present when `mode` is `put`. The presigned URL to PUT the file contents to." + }, + "headers": { + "type": "object", + "description": "Present when `mode` is `put`. The HTTP headers that must be sent with the PUT request." + }, + "attachment": { + "$ref": "#/components/schemas/Attachment" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "attachmentsCreate" + } + }, + "/attachments.redirect": { + "post": { + "tags": [ + "Attachments" + ], + "summary": "Retrieve an attachment", + "description": "Load an attachment from where it is stored based on the id. If the attachment is private then a temporary, signed url with embedded credentials is generated on demand.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the attachment.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "302": { + "description": "Redirect to the attachment URL" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "attachmentsRedirect" + } + }, + "/attachments.delete": { + "post": { + "tags": [ + "Attachments" + ], + "summary": "Delete an attachment", + "description": "Deleting an attachment is permanent. It will not delete references or links to the attachment that may exist in your documents.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the attachment." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "attachmentsDelete" + } + }, + "/auth.info": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Retrieve auth", + "description": "Retrieve authentication details for the current API key", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Auth" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "authInfo" + } + }, + "/auth.config": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Retrieve auth config", + "description": "Retrieve authentication options", + "security": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Acme Inc" + }, + "hostname": { + "type": "string", + "example": "acme-inc.getoutline.com" + }, + "services": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "slack" + }, + "name": { + "type": "string", + "example": "Slack" + }, + "authUrl": { + "type": "string", + "example": "https://acme-inc.getoutline.com/auth/slack" + } + } + } + } + } + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "authConfig" + } + }, + "/collections.info": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Retrieve a collection", + "description": "Retrieve the details of a collection by its unique identifier.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the collection.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Collection" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsInfo" + } + }, + "/collections.documents": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Retrieve a collections document structure", + "description": "Returns the document structure of a collection as a tree of navigation nodes, representing the hierarchy of documents within the collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the collection.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NavigationNode" + }, + "example": [] + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsDocuments" + } + }, + "/collections.list": { + "post": { + "tags": [ + "Collections" + ], + "summary": "List all collections", + "description": "List all collections that the authenticated user has access to.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "If set, will filter the results by collection name." + }, + "statusFilter": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionStatus" + }, + "description": "An optional array of statuses to filter by." + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Collection" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsList" + } + }, + "/collections.create": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Create a collection", + "description": "Create a new collection with the specified name, description, icon, color, and permission settings. Collections are used to organize documents.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Human Resources" + }, + "description": { + "type": "string", + "description": "A brief description of the collection, markdown supported. Only one of `description` or `data` may be provided.", + "example": "HR documentation is confidential and should be handled with care." + }, + "data": { + "type": "object", + "description": "The collection description as a rich-text ProseMirror JSON document. Only one of `description` or `data` may be provided." + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "icon": { + "type": "string", + "description": "A string that represents an icon in the outline-icons package or an emoji" + }, + "color": { + "type": "string", + "description": "A hex color code for the collection icon", + "example": "#123123" + }, + "sharing": { + "type": "boolean", + "description": "Whether public sharing of documents is allowed", + "example": false + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Collection" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsCreate" + } + }, + "/collections.update": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Update a collection", + "description": "Update an existing collection's properties such as name, description, icon, color, sharing settings, or permission level.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "Human Resources" + }, + "description": { + "type": "string", + "description": "A brief description of the collection, markdown supported. Only one of `description` or `data` may be provided.", + "example": "HR documentation is confidential and should be handled with care." + }, + "data": { + "type": "object", + "description": "The collection description as a rich-text ProseMirror JSON document. Only one of `description` or `data` may be provided." + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "icon": { + "type": "string", + "description": "A string that represents an icon in the outline-icons package or an emoji" + }, + "color": { + "type": "string", + "description": "A hex color code for the collection icon", + "example": "#123123" + }, + "sharing": { + "type": "boolean", + "description": "Whether public sharing of documents is allowed", + "example": false + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Collection" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsUpdate" + } + }, + "/collections.add_user": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Add a collection user", + "description": "This method allows you to add a user membership to the specified collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the collection", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Identifier for the user to add to the collection", + "format": "uuid" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id", + "userId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Membership" + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsAddUser" + } + }, + "/collections.remove_user": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Remove a collection user", + "description": "This method allows you to remove a user from the specified collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the collection", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Identifier for the user to remove from the collection", + "format": "uuid" + } + }, + "required": [ + "id", + "userId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsRemoveUser" + } + }, + "/collections.memberships": { + "post": { + "tags": [ + "Collections" + ], + "summary": "List all collection memberships", + "description": "This method allows you to list a collections individual memberships. It's important to note that memberships returned from this endpoint do not include group memberships.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the collection", + "format": "uuid" + }, + "query": { + "type": "string", + "description": "Filter memberships by user names", + "example": "jenny" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Membership" + } + } + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsMemberships" + } + }, + "/collections.add_group": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Add a group to a collection", + "description": "This method allows you to give all members in a group access to a collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "groupId": { + "type": "string", + "format": "uuid" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id", + "groupId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "collectionGroupMemberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionGroupMembership" + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsAddGroup" + } + }, + "/collections.remove_group": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Remove a collection group", + "description": "This method allows you to revoke all members in a group access to a collection. Note that members of the group may still retain access through other groups or individual memberships.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the collection", + "format": "uuid" + }, + "groupId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "groupId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsRemoveGroup" + } + }, + "/collections.group_memberships": { + "post": { + "tags": [ + "Collections" + ], + "summary": "List all collection group members", + "description": "This method allows you to list a collections group memberships. This is the list of groups that have been given access to the collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the collection", + "format": "uuid" + }, + "query": { + "type": "string", + "description": "Filter memberships by group names", + "example": "developers" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Group" + } + }, + "collectionGroupMemberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionGroupMembership" + } + } + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsGroupMemberships" + } + }, + "/collections.delete": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Delete a collection", + "description": "Delete a collection and all of its documents. This action can’t be undone so please be careful.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsDelete" + } + }, + "/collections.export": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Export a collection", + "description": "Triggers a bulk export of the collection in markdown format and their attachments. If documents are nested then they will be nested in folders inside the zip file. The endpoint returns a `FileOperation` that can be queried to track the progress of the export and get the url for the final file.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "outline-markdown", + "json", + "html" + ] + }, + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "fileOperation": { + "$ref": "#/components/schemas/FileOperation" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsExport" + } + }, + "/collections.export_all": { + "post": { + "tags": [ + "Collections" + ], + "summary": "Export all collections", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "outline-markdown", + "json", + "html" + ] + }, + "includeAttachments": { + "type": "boolean", + "description": "Whether to include attachments in the export.", + "default": true + }, + "includePrivate": { + "type": "boolean", + "description": "Whether to include private collections in the export.", + "default": true + } + } + } + } + } + }, + "description": "Triggers a bulk export of multiple collections and their documents. The endpoint returns a `FileOperation` that can be queried through the fileOperations endpoint to track the progress of the export and get the url for the final file.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "fileOperation": { + "$ref": "#/components/schemas/FileOperation" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "collectionsExportAll" + } + }, + "/comments.create": { + "post": { + "tags": [ + "Comments" + ], + "summary": "Create a comment", + "description": "Add a comment or reply to a document, either `data` or `text` is required. Provide `anchorText` to create an inline comment attached to a specific text range in the document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "documentId": { + "type": "string", + "format": "uuid" + }, + "parentCommentId": { + "type": "string", + "format": "uuid" + }, + "data": { + "type": "object", + "description": "The body of the comment." + }, + "text": { + "type": "string", + "description": "The body of the comment in markdown.", + "example": "Sounds great" + }, + "anchorText": { + "type": "string", + "description": "Plain text substring to anchor the comment to as an inline comment. The first occurrence in the document's plain text is used unless disambiguated by `anchorPrefix` and/or `anchorSuffix`." + }, + "anchorPrefix": { + "type": "string", + "description": "Text immediately preceding `anchorText`, used to disambiguate between multiple occurrences. Requires `anchorText`." + }, + "anchorSuffix": { + "type": "string", + "description": "Text immediately following `anchorText`, used to disambiguate between multiple occurrences. Requires `anchorText`." + } + }, + "required": [ + "documentId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Comment" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "commentsCreate" + } + }, + "/comments.info": { + "post": { + "tags": [ + "Comments" + ], + "summary": "Retrieve a comment", + "description": "Retrieve a comment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "includeAnchorText": { + "type": "boolean", + "description": "Include the document text that the comment is anchored to, if any, in the response." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Comment" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "commentsInfo" + } + }, + "/comments.update": { + "post": { + "tags": [ + "Comments" + ], + "summary": "Update a comment", + "description": "Update a comment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "data": { + "type": "object" + } + }, + "required": [ + "id", + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Comment" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "commentsUpdate" + } + }, + "/comments.delete": { + "post": { + "tags": [ + "Comments" + ], + "summary": "Delete a comment", + "description": "Deletes a comment. If the comment is a top-level comment, all its children will be deleted as well.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "commentsDelete" + } + }, + "/comments.list": { + "post": { + "tags": [ + "Comments" + ], + "summary": "List all comments", + "description": "This method will list all comments matching the given properties.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid", + "description": "Filter to a specific document" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Filter to a specific collection" + }, + "includeAnchorText": { + "type": "boolean", + "description": "Include the document text that the comment is anchored to, if any, in the response." + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Comment" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "commentsList" + } + }, + "/dataAttributes.info": { + "post": { + "x-badges": [ + { + "name": "Business" + }, + { + "name": "Enterprise" + } + ], + "tags": [ + "DataAttributes" + ], + "summary": "Retrieve a data attribute", + "description": "Retrieve a data attribute by its unique identifier.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the data attribute.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/DataAttribute" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "dataAttributesInfo" + } + }, + "/dataAttributes.list": { + "post": { + "x-badges": [ + { + "name": "Business" + }, + { + "name": "Enterprise" + } + ], + "tags": [ + "DataAttributes" + ], + "summary": "List all data attributes", + "description": "List all data attributes.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataAttribute" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "dataAttributesList" + } + }, + "/dataAttributes.create": { + "post": { + "x-badges": [ + { + "name": "Business" + }, + { + "name": "Enterprise" + } + ], + "tags": [ + "DataAttributes" + ], + "summary": "Create a data attribute", + "description": "Create a new data attribute. Only admins can create data attributes.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the data attribute.", + "example": "Status" + }, + "description": { + "type": "string", + "description": "Description of the data attribute.", + "example": "The current status of the document." + }, + "dataType": { + "$ref": "#/components/schemas/DataAttributeDataType" + }, + "options": { + "$ref": "#/components/schemas/DataAttributeOptions" + }, + "pinned": { + "type": "boolean", + "description": "Whether the data attribute is pinned to the top of document.", + "default": false + } + }, + "required": [ + "name", + "dataType" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/DataAttribute" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "dataAttributesCreate" + } + }, + "/dataAttributes.update": { + "post": { + "x-badges": [ + { + "name": "Business" + }, + { + "name": "Enterprise" + } + ], + "tags": [ + "DataAttributes" + ], + "summary": "Update a data attribute", + "description": "Update an existing data attribute. Only admins can update data attributes. Note that the dataType cannot be changed after creation.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the data attribute.", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Name of the data attribute.", + "example": "Status" + }, + "description": { + "type": "string", + "description": "Description of the data attribute." + }, + "options": { + "$ref": "#/components/schemas/DataAttributeOptions" + }, + "pinned": { + "type": "boolean", + "description": "Whether the data attribute is pinned to the top of document." + } + }, + "required": [ + "id", + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/DataAttribute" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "dataAttributesUpdate" + } + }, + "/dataAttributes.delete": { + "post": { + "x-badges": [ + { + "name": "Business" + }, + { + "name": "Enterprise" + } + ], + "tags": [ + "DataAttributes" + ], + "summary": "Delete a data attribute", + "description": "Delete a data attribute. Only admins can delete data attributes.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the data attribute.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "dataAttributesDelete" + } + }, + "/documents.info": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Retrieve a document", + "description": "Retrieve a document by its `UUID`, `urlId`, or `shareId`. At least one of these parameters must be provided.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "shareId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for a document share, a shareId may be used in place of a document UUID" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsInfo" + } + }, + "/documents.insights": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Retrieve insights for a document", + "description": "Retrieve a chronologically sorted array of activity rollups (views, comments, reactions, revisions, editors) for a document. Recent activity is returned as daily rollups, while older activity is aggregated into weekly rollups. Insights must be enabled on the document. Defaults to the last 30 days when no date range is provided.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the document." + }, + "startDate": { + "type": "string", + "format": "date-time", + "description": "Start of the insights window (inclusive). Defaults to 30 days ago." + }, + "endDate": { + "type": "string", + "format": "date-time", + "description": "End of the insights window (inclusive). Defaults to today." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentInsight" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsInsights" + } + }, + "/documents.import": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Import a file as a document", + "description": "This method allows you to create a new document by importing an existing file. By default a document is set to the collection root. If you want to create a nested/child document, you should pass parentDocumentId to set the parent document.", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "object", + "description": "Plain text, markdown, docx, csv, tsv, and html format are supported." + }, + "collectionId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier for the collection to import into. One of collectionId or parentDocumentId is required." + }, + "parentDocumentId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier for the parent document to import under. One of collectionId or parentDocumentId is required." + }, + "publish": { + "type": "boolean", + "description": "Whether to publish the imported document" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsImport" + } + }, + "/documents.export": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Export a document.", + "description": "Export a document in Markdown, HTML, or PDF format. The response format is determined by the Accept header. Optionally include child documents in the export as a zip file.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "paperSize": { + "type": "string", + "description": "Paper size for PDF export (e.g., \"A4\", \"Letter\")" + }, + "signedUrls": { + "type": "number", + "description": "How long signed URLs should remain valid for attachment links (in seconds)" + }, + "includeChildDocuments": { + "type": "boolean", + "description": "Whether to include child documents in the export. Using this option will always return a zip file.", + "default": false + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "The document content in Markdown formatting" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsExport" + } + }, + "/documents.list": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List all documents", + "description": "This method will list all published documents and draft documents belonging to the current user.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Optionally filter to a specific collection" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "Optionally filter to documents created by a specific user" + }, + "backlinkDocumentId": { + "type": "string", + "format": "uuid" + }, + "parentDocumentId": { + "type": "string", + "format": "uuid" + }, + "statusFilter": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "draft", + "archived", + "published" + ] + }, + "description": "Document statuses to include in results" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsList" + } + }, + "/documents.documents": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Retrieve a document's child structure", + "description": "This method returns the nested document structure (tree) for the children of the specified document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/NavigationNode" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsDocuments" + } + }, + "/documents.drafts": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List all draft documents", + "description": "This method will list all draft documents belonging to the current user.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "collectionId": { + "type": "string", + "description": "A collection to search within", + "format": "uuid" + }, + "dateFilter": { + "type": "string", + "description": "Any documents that have not been updated within the specified period will be filtered out", + "example": "month", + "enum": [ + "day", + "week", + "month", + "year" + ] + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsDrafts" + } + }, + "/documents.viewed": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List all recently viewed documents", + "description": "This method will list all documents recently viewed by the current user.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsViewed" + } + }, + "/documents.answerQuestion": { + "post": { + "x-badges": [ + { + "name": "Business" + }, + { + "name": "Enterprise" + }, + { + "name": "Cloud" + } + ], + "tags": [ + "Documents" + ], + "summary": "Query documents with natural language", + "description": "This method allows asking direct questions of your documents – where possible an answer will be provided. Search results will be restricted to those accessible by the current access token. Note that \"AI answers\" must be enabled for the workspace.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "properties": { + "query": { + "type": "string", + "example": "What is our holiday policy?" + }, + "userId": { + "type": "string", + "description": "Any documents that have not been edited by the user identifier will be filtered out", + "format": "uuid" + }, + "collectionId": { + "type": "string", + "description": "A collection to search within", + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "A document to search within", + "format": "uuid" + }, + "statusFilter": { + "type": "string", + "description": "Any documents that are not in the specified status will be filtered out", + "enum": [ + "draft", + "archived", + "published" + ] + }, + "dateFilter": { + "type": "string", + "description": "Any documents that have not been updated within the specified period will be filtered out", + "enum": [ + "day", + "week", + "month", + "year" + ] + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "search": { + "$ref": "#/components/schemas/SearchResult" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsAnswerQuestion" + } + }, + "/documents.search_titles": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Search document titles", + "description": "This method allows you to search document titles with keywords. Unlike documents.search, this only searches titles and returns faster results.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query to match against document titles" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Filter to a specific collection" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "Filter results based on user" + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Filter results based on content within a document and its children" + }, + "statusFilter": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "draft", + "archived", + "published" + ] + }, + "description": "Document statuses to include in results" + }, + "dateFilter": { + "type": "string", + "description": "Any documents that have not been updated within the specified period will be filtered out", + "enum": [ + "day", + "week", + "month", + "year" + ] + }, + "shareId": { + "type": "string", + "description": "Filter results for the collection or document referenced by the shareId" + }, + "sort": { + "type": "string", + "enum": [ + "relevance", + "createdAt", + "updatedAt", + "title" + ], + "description": "Specifies the attributes by which search results will be sorted" + }, + "direction": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "description": "Specifies the sort order with respect to sort field" + } + }, + "required": [ + "query" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsSearchTitles" + } + }, + "/documents.search": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Search all documents", + "description": "This methods allows you to search your workspace's documents with keywords. Note that search results will be restricted to those accessible by the current access token.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "type": "object", + "properties": { + "query": { + "type": "string", + "example": "hiring" + }, + "userId": { + "type": "string", + "description": "Any documents that have not been edited by the user identifier will be filtered out", + "format": "uuid" + }, + "collectionId": { + "type": "string", + "description": "A collection to search within", + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "A document to search within", + "format": "uuid" + }, + "statusFilter": { + "type": "array", + "description": "Document statuses to include in results", + "items": { + "type": "string", + "enum": [ + "draft", + "archived", + "published" + ] + } + }, + "dateFilter": { + "type": "string", + "description": "Any documents that have not been updated within the specified period will be filtered out", + "example": "month", + "enum": [ + "day", + "week", + "month", + "year" + ] + }, + "shareId": { + "type": "string", + "description": "Filter results to the collection or document referenced by the shareId" + }, + "snippetMinWords": { + "type": "number", + "description": "Minimum number of words to show in search result snippets", + "default": 20 + }, + "snippetMaxWords": { + "type": "number", + "description": "Maximum number of words to show in search result snippets", + "default": 30 + }, + "sort": { + "type": "string", + "enum": [ + "relevance", + "createdAt", + "updatedAt", + "title" + ], + "description": "Specifies the attributes by which search results will be sorted" + }, + "direction": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "description": "Specifies the sort order with respect to sort field" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "A short snippet of context from the document that includes the search query.", + "example": "At Acme Inc our hiring practices are inclusive" + }, + "ranking": { + "type": "number", + "description": "The ranking used to order search results based on relevance.", + "format": "float", + "example": 1.1844109 + }, + "document": { + "$ref": "#/components/schemas/Document" + } + } + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsSearch" + } + }, + "/documents.create": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Create a document", + "description": "This method allows you to create or publish a new document. By default a document is set to the collection root. If you want to create a nested/child document, you should pass parentDocumentId to set the parent document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Optional identifier for the document" + }, + "title": { + "type": "string", + "example": "Welcome to Acme Inc" + }, + "text": { + "type": "string", + "maxLength": 1536000, + "description": "The body of the document in markdown" + }, + "icon": { + "type": "string", + "description": "Icon displayed alongside the document title" + }, + "color": { + "type": "string", + "nullable": true, + "description": "Color for the document icon (hex format)" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier for the collection. Required to publish unless parentDocumentId is provided" + }, + "parentDocumentId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier for the parent document. Required to publish unless collectionId is provided" + }, + "templateId": { + "type": "string", + "format": "uuid" + }, + "publish": { + "type": "boolean", + "description": "Whether this document should be immediately published and made visible to other workspace members." + }, + "fullWidth": { + "type": "boolean", + "description": "Whether the document should be displayed in full width" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Optionally set the created date in the past" + }, + "dataAttributes": { + "type": "array", + "description": "Data attributes to be included on the document.", + "items": { + "type": "object", + "properties": { + "dataAttributeId": { + "type": "string", + "description": "Unique identifier for the data attribute.", + "format": "uuid" + }, + "value": { + "description": "The value of the data attribute. Can be a string, boolean, or number depending on the data attribute type.", + "example": "In Progress", + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "dataAttributeId", + "value" + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsCreate" + } + }, + "/documents.update": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Update a document", + "description": "This method allows you to modify an already created document", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "title": { + "type": "string", + "description": "The title of the document." + }, + "text": { + "type": "string", + "maxLength": 1536000, + "description": "The body of the document in markdown." + }, + "icon": { + "type": "string", + "nullable": true, + "description": "Icon displayed alongside the document title" + }, + "color": { + "type": "string", + "nullable": true, + "description": "Color for the document icon (hex format)" + }, + "fullWidth": { + "type": "boolean", + "description": "Whether the document should be displayed in full width" + }, + "templateId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier for the template this document is based on" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier for the collection to move the document to" + }, + "insightsEnabled": { + "type": "boolean", + "description": "Whether insights should be visible on the document" + }, + "editMode": { + "$ref": "#/components/schemas/TextEditMode" + }, + "findText": { + "type": "string", + "description": "The text to find within the document when using `patch` editMode. This text will be replaced with the value of `text`. Required when `editMode` is `patch`." + }, + "publish": { + "type": "boolean", + "description": "Whether this document should be published and made visible to other workspace members, if a draft" + }, + "dataAttributes": { + "type": "array", + "description": "Data attributes to be updated. Attributes not included will be removed from the document.", + "nullable": true, + "items": { + "type": "object", + "properties": { + "dataAttributeId": { + "type": "string", + "description": "Unique identifier for the data attribute.", + "format": "uuid" + }, + "value": { + "description": "The value of the data attribute. Can be a string, boolean, or number depending on the data attribute type.", + "example": "In Progress", + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "dataAttributeId", + "value" + ] + } + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsUpdate" + } + }, + "/documents.templatize": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Create a template from a document", + "description": "This method allows you to create a new template using an existing document as the basis", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier for the collection where the template should be created" + }, + "publish": { + "type": "boolean", + "description": "Whether the new template should be published" + } + }, + "required": [ + "id", + "publish" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsTemplatize" + } + }, + "/documents.unpublish": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Unpublish a document", + "description": "Unpublishing a document moves it back to a draft status and out of the collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "detach": { + "type": "boolean", + "description": "Whether to detach the document from the collection", + "default": false + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsUnpublish" + } + }, + "/documents.move": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Move a document", + "description": "Move a document to a new location or collection. If no parent document is provided, the document will be moved to the collection root.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "collectionId": { + "type": "string", + "format": "uuid" + }, + "parentDocumentId": { + "type": "string", + "format": "uuid" + }, + "index": { + "type": "number", + "description": "The position index in the collection structure" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "collections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Collection" + } + } + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsMove" + } + }, + "/documents.archive": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Archive a document", + "description": "Archiving a document allows outdated information to be moved out of sight whilst retaining the ability to optionally search and restore it later.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsArchive" + } + }, + "/documents.restore": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Restore a document", + "description": "If a document has been archived or deleted, it can be restored. Optionally a revision can be passed to restore the document to a previous point in time.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the collection to restore the document to." + }, + "revisionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the revision to restore to." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsRestore" + } + }, + "/documents.delete": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Delete a document", + "description": "Deleting a document moves it to the trash. If not restored within 30 days it is permanently deleted.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "permanent": { + "type": "boolean", + "example": false, + "description": "If set to true the document will be destroyed with no way to recover rather than moved to the trash." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsDelete" + } + }, + "/documents.users": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List document users", + "description": "All users with access to a document. To list only users with direct membership to the document use `documents.memberships`", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "query": { + "type": "string", + "description": "If set, will filter the results by user name." + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "If set, will filter the results to a specific user." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsUsers" + } + }, + "/documents.memberships": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List document memberships", + "description": "Users with direct membership to a document. To list all users with access to a document use `documents.users`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "hDYep1TPAM", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "query": { + "type": "string", + "description": "If set, will filter the results by user name" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Membership" + } + } + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsMemberships" + } + }, + "/documents.add_user": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Add a document user", + "description": "This method allows you to add a user membership to the specified document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id", + "userId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Membership" + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsAddUser" + } + }, + "/documents.remove_user": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Remove a document user", + "description": "This method allows you to remove a user membership from the specified document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "userId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "userId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsRemoveUser" + } + }, + "/documents.archived": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List all archived documents", + "description": "This method will list all archived documents belonging to the workspace that the current user has access to.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Optionally filter to a specific collection" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsArchived" + } + }, + "/documents.deleted": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List all deleted documents", + "description": "This method will list all deleted documents belonging to the workspace that the current user has access to.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsDeleted" + } + }, + "/documents.duplicate": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Duplicate a document", + "description": "This method allows you to duplicate an existing document and optionally all of its child documents.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "title": { + "type": "string", + "description": "New title for the duplicated document" + }, + "recursive": { + "type": "boolean", + "description": "Whether child documents should also be duplicated" + }, + "publish": { + "type": "boolean", + "description": "Whether the new document should be published" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the collection the document should be copied to" + }, + "parentDocumentId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the parent document the document should be copied to" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + } + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsDuplicate" + } + }, + "/documents.add_group": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Add a group to a document", + "description": "This method allows you to give all members in a group access to a document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "groupId": { + "type": "string", + "format": "uuid" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id", + "groupId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "groupMemberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionGroupMembership" + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsAddGroup" + } + }, + "/documents.remove_group": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Remove a group from a document", + "description": "This method allows you to revoke all members in a group access to a document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "groupId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "groupId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsRemoveGroup" + } + }, + "/documents.group_memberships": { + "post": { + "tags": [ + "Documents" + ], + "summary": "List document group memberships", + "description": "This method allows you to list a document's group memberships.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document. Either the UUID or the urlId is acceptable." + }, + "query": { + "type": "string", + "description": "Filter memberships by group names" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "id" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Group" + } + }, + "groupMemberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionGroupMembership" + } + } + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsGroupMemberships" + } + }, + "/documents.empty_trash": { + "post": { + "tags": [ + "Documents" + ], + "summary": "Empty trash", + "description": "Permanently delete all documents in the trash. This action is irreversible. Only available to admin users.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "documentsEmptyTrash" + } + }, + "/events.list": { + "post": { + "tags": [ + "Events" + ], + "summary": "List all events", + "description": "Events are an audit trail of important events that happen in the knowledge base.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Filter to a specific event, e.g. \"collections.create\". Event names are in the format \"objects.verb\"" + }, + "actorId": { + "type": "string", + "format": "uuid", + "description": "Filter to events performed by the selected user" + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Filter to events performed in the selected document" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Filter to events performed in the selected collection" + }, + "auditLog": { + "type": "boolean", + "description": "Whether to return detailed events suitable for an audit log. Without this flag less detailed event types will be returned." + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Event" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "eventsList" + } + }, + "/fileOperations.info": { + "post": { + "tags": [ + "FileOperations" + ], + "summary": "Retrieve a file operation", + "description": "Retrieve the details and current status of a file operation by its unique identifier. File operations represent long-running import or export tasks.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the file operation.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FileOperation" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "fileOperationsInfo" + } + }, + "/fileOperations.delete": { + "post": { + "tags": [ + "FileOperations" + ], + "summary": "Delete a file operation", + "description": "Delete a file operation and its associated files. This is useful for cleaning up completed or failed import/export operations.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the file operation.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "fileOperationsDelete" + } + }, + "/fileOperations.redirect": { + "post": { + "tags": [ + "FileOperations" + ], + "summary": "Retrieve the file", + "description": "Load the resulting file from where it is stored based on the id. A temporary, signed url with embedded credentials is generated on demand.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the file operation.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "fileOperationsRedirect" + } + }, + "/fileOperations.list": { + "post": { + "tags": [ + "FileOperations" + ], + "summary": "List all file operations", + "description": "List all file operations for the current workspace, filtered by type (import or export).", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of fileOperation", + "example": "export", + "enum": [ + "export", + "import" + ] + } + }, + "required": [ + "type" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileOperation" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "fileOperationsList" + } + }, + "/groups.info": { + "post": { + "tags": [ + "Groups" + ], + "summary": "Retrieve a group", + "description": "Retrieve the details of a group by its unique identifier, including its name and member count.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the group.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Group" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsInfo" + } + }, + "/groups.list": { + "post": { + "tags": [ + "Groups" + ], + "summary": "List all groups", + "description": "List all groups in the workspace. Groups are used to organize users and manage permissions for collections.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "userId": { + "type": "string", + "format": "uuid", + "description": "Filter to groups including a specific user" + }, + "externalId": { + "type": "string", + "format": "uuid", + "description": "Filter to groups matching an external ID" + }, + "query": { + "type": "string", + "format": "uuid", + "description": "Filter to groups matching a search query" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Group" + } + }, + "groupMemberships": { + "type": "array", + "description": "A preview of memberships in the group, note that this is not all memberships which can be queried from `groups.memberships`.", + "items": { + "$ref": "#/components/schemas/GroupMembership" + } + } + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsList" + } + }, + "/groups.create": { + "post": { + "tags": [ + "Groups" + ], + "summary": "Create a group", + "description": "Create a new group with the specified name. Groups can be used to organize users and assign collection permissions to multiple users at once.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Designers" + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Group" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsCreate" + } + }, + "/groups.update": { + "post": { + "tags": [ + "Groups" + ], + "summary": "Update a group", + "description": "Update an existing group's name. The group is identified by its unique identifier.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "Designers" + } + }, + "required": [ + "id", + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Group" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsUpdate" + } + }, + "/groups.delete": { + "post": { + "tags": [ + "Groups" + ], + "summary": "Delete a group", + "description": "Deleting a group will cause all of its members to lose access to any collections the group has previously been added to. This action can’t be undone so please be careful.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsDelete" + } + }, + "/groups.memberships": { + "post": { + "tags": [ + "Groups" + ], + "summary": "List all group members", + "description": "List and filter all the members in a group.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Group id", + "example": "a32c2ee6-fbde-4654-841b-0eabdc71b812" + }, + "query": { + "type": "string", + "description": "Filter memberships by user names", + "example": "jenny" + } + }, + "required": [ + "id" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "groupMemberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupMembership" + } + } + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsMemberships" + } + }, + "/groups.add_user": { + "post": { + "tags": [ + "Groups" + ], + "summary": "Add a group member", + "description": "This method allows you to add a user to the specified group.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the group", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Identifier for the user to add to the group", + "format": "uuid" + } + }, + "required": [ + "id", + "userId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Group" + } + }, + "groupMemberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupMembership" + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsAddUser" + } + }, + "/groups.remove_user": { + "post": { + "tags": [ + "Groups" + ], + "summary": "Remove a group member", + "description": "This method allows you to remove a user from the group.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the group", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Identifier for the user to remove from the group", + "format": "uuid" + } + }, + "required": [ + "id", + "userId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Group" + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "groupsRemoveUser" + } + }, + "/oauthClients.info": { + "post": { + "tags": [ + "OAuthClients" + ], + "summary": "Retrieve an OAuth client", + "description": "To retrieve information about an OAuth client you must pass either an `id` or a `clientId`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the OAuth client.", + "format": "uuid" + }, + "clientId": { + "type": "string", + "description": "Public identifier for the OAuth client.", + "example": "2bquf8avrpdv31par42a" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/OAuthClient" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthClientsInfo" + } + }, + "/oauthClients.list": { + "post": { + "tags": [ + "OAuthClients" + ], + "summary": "List accessible OAuth clients", + "description": "List all OAuth clients that the authenticated user has access to. This includes both clients created by the user and published clients available to the workspace.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pagination" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OAuthClient" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthClientsList" + } + }, + "/oauthClients.create": { + "post": { + "tags": [ + "OAuthClients" + ], + "summary": "Create an OAuth client", + "description": "Create a new OAuth client application that can be used to authenticate users and access the API on their behalf.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the OAuth client.", + "example": "My App" + }, + "description": { + "type": "string", + "description": "A short description of this OAuth client.", + "example": "Integrate Acme Inc's services into Outline." + }, + "developerName": { + "type": "string", + "description": "The name of the developer who created this OAuth client.", + "example": "Acme Inc" + }, + "developerUrl": { + "type": "string", + "description": "The URL of the developer who created this OAuth client.", + "example": "https://example.com" + }, + "avatarUrl": { + "type": "string", + "description": "A URL pointing to an image representing the OAuth client." + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of redirect URIs for the OAuth client.", + "example": [ + "https://example.com/callback" + ] + }, + "published": { + "type": "boolean", + "description": "Whether the OAuth client is available to other workspaces.", + "example": true + } + }, + "required": [ + "name", + "redirectUris" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/OAuthClient" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthClientsCreate" + } + }, + "/oauthClients.update": { + "post": { + "tags": [ + "OAuthClients" + ], + "summary": "Update an OAuth client", + "description": "Update an existing OAuth client's properties such as name, description, redirect URIs, or published status.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the OAuth client.", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Name of the OAuth client.", + "example": "My App" + }, + "description": { + "type": "string", + "description": "A short description of this OAuth client.", + "example": "Integrate Acme Inc's services into Outline." + }, + "developerName": { + "type": "string", + "description": "The name of the developer who created this OAuth client.", + "example": "Acme Inc" + }, + "developerUrl": { + "type": "string", + "description": "The URL of the developer who created this OAuth client.", + "example": "https://example.com" + }, + "avatarUrl": { + "type": "string", + "description": "A URL pointing to an image representing the OAuth client." + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of redirect URIs for the OAuth client.", + "example": [ + "https://example.com/callback" + ] + }, + "published": { + "type": "boolean", + "description": "Whether the OAuth client is available to other workspaces.", + "example": true + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/OAuthClient" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthClientsUpdate" + } + }, + "/oauthClients.rotate_secret": { + "post": { + "tags": [ + "OAuthClients" + ], + "summary": "Rotate the secret for an OAuth client", + "description": "Generate a new client secret for an OAuth client. The old secret will be invalidated immediately, so ensure your application is updated to use the new secret.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the OAuth client.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/OAuthClient" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthClientsRotateSecret" + } + }, + "/oauthClients.delete": { + "post": { + "tags": [ + "OAuthClients" + ], + "summary": "Delete an OAuth client", + "description": "Permanently delete an OAuth client and revoke all associated access tokens. This action cannot be undone.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the OAuth client.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthClientsDelete" + } + }, + "/oauthAuthentications.list": { + "post": { + "tags": [ + "OAuthAuthentications" + ], + "summary": "List accessible OAuth authentications", + "description": "List all OAuth authentications for the current user. These represent the third-party applications that the user has authorized to access their account.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pagination" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OAuthAuthentication" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthAuthenticationsList" + } + }, + "/oauthAuthentications.delete": { + "post": { + "tags": [ + "OAuthAuthentications" + ], + "summary": "Delete an OAuth authentiation", + "description": "Revoke an OAuth authentication, removing the third-party application's access to the user's account.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "oauthClientId": { + "type": "string", + "format": "uuid" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "oauthClientId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "oauthAuthenticationsDelete" + } + }, + "/revisions.info": { + "post": { + "tags": [ + "Revisions" + ], + "summary": "Retrieve a revision", + "description": "A revision is a snapshot of a document at a specific point in time. This endpoint allows you to retrieve a specific version of a document by its unique identifier.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the revision.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/RevisionDetail" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "revisionsInfo" + } + }, + "/revisions.list": { + "post": { + "tags": [ + "Revisions" + ], + "summary": "List all revisions", + "description": "List all revisions for a specific document. Revisions represent historical snapshots of a document's content and can be used to track changes over time. The `data` and `text` fields are omitted from listed revisions for performance; use `revisions.info` to retrieve the full content of a specific revision.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid", + "description": "The document ID to retrieve revisions for" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Revision" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "revisionsList" + } + }, + "/shares.info": { + "post": { + "tags": [ + "Shares" + ], + "summary": "Retrieve a share object", + "description": "Retrieve the details of a share link by its unique identifier or by the associated document ID. Shares allow documents to be accessed publicly or by specific users.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the share.", + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "Unique identifier for a document. One of id or documentId must be provided.", + "format": "uuid" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Share" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "sharesInfo" + } + }, + "/shares.list": { + "post": { + "tags": [ + "Shares" + ], + "summary": "List all shares", + "description": "List all share links in the workspace.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Filter to shared documents matching a search query" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Share" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "sharesList" + } + }, + "/shares.create": { + "post": { + "tags": [ + "Shares" + ], + "summary": "Create a share", + "description": "Creates a new share link that can be used by to access a document or collection. If you request multiple shares for the same resource with the same API key, the same share object will be returned. By default all shares are unpublished. Exactly one of `documentId` or `collectionId` must be provided.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the document to share. Mutually exclusive with `collectionId`." + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the collection to share. Mutually exclusive with `documentId`." + } + }, + "oneOf": [ + { + "required": [ + "documentId" + ] + }, + { + "required": [ + "collectionId" + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Share" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "sharesCreate" + } + }, + "/shares.update": { + "post": { + "tags": [ + "Shares" + ], + "summary": "Update a share", + "description": "Allows changing an existing share's published status, which removes authentication and makes it available to anyone with the link.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "published": { + "type": "boolean" + }, + "title": { + "type": "string", + "maxLength": 255, + "nullable": true, + "description": "Override title displayed on the publicly shared page. If not set the source document or collection title is used." + }, + "iconUrl": { + "type": "string", + "format": "uri", + "maxLength": 4096, + "nullable": true, + "description": "URL of an icon to display on the publicly shared page, overriding the workspace branding." + } + }, + "required": [ + "id", + "published" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Share" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "sharesUpdate" + } + }, + "/shares.revoke": { + "post": { + "tags": [ + "Shares" + ], + "summary": "Revoke a share", + "description": "Makes the share link inactive so that it can no longer be used to access the document.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "sharesRevoke" + } + }, + "/stars.create": { + "post": { + "tags": [ + "Stars" + ], + "summary": "Create a star", + "description": "Stars a document or collection so it appears in the users sidebar. One of either `documentId` or `collectionId` must be provided.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + }, + "collectionId": { + "type": "string", + "format": "uuid" + }, + "index": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Star" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "starsCreate" + } + }, + "/stars.list": { + "post": { + "tags": [ + "Stars" + ], + "summary": "List all stars", + "description": "List all starred documents for the authenticated user. Stars allow users to bookmark important documents for quick access in the sidebar.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pagination" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "stars": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Star" + } + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Document" + } + } + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "starsList" + } + }, + "/stars.update": { + "post": { + "tags": [ + "Stars" + ], + "summary": "Update a stars order in the sidebar", + "description": "Update the position of a starred document in the sidebar. The index parameter determines the display order relative to other starred documents.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "index": { + "type": "string" + } + }, + "required": [ + "id", + "index" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Star" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "starsUpdate" + } + }, + "/stars.delete": { + "post": { + "tags": [ + "Stars" + ], + "summary": "Delete a star", + "description": "Remove a star from a document, removing it from the user's starred documents list in the sidebar.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "starsDelete" + } + }, + "/users.invite": { + "post": { + "tags": [ + "Users" + ], + "summary": "Invite users", + "description": "Send email invitations to one or more users to join the workspace. Invitations include a link to create an account and join the workspace.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "invites": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invite" + } + }, + "suppressEmail": { + "type": "boolean", + "description": "If true, the invitation emails will not be sent to the invited users. Defaults to false." + } + }, + "required": [ + "invites" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "sent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invite" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersInvite" + } + }, + "/users.info": { + "post": { + "tags": [ + "Users" + ], + "summary": "Retrieve a user", + "description": "Retrieve the details of a user by their unique identifier, including their name, email, avatar, and role within the workspace.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the user.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/User" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersInfo" + } + }, + "/users.list": { + "post": { + "tags": [ + "Users" + ], + "summary": "List all users", + "description": "List and filter all the users in the workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "query": { + "type": "string", + "example": "jane" + }, + "emails": { + "type": "array", + "description": "Array of emails", + "items": { + "type": "string" + }, + "example": [ + "jane.crandall@mail.com", + "prudence.crandall@mail.com" + ] + }, + "filter": { + "type": "string", + "description": "The status to filter by", + "enum": [ + "all", + "invited", + "active", + "suspended" + ] + }, + "role": { + "$ref": "#/components/schemas/UserRole" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersList" + } + }, + "/users.update": { + "post": { + "tags": [ + "Users" + ], + "summary": "Update a user", + "description": "Update a users name or avatar. If no `id` is passed then the user associated with the authentication will be updated by default.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "language": { + "type": "string", + "format": "BCP47" + }, + "avatarUrl": { + "type": "string", + "format": "uri" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/User" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersUpdate" + } + }, + "/users.update_role": { + "post": { + "tags": [ + "Users" + ], + "summary": "Change a users role", + "description": "Change the role of a user, only available to admin authorization.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the user.", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/UserRole" + } + }, + "required": [ + "id", + "role" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/User" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersUpdateRole" + } + }, + "/users.suspend": { + "post": { + "tags": [ + "Users" + ], + "summary": "Suspend a user", + "description": "Suspending a user prevents the user from signing in. Users that are suspended are also not counted against billing totals in the hosted version.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the user.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/User" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersSuspend" + } + }, + "/users.activate": { + "post": { + "tags": [ + "Users" + ], + "summary": "Activate a user", + "description": "Activating a previously suspended user allows them to signin again. Users that are activated will cause billing totals to be re-calculated in the hosted version.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the user.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/User" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersActivate" + } + }, + "/users.delete": { + "post": { + "tags": [ + "Users" + ], + "summary": "Delete a user", + "description": "Deleting a user removes the object entirely. In almost every circumstance it is preferable to suspend a user, as a deleted user can be recreated by signing in with SSO again.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the user.", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "usersDelete" + } + }, + "/views.list": { + "post": { + "tags": [ + "Views" + ], + "summary": "List all views", + "description": "List all users that have viewed a document and the overall view count.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid", + "description": "The document ID to retrieve views for" + }, + "includeSuspended": { + "type": "boolean", + "description": "Whether to include views from suspended users", + "default": false + } + }, + "required": [ + "documentId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/View" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "viewsList" + } + }, + "/views.create": { + "post": { + "tags": [ + "Views" + ], + "summary": "Create a view", + "description": "Creates a new view for a document. This is documented in the interests of thoroughness however it is recommended that views are not created from outside of the Outline UI.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/View" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "viewsCreate" + } + }, + "/templates.create": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Create a template", + "description": "Create a new template that can be used as a starting point for new documents. Templates can optionally be scoped to a specific collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Optionally provide a specific UUID for the template." + }, + "title": { + "type": "string", + "description": "The title of the template.", + "minLength": 1, + "maxLength": 255 + }, + "data": { + "type": "object", + "description": "The body of the template as a Prosemirror document." + }, + "icon": { + "type": "string", + "description": "An emoji to use as the template icon.", + "nullable": true + }, + "color": { + "type": "string", + "description": "The color of the template icon in hex format.", + "nullable": true, + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the collection to which the template belongs." + } + }, + "required": [ + "title", + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "templatesCreate" + } + }, + "/templates.list": { + "post": { + "tags": [ + "Templates" + ], + "summary": "List all templates", + "description": "List all templates available to the current user. Optionally filter by collection. Templates not associated with a collection are workspace-wide.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Pagination" + }, + { + "$ref": "#/components/schemas/Sorting" + }, + { + "type": "object", + "properties": { + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Optionally filter to a specific collection" + }, + "query": { + "type": "string", + "description": "Search query to filter templates by title" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "templatesList" + } + }, + "/templates.info": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Retrieve a template", + "description": "Retrieve a template by its unique identifier.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the template. Either the UUID or the urlId is acceptable." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "templatesInfo" + } + }, + "/templates.update": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Update a template", + "description": "Update an existing template by its unique identifier.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the template. Either the UUID or the urlId is acceptable." + }, + "title": { + "type": "string", + "description": "The title of the template." + }, + "data": { + "type": "object", + "description": "The body of the template as a Prosemirror document." + }, + "icon": { + "type": "string", + "description": "An emoji to use as the template icon.", + "nullable": true + }, + "color": { + "type": "string", + "description": "The color of the template icon in hex format.", + "nullable": true, + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "fullWidth": { + "type": "boolean", + "description": "Whether the template should be displayed full width." + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the collection to which the template belongs. Set to null for a workspace-wide template.", + "nullable": true + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "templatesUpdate" + } + }, + "/templates.delete": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Delete a template", + "description": "Delete a template by its unique identifier. This will soft-delete the template, it can be restored later.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the template. Either the UUID or the urlId is acceptable." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "templatesDelete" + } + }, + "/templates.restore": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Restore a template", + "description": "Restore a previously deleted template by its unique identifier.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the template. Either the UUID or the urlId is acceptable." + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "templatesRestore" + } + }, + "/templates.duplicate": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Duplicate a template", + "description": "Create a copy of an existing template. Optionally override the title and target collection.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the template to duplicate. Either the UUID or the urlId is acceptable." + }, + "title": { + "type": "string", + "description": "Override the title of the duplicated template." + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the collection to place the duplicated template in. If not provided, uses the original template's collection.", + "nullable": true + } + }, + "required": [ + "id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Template" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Policy" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Validation" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "operationId": "templatesDuplicate" + } + } + }, + "components": { + "schemas": { + "Permission": { + "type": "string", + "enum": [ + "read", + "read_write" + ] + }, + "TextEditMode": { + "type": "string", + "description": "The editing mode for text updates to a document. When set to `patch`, the `findText` parameter is required and the existing occurrence of `findText` will be replaced with the value of `text`.", + "enum": [ + "append", + "prepend", + "replace", + "patch" + ] + }, + "AccessRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "Identifier for the document this request is for.", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Identifier for the user that made the request.", + "format": "uuid" + }, + "user": { + "$ref": "#/components/schemas/User" + }, + "teamId": { + "type": "string", + "description": "Identifier for the workspace the request belongs to.", + "format": "uuid" + }, + "status": { + "type": "string", + "description": "The current status of the access request.", + "enum": [ + "pending", + "approved", + "dismissed" + ] + }, + "responderId": { + "type": "string", + "description": "Identifier for the user that responded to the request, if any.", + "format": "uuid", + "nullable": true + }, + "responder": { + "$ref": "#/components/schemas/User" + }, + "respondedAt": { + "type": "string", + "description": "The date and time the request was responded to, if any.", + "format": "date-time", + "nullable": true + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object was last changed", + "readOnly": true, + "format": "date-time" + } + } + }, + "Attachment": { + "type": "object", + "properties": { + "contentType": { + "type": "string", + "example": "image/png" + }, + "size": { + "type": "string", + "description": "The size of the attachment in bytes. Returned as a string as the value may exceed the safe integer range." + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "documentId": { + "type": "string", + "description": "Identifier for the associated document, if any.", + "format": "uuid", + "nullable": true + }, + "userId": { + "type": "string", + "description": "Identifier for the user that created the attachment.", + "format": "uuid" + } + } + }, + "Pagination": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "example": 0 + }, + "limit": { + "type": "number", + "example": 25 + } + } + }, + "Sorting": { + "type": "object", + "properties": { + "sort": { + "type": "string", + "example": "updatedAt" + }, + "direction": { + "type": "string", + "example": "DESC", + "enum": [ + "ASC", + "DESC" + ] + } + } + }, + "NavigationNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the document.", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NavigationNode" + } + } + } + }, + "Auth": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/User" + }, + "team": { + "$ref": "#/components/schemas/Team" + } + } + }, + "Collection": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "url": { + "type": "string", + "description": "The relative URL path at which the collection can be accessed.", + "readOnly": true + }, + "urlId": { + "type": "string", + "description": "A short unique identifier that can be used to identify the collection instead of the UUID.", + "readOnly": true, + "example": "hDYep1TPAM" + }, + "name": { + "type": "string", + "description": "The name of the collection.", + "example": "Human Resources" + }, + "description": { + "type": "string", + "nullable": true, + "description": "A description of the collection, may contain markdown formatting", + "example": "" + }, + "data": { + "type": "object", + "nullable": true, + "description": "The collection description as rich-text JSON, when available." + }, + "sort": { + "type": "object", + "description": "The sort of documents in the collection. Note that not all API responses respect this and it is left as a frontend concern to implement.", + "properties": { + "field": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + } + }, + "index": { + "type": "string", + "nullable": true, + "description": "The position of the collection in the sidebar", + "example": "P" + }, + "color": { + "type": "string", + "nullable": true, + "description": "A color representing the collection, this is used to help make collections more identifiable in the UI. It should be in HEX format including the #", + "example": "#123123" + }, + "icon": { + "type": "string", + "nullable": true, + "description": "A string that represents an icon in the outline-icons package or an emoji" + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "templateManagement": { + "$ref": "#/components/schemas/Permission" + }, + "sharing": { + "type": "boolean", + "description": "Whether public document sharing is enabled in this collection", + "default": false + }, + "commenting": { + "type": "boolean", + "nullable": true, + "description": "Whether commenting is enabled in this collection" + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object was last changed", + "readOnly": true, + "format": "date-time" + }, + "deletedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that this object was deleted", + "readOnly": true, + "format": "date-time" + }, + "archivedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that this object was archived", + "readOnly": true, + "format": "date-time" + }, + "archivedBy": { + "$ref": "#/components/schemas/User" + }, + "sourceMetadata": { + "type": "object", + "nullable": true, + "description": "Metadata about the external source this collection was imported from, if any.", + "properties": { + "externalId": { + "type": "string" + }, + "externalName": { + "type": "string" + }, + "createdByName": { + "type": "string" + } + } + } + } + }, + "Comment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "data": { + "type": "object", + "description": "The editor data representing this comment." + }, + "documentId": { + "type": "string", + "description": "Identifier for the document this is related to.", + "format": "uuid" + }, + "parentCommentId": { + "type": "string", + "description": "Identifier for the comment this is a child of, if any.", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + }, + "createdBy": { + "$ref": "#/components/schemas/User" + }, + "createdById": { + "type": "string", + "description": "Identifier for the user who created this comment.", + "format": "uuid", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object was last changed", + "readOnly": true, + "format": "date-time" + }, + "resolvedAt": { + "type": "string", + "description": "The date and time that this comment was resolved, if it has been.", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "resolvedBy": { + "allOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/User" + } + ] + }, + "resolvedById": { + "type": "string", + "description": "Identifier for the user who resolved this comment, if any.", + "format": "uuid", + "nullable": true, + "readOnly": true + }, + "reactions": { + "type": "array", + "description": "List of emoji reactions on this comment.", + "items": { + "type": "object" + }, + "readOnly": true + }, + "anchorText": { + "type": "string", + "description": "The document text that the comment is anchored to, only included if includeAnchorText=true.", + "readOnly": true + } + } + }, + "DataAttribute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "name": { + "type": "string", + "description": "The name of this data attribute.", + "example": "Status" + }, + "description": { + "type": "string", + "description": "A description of the data attribute.", + "example": "The current status of the document." + }, + "dataType": { + "$ref": "#/components/schemas/DataAttributeDataType" + }, + "options": { + "$ref": "#/components/schemas/DataAttributeOptions" + }, + "pinned": { + "type": "boolean", + "description": "Whether this data attribute is pinned to the top of documents.", + "default": false + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object was last changed", + "readOnly": true, + "format": "date-time" + }, + "deletedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that this object was deleted", + "readOnly": true, + "format": "date-time" + } + } + }, + "DataAttributeDataType": { + "type": "string", + "description": "The data type of the attribute value.", + "enum": [ + "string", + "number", + "boolean", + "list" + ] + }, + "DataAttributeOptions": { + "type": "object", + "description": "Additional options for certain data attribute types.", + "properties": { + "icon": { + "type": "string", + "description": "An icon representing the data attribute from the outline-icons package." + }, + "options": { + "type": "array", + "description": "Valid options for list data type.", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "The label/value of the option." + }, + "color": { + "type": "string", + "description": "Optional color for the option." + } + } + } + } + } + }, + "DocumentDataAttribute": { + "type": "object", + "properties": { + "dataAttributeId": { + "type": "string", + "description": "Unique identifier for the associated data attribute.", + "format": "uuid" + }, + "value": { + "description": "The value of the data attribute for this document.", + "example": "In Progress", + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + } + ] + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object attribute was last changed", + "readOnly": true, + "format": "date-time" + } + } + }, + "Document": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "collectionId": { + "type": "string", + "description": "Identifier for the associated collection.", + "format": "uuid", + "nullable": true + }, + "parentDocumentId": { + "type": "string", + "description": "Identifier for the document this is a child of, if any.", + "format": "uuid", + "nullable": true + }, + "title": { + "type": "string", + "description": "The title of the document.", + "example": "Welcome to Acme Inc" + }, + "fullWidth": { + "type": "boolean", + "description": "Whether this document should be displayed in a full-width view." + }, + "icon": { + "type": "string", + "nullable": true, + "description": "An emoji or icon associated with the document.", + "example": "🎉" + }, + "color": { + "type": "string", + "nullable": true, + "description": "The color of the document icon in hex format." + }, + "text": { + "type": "string", + "description": "The text content of the document, contains markdown formatting", + "example": "…" + }, + "data": { + "type": "object", + "nullable": true, + "description": "The body of the document as a Prosemirror document, returned in place of text when requested." + }, + "url": { + "type": "string", + "description": "A URL path to access the document.", + "readOnly": true + }, + "urlId": { + "type": "string", + "description": "A short unique ID that can be used to identify the document as an alternative to the UUID", + "example": "hDYep1TPAM" + }, + "collaboratorIds": { + "type": "array", + "description": "Identifiers of users who have edited the document.", + "items": { + "type": "string", + "format": "uuid" + } + }, + "tasks": { + "type": "object", + "description": "Task completion counts for the document.", + "properties": { + "completed": { + "type": "number" + }, + "total": { + "type": "number" + } + } + }, + "templateId": { + "type": "string", + "description": "Unique identifier for the template this document was created from, if any", + "format": "uuid" + }, + "revision": { + "type": "number", + "description": "A number that is auto incrementing with every revision of the document that is saved", + "readOnly": true + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + }, + "createdBy": { + "$ref": "#/components/schemas/User" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object was last changed", + "readOnly": true, + "format": "date-time" + }, + "updatedBy": { + "$ref": "#/components/schemas/User" + }, + "publishedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that this object was published", + "readOnly": true, + "format": "date-time" + }, + "dataAttributes": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/DocumentDataAttribute" + } + }, + "archivedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that this object was archived", + "readOnly": true, + "format": "date-time" + }, + "deletedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that this object was deleted", + "readOnly": true, + "format": "date-time" + } + } + }, + "DocumentInsight": { + "type": "object", + "description": "A rollup of activity counts for a document over a daily or weekly period.", + "properties": { + "date": { + "type": "string", + "format": "date", + "description": "The UTC day the rollup represents. For weekly rollups this is the first day (Monday) of the week." + }, + "period": { + "type": "string", + "description": "The length of time the rollup covers. Daily rollups are stored for recent activity, older rollups are aggregated into weekly buckets.", + "enum": [ + "day", + "week" + ] + }, + "viewCount": { + "type": "integer", + "description": "Total number of document views on this day." + }, + "viewerCount": { + "type": "integer", + "description": "Number of unique viewers on this day." + }, + "commentCount": { + "type": "integer", + "description": "Total comments made on this day." + }, + "reactionCount": { + "type": "integer", + "description": "Total reactions added on this day." + }, + "revisionCount": { + "type": "integer", + "description": "Number of document revisions on this day." + }, + "editorCount": { + "type": "integer", + "description": "Number of unique editors on this day." + } + } + }, + "Event": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "name": { + "type": "string", + "example": "documents.create", + "readOnly": true + }, + "modelId": { + "type": "string", + "description": "Identifier for the object this event is associated with when it is not one of document, collection, or user.", + "format": "uuid", + "readOnly": true + }, + "userId": { + "type": "string", + "description": "Identifier for the user associated with the event, if any.", + "format": "uuid", + "readOnly": true + }, + "actorId": { + "type": "string", + "description": "The user that performed the action.", + "format": "uuid", + "readOnly": true + }, + "actorIpAddress": { + "type": "string", + "description": "The ip address the action was performed from. This field is only returned when the `auditLog` boolean is true.", + "example": "60.169.88.100", + "readOnly": true + }, + "collectionId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the associated collection, if any", + "readOnly": true + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Identifier for the associated document, if any", + "readOnly": true + }, + "createdAt": { + "type": "string", + "description": "The date and time that this event was created", + "readOnly": true, + "format": "date-time" + }, + "data": { + "type": "object", + "example": { + "name": "Equipment list" + }, + "description": "Additional unstructured data associated with the event", + "readOnly": true + }, + "changes": { + "type": "object", + "nullable": true, + "description": "The set of changes made by this event. This field is only returned when the `auditLog` boolean is true.", + "readOnly": true + }, + "actor": { + "$ref": "#/components/schemas/User" + } + } + }, + "Error": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": false + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "number" + }, + "data": { + "type": "object" + } + } + }, + "FileOperation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "type": { + "type": "string", + "example": "export", + "description": "The type of file operation.", + "readOnly": true, + "enum": [ + "import", + "export" + ] + }, + "format": { + "type": "string", + "description": "The file format of the resulting file.", + "example": "outline-markdown", + "readOnly": true + }, + "name": { + "type": "string", + "description": "The name of the file operation, derived from the collection name, document title, or file name.", + "readOnly": true + }, + "state": { + "type": "string", + "description": "The state of the file operation.", + "example": "complete", + "readOnly": true, + "enum": [ + "creating", + "uploading", + "complete", + "error", + "expired" + ] + }, + "error": { + "type": "string", + "nullable": true, + "description": "An error message if the file operation failed.", + "readOnly": true + }, + "size": { + "type": "string", + "description": "The size of the resulting file in bytes. Returned as a string as the value may exceed the safe integer range.", + "readOnly": true, + "example": "2048" + }, + "collectionId": { + "type": "string", + "nullable": true, + "description": "Identifier for the associated collection, if the file operation is scoped to a single collection.", + "readOnly": true, + "format": "uuid" + }, + "documentId": { + "type": "string", + "nullable": true, + "description": "Identifier for the associated document, if the file operation is scoped to a single document.", + "readOnly": true, + "format": "uuid" + }, + "user": { + "$ref": "#/components/schemas/User" + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object was last changed", + "readOnly": true, + "format": "date-time" + } + } + }, + "Group": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "name": { + "type": "string", + "description": "The name of this group.", + "example": "Engineering" + }, + "description": { + "type": "string", + "nullable": true, + "description": "A short description of this group." + }, + "externalId": { + "type": "string", + "nullable": true, + "description": "An identifier for this group in an external system, if linked." + }, + "disableMentions": { + "type": "boolean", + "description": "Whether mentioning this group is disabled." + }, + "externalGroup": { + "type": "object", + "nullable": true, + "description": "Details of the linked external group, if any." + }, + "memberCount": { + "type": "number", + "description": "The number of users that are members of the group", + "example": 11, + "readOnly": true + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this object was last changed", + "readOnly": true, + "format": "date-time" + } + } + }, + "OAuthClient": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "name": { + "type": "string", + "description": "The name of this OAuth client.", + "example": "Acme Inc" + }, + "description": { + "type": "string", + "nullable": true, + "description": "A short description of this OAuth client.", + "example": "Integrate Acme Inc's services into Outline." + }, + "developerName": { + "type": "string", + "nullable": true, + "description": "The name of the developer who created this OAuth client.", + "example": "Acme Inc" + }, + "developerUrl": { + "type": "string", + "nullable": true, + "description": "The URL of the developer who created this OAuth client.", + "example": "https://example.com" + }, + "avatarUrl": { + "type": "string", + "nullable": true, + "description": "A URL pointing to an image representing the OAuth client." + }, + "clientId": { + "type": "string", + "description": "The client ID for the OAuth client.", + "readOnly": true, + "example": "2bquf8avrpdv31par42a" + }, + "clientSecret": { + "type": "string", + "description": "The client secret for the OAuth client.", + "readOnly": true, + "example": "ol_sk_rapdv31..." + }, + "clientType": { + "type": "string", + "description": "The type of the OAuth client.", + "readOnly": true, + "enum": [ + "public", + "confidential" + ] + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The redirect URIs for the OAuth client.", + "example": [ + "https://example.com/callback" + ] + }, + "published": { + "type": "boolean", + "description": "Whether the OAuth client is available to other workspaces.", + "example": true + }, + "lastActiveAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Date and time when this OAuth client was last used.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this OAuth client was created", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this OAuth client was updated", + "readOnly": true + } + } + }, + "OAuthAuthentication": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "oauthClientId": { + "type": "string", + "description": "Identifier for the associated OAuthClient.", + "readOnly": true, + "format": "uuid" + }, + "oauthClient": { + "type": "object", + "readOnly": true, + "description": "A reduced, public representation of the associated OAuth client.", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "developerName": { + "type": "string", + "nullable": true + }, + "developerUrl": { + "type": "string", + "nullable": true + }, + "avatarUrl": { + "type": "string", + "nullable": true + }, + "clientId": { + "type": "string" + }, + "published": { + "type": "boolean" + } + } + }, + "userId": { + "type": "string", + "description": "Identifier for the associated User.", + "readOnly": true, + "format": "uuid" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + } + }, + "lastActiveAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this authentication was last used", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this authentication was created", + "readOnly": true + } + } + }, + "Revision": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "Identifier for the associated document.", + "readOnly": true, + "format": "uuid" + }, + "title": { + "type": "string", + "description": "Title of the document.", + "readOnly": true + }, + "name": { + "type": "string", + "nullable": true, + "description": "The name of the revision, if any.", + "readOnly": true + }, + "icon": { + "type": "string", + "nullable": true, + "description": "An emoji or icon associated with the revision.", + "readOnly": true + }, + "color": { + "type": "string", + "nullable": true, + "description": "The color of the revision icon in hex format.", + "readOnly": true + }, + "collaborators": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this revision was created", + "readOnly": true + }, + "createdBy": { + "$ref": "#/components/schemas/User" + }, + "createdById": { + "type": "string", + "description": "Identifier for the user who created this revision.", + "format": "uuid", + "readOnly": true + }, + "deletedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Date and time when this revision was deleted, if applicable.", + "readOnly": true + } + } + }, + "RevisionDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Revision" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "The body of the revision as a Prosemirror document.", + "readOnly": true + }, + "text": { + "type": "string", + "description": "Body of the document, may contain markdown formatting", + "readOnly": true + } + } + } + ] + }, + "Share": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "documentTitle": { + "type": "string", + "description": "Title of the shared document.", + "example": "React best practices", + "readOnly": true + }, + "documentUrl": { + "type": "string", + "format": "uri", + "description": "URL of the original document.", + "readOnly": true + }, + "sourceTitle": { + "type": "string", + "description": "Title of the shared document or collection.", + "readOnly": true + }, + "sourcePath": { + "type": "string", + "description": "Path of the shared document or collection.", + "readOnly": true + }, + "documentId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier of the shared document, if any.", + "readOnly": true + }, + "collectionId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Identifier of the shared collection, if any.", + "readOnly": true + }, + "urlId": { + "type": "string", + "nullable": true, + "description": "Short URL identifier for the share, if set.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the publicly shared document.", + "readOnly": true + }, + "domain": { + "type": "string", + "nullable": true, + "description": "Custom domain the share is served on, if any." + }, + "title": { + "type": "string", + "maxLength": 255, + "nullable": true, + "description": "Override title displayed on the publicly shared page. If not set the source document or collection title is used." + }, + "iconUrl": { + "type": "string", + "format": "uri", + "maxLength": 4096, + "nullable": true, + "description": "URL of an icon displayed on the publicly shared page, overriding the workspace branding." + }, + "published": { + "type": "boolean", + "example": false, + "description": "If true the share can be loaded without a user account." + }, + "includeChildDocuments": { + "type": "boolean", + "example": true, + "description": "If to also give permission to view documents nested beneath this one." + }, + "allowSubscriptions": { + "type": "boolean", + "example": true, + "description": "Whether visitors to the public share can subscribe to receive email notifications when the document is updated. Requires SMTP to be configured on the workspace." + }, + "allowIndexing": { + "type": "boolean", + "description": "Whether the shared page may be indexed by search engines." + }, + "showLastUpdated": { + "type": "boolean", + "description": "Whether to show the last-updated time on the shared page." + }, + "showTOC": { + "type": "boolean", + "description": "Whether to show a table of contents on the shared page." + }, + "views": { + "type": "number", + "description": "The number of times the shared page has been viewed.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this share was created", + "readOnly": true + }, + "createdBy": { + "allOf": [ + { + "$ref": "#/components/schemas/User" + } + ], + "nullable": true, + "description": "The user that created the share. Only returned to viewers with access to read the share; omitted from responses to unauthenticated viewers of a published share, and when the creating user has been deleted.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this share was edited", + "readOnly": true + }, + "lastAccessedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Date and time when this share was last viewed. Only returned to workspace admins.", + "readOnly": true + } + } + }, + "Star": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "index": { + "type": "string", + "description": "Index of the star in the list of stars." + }, + "documentId": { + "type": "string", + "description": "Unique identifier for the starred document.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "collectionId": { + "type": "string", + "description": "Unique identifier for the starred collection.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this star was created", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when this star was last changed", + "readOnly": true + } + } + }, + "Team": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "name": { + "type": "string", + "description": "The name of this workspace, it is usually auto-generated when the first SSO connection is made but can be changed if necessary." + }, + "description": { + "type": "string", + "nullable": true, + "description": "A short description of this workspace." + }, + "avatarUrl": { + "type": "string", + "format": "uri", + "description": "The URL for the image associated with this workspace, it will be displayed in the workspace switcher and in the top left of the knowledge base along with the name." + }, + "sharing": { + "type": "boolean", + "description": "Whether this workspace has share links globally enabled. If this value is false then all sharing UI and APIs are disabled." + }, + "defaultCollectionId": { + "type": "string", + "description": "If set then the referenced collection is where users will be redirected to after signing in instead of the Home screen", + "format": "uuid" + }, + "defaultUserRole": { + "$ref": "#/components/schemas/UserRole" + }, + "memberCollectionCreate": { + "type": "boolean", + "description": "Whether members are allowed to create new collections. If false then only admins can create collections." + }, + "memberTeamCreate": { + "type": "boolean", + "description": "Whether members are allowed to create new groups. If false then only admins can create groups." + }, + "documentEmbeds": { + "type": "boolean", + "description": "Whether this workspace has embeds in documents globally enabled. It can be disabled to reduce potential data leakage to third parties." + }, + "inviteRequired": { + "type": "boolean", + "description": "Whether an invite is required to join this workspace, if false users may join with a linked SSO provider." + }, + "allowedDomains": { + "type": "array", + "items": { + "type": "string", + "description": "A hostname that user emails are restricted to" + } + }, + "guestSignin": { + "type": "boolean", + "description": "Whether this workspace has guest signin enabled. Guests can signin with an email address and are not required to have a Google Workspace/Slack SSO account once invited." + }, + "subdomain": { + "type": "string", + "description": "Represents the subdomain at which this workspace's knowledge base can be accessed." + }, + "domain": { + "type": "string", + "nullable": true, + "description": "The custom domain configured for this workspace, if any." + }, + "url": { + "type": "string", + "description": "The fully qualified URL at which this workspace's knowledge base can be accessed.", + "readOnly": true, + "format": "uri" + }, + "passkeysEnabled": { + "type": "boolean", + "description": "Whether passkey authentication is enabled for this workspace." + }, + "preferences": { + "type": "object", + "nullable": true, + "description": "Workspace-level preference flags." + }, + "guidanceMCP": { + "type": "string", + "nullable": true, + "description": "Guidance text provided to MCP integrations." + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "name": { + "type": "string", + "description": "The name of this user, it is migrated from Slack or Google Workspace when the SSO connection is made but can be changed if necessary.", + "example": "Jane Doe" + }, + "avatarUrl": { + "type": "string", + "format": "uri", + "description": "The URL for the image associated with this user, it will be displayed in the application UI and email notifications." + }, + "color": { + "type": "string", + "description": "A color representing the user, used in the UI for avatars without an image.", + "readOnly": true + }, + "email": { + "type": "string", + "description": "The email associated with this user, it is migrated from Slack or Google Workspace when the SSO connection is made but can be changed if necessary.", + "format": "email", + "readOnly": true + }, + "role": { + "$ref": "#/components/schemas/UserRole" + }, + "isSuspended": { + "type": "boolean", + "description": "Whether this user has been suspended.", + "readOnly": true + }, + "lastActiveAt": { + "type": "string", + "nullable": true, + "description": "The last time this user made an API request, this value is updated at most every 5 minutes.", + "readOnly": true, + "format": "date-time" + }, + "timezone": { + "type": "string", + "nullable": true, + "description": "The timezone this user has registered." + }, + "createdAt": { + "type": "string", + "description": "The date and time that this user first signed in or was invited as a guest.", + "readOnly": true, + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that this user was last updated.", + "readOnly": true, + "format": "date-time" + }, + "deletedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that this user was deleted, if applicable.", + "readOnly": true, + "format": "date-time" + } + } + }, + "Invite": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The full name of the user being invited" + }, + "email": { + "type": "string", + "description": "The email address to invite" + }, + "role": { + "$ref": "#/components/schemas/UserRole" + } + } + }, + "UserRole": { + "type": "string", + "enum": [ + "admin", + "member", + "viewer", + "guest" + ] + }, + "CollectionStatus": { + "type": "string", + "enum": [ + "archived" + ] + }, + "Membership": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true + }, + "userId": { + "type": "string", + "description": "Identifier for the associated user.", + "readOnly": true, + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "Identifier for the associated document, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "collectionId": { + "type": "string", + "description": "Identifier for the associated collection, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "createdById": { + "type": "string", + "description": "Identifier for the user who created this membership.", + "readOnly": true, + "format": "uuid" + }, + "sourceId": { + "type": "string", + "description": "Identifier for the membership this one was inherited from, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "index": { + "type": "string", + "description": "The position of the collection in the user's sidebar.", + "nullable": true + } + } + }, + "SearchResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "readOnly": true, + "format": "uuid" + }, + "query": { + "type": "string", + "description": "The user-provided search query", + "example": "What is our hiring policy?", + "readOnly": true + }, + "answer": { + "type": "string", + "description": "An answer to the query, if possible", + "example": "Our hiring policy can be summarized as…", + "readOnly": true + }, + "source": { + "type": "string", + "example": "app", + "description": "The source of the query", + "readOnly": true, + "enum": [ + "api", + "app", + "mcp" + ] + }, + "createdAt": { + "type": "string", + "description": "The date and time that this object was created", + "readOnly": true, + "format": "date-time" + } + } + }, + "Policy": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object this policy references.", + "format": "uuid", + "readOnly": true + }, + "abilities": { + "type": "object", + "description": "The abilities that are allowed by this policy, if an array is returned then the individual ID's in the array represent the memberships that grant the ability.", + "additionalProperties": { + "$ref": "#/components/schemas/Ability" + }, + "example": { + "read": true, + "update": true, + "delete": false + } + } + } + }, + "Ability": { + "description": "A single permission granted by a policy", + "example": true, + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ] + }, + "GroupMembership": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true + }, + "groupId": { + "type": "string", + "description": "Identifier for the associated group.", + "readOnly": true, + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "Identifier for the associated document, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "collectionId": { + "type": "string", + "description": "Identifier for the associated collection, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "sourceId": { + "type": "string", + "description": "Identifier for the membership this one was inherited from, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + } + } + }, + "CollectionGroupMembership": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true + }, + "groupId": { + "type": "string", + "description": "Identifier for the associated group.", + "readOnly": true, + "format": "uuid" + }, + "documentId": { + "type": "string", + "description": "Identifier for the associated document, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "collectionId": { + "type": "string", + "description": "Identifier for the associated collection, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + }, + "permission": { + "$ref": "#/components/schemas/Permission" + }, + "sourceId": { + "type": "string", + "description": "Identifier for the membership this one was inherited from, if any.", + "readOnly": true, + "format": "uuid", + "nullable": true + } + } + }, + "Template": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true, + "format": "uuid" + }, + "url": { + "type": "string", + "description": "A URL path to access the template.", + "readOnly": true + }, + "urlId": { + "type": "string", + "description": "A short unique identifier for the template used in URLs.", + "readOnly": true + }, + "title": { + "type": "string", + "description": "The title of the template." + }, + "data": { + "type": "object", + "description": "The body of the template as a Prosemirror document." + }, + "icon": { + "type": "string", + "description": "An emoji to use as the template icon.", + "nullable": true + }, + "color": { + "type": "string", + "description": "The color of the template icon in hex format.", + "nullable": true + }, + "fullWidth": { + "type": "boolean", + "description": "Whether the template should be displayed full width." + }, + "collectionId": { + "type": "string", + "description": "Identifier for the associated collection, if any.", + "format": "uuid", + "nullable": true + }, + "createdAt": { + "type": "string", + "description": "The date and time that the template was created.", + "readOnly": true, + "format": "date-time" + }, + "createdBy": { + "$ref": "#/components/schemas/User" + }, + "updatedAt": { + "type": "string", + "description": "The date and time that the template was last changed.", + "readOnly": true, + "format": "date-time" + }, + "updatedBy": { + "$ref": "#/components/schemas/User" + }, + "deletedAt": { + "type": "string", + "description": "The date and time that the template was deleted.", + "readOnly": true, + "format": "date-time", + "nullable": true + }, + "publishedAt": { + "type": "string", + "nullable": true, + "description": "The date and time that the template was published.", + "readOnly": true, + "format": "date-time" + } + } + }, + "View": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the object.", + "readOnly": true + }, + "documentId": { + "type": "string", + "description": "Identifier for the associated document.", + "readOnly": true, + "format": "uuid" + }, + "firstViewedAt": { + "type": "string", + "description": "When the document was first viewed by the user", + "readOnly": true, + "format": "date-time" + }, + "lastViewedAt": { + "type": "string", + "description": "When the document was last viewed by the user", + "readOnly": true, + "format": "date-time" + }, + "count": { + "type": "number", + "description": "The number of times the user has viewed the document.", + "example": 22, + "readOnly": true + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "Identifier of the user who viewed the document.", + "readOnly": true + }, + "user": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "NotFound": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Validation": { + "description": "The request failed one or more validations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "The current API key is not authorized to perform this action.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthenticated": { + "description": "The API key is missing or otherwise invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "RateLimited": { + "description": "The request was rate limited.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "RateLimit-Limit": { + "$ref": "#/components/headers/RateLimit-Limit" + }, + "RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimit-Remaining" + }, + "RateLimit-Reset": { + "$ref": "#/components/headers/RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": false + }, + "error": { + "type": "string", + "example": "rate_limit_exceeded" + }, + "status": { + "type": "number", + "example": 429 + } + } + } + } + } + } + }, + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds in the future to retry the request, if rate limited." + }, + "RateLimit-Limit": { + "schema": { + "type": "integer" + }, + "description": "The maximum requests available in the current duration." + }, + "RateLimit-Remaining": { + "schema": { + "type": "integer" + }, + "description": "How many requests are left in the current duration." + }, + "RateLimit-Reset": { + "schema": { + "type": "string" + }, + "description": "Timestamp in the future the duration will reset." + } + }, + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "OAuth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://app.getoutline.com/oauth/authorize", + "tokenUrl": "https://app.getoutline.com/oauth/token", + "refreshUrl": "https://app.getoutline.com/oauth/token", + "scopes": { + "read": "Read access", + "write": "Write access" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..c4ac83d --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,38 @@ +use serde_json::json; + +use crate::client::{Client, MethodDef}; +use crate::error::Result; +use crate::models::{AuthConfig, AuthInfo}; + +const INFO: MethodDef = MethodDef { + name: "auth.info", + idempotent: true, +}; +const CONFIG: MethodDef = MethodDef { + name: "auth.config", + idempotent: true, +}; + +/// Access to the `auth.*` endpoints. +#[derive(Debug)] +pub struct AuthApi<'a> { + client: &'a Client, +} + +impl<'a> AuthApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + AuthApi { client } + } + + /// Retrieves the user and workspace associated with the current credentials. + pub async fn info(&self) -> Result { + self.client.rpc(INFO, &json!({})).await + } + + /// Retrieves authentication configuration (available SSO services) for this instance. + /// + /// Unlike other endpoints, this one requires no credentials. + pub async fn config(&self) -> Result { + self.client.rpc(CONFIG, &json!({})).await + } +} diff --git a/src/api/collections.rs b/src/api/collections.rs new file mode 100644 index 0000000..93c1da3 --- /dev/null +++ b/src/api/collections.rs @@ -0,0 +1,137 @@ +use serde::Serialize; + +use crate::client::{Client, MethodDef}; +use crate::error::Result; +use crate::models::{Collection, CollectionStatus, Id, NavigationNode, SortDirection}; +use crate::page::{DEFAULT_PAGE_SIZE, Page, PageParams, Paginated, Paginator}; + +const LIST: MethodDef = MethodDef { + name: "collections.list", + idempotent: true, +}; +const INFO: MethodDef = MethodDef { + name: "collections.info", + idempotent: true, +}; +const DOCUMENTS: MethodDef = MethodDef { + name: "collections.documents", + idempotent: true, +}; + +/// Access to the `collections.*` endpoints. +#[derive(Debug)] +pub struct CollectionsApi<'a> { + client: &'a Client, +} + +impl<'a> CollectionsApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + CollectionsApi { client } + } + + /// Retrieves a single collection by id. + pub async fn info(&self, id: impl Into) -> Result { + #[derive(Serialize)] + struct Params { + id: Id, + } + self.client.rpc(INFO, &Params { id: id.into() }).await + } + + /// Retrieves the full document tree (nested navigation) for a collection. + /// + /// Unlike most list endpoints this is not paginated: the whole tree is + /// returned in one call. + pub async fn documents(&self, id: impl Into) -> Result> { + #[derive(Serialize)] + struct Params { + id: Id, + } + self.client.rpc(DOCUMENTS, &Params { id: id.into() }).await + } + + /// Starts building a `collections.list` request. + pub fn list(&self) -> ListCollections<'a> { + ListCollections::new(self.client) + } +} + +/// Request parameters for `collections.list`. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCollectionsParams { + /// Pagination parameters. + #[serde(flatten)] + pub page: PageParams, + /// Filters results by collection name. + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, + /// Restricts results to collections with the given statuses. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub status_filter: Vec, +} + +impl Paginated for ListCollectionsParams { + fn page_params_mut(&mut self) -> &mut PageParams { + &mut self.page + } +} + +/// A builder for `collections.list`, obtained from [`CollectionsApi::list`]. +#[derive(Debug)] +pub struct ListCollections<'a> { + client: &'a Client, + params: ListCollectionsParams, +} + +impl<'a> ListCollections<'a> { + fn new(client: &'a Client) -> Self { + ListCollections { + client, + params: ListCollectionsParams::default(), + } + } + + /// Filters results by collection name. + pub fn query(mut self, query: impl Into) -> Self { + self.params.query = Some(query.into()); + self + } + + /// Restricts results to collections with the given statuses. + pub fn status_filter(mut self, statuses: impl IntoIterator) -> Self { + self.params.status_filter = statuses.into_iter().collect(); + self + } + + /// Sorts results by the given field (e.g. `"updatedAt"`) and direction. + pub fn sort(mut self, field: impl Into, 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> { + 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, ListCollectionsParams, Collection> { + let page_size = self.params.page.limit.unwrap_or(DEFAULT_PAGE_SIZE); + Paginator::new(self.client, LIST, self.params, page_size) + } +} diff --git a/src/api/documents.rs b/src/api/documents.rs new file mode 100644 index 0000000..4654fb0 --- /dev/null +++ b/src/api/documents.rs @@ -0,0 +1,534 @@ +use serde::Serialize; + +use crate::client::{Client, MethodDef}; +use crate::error::Result; +use crate::models::{Document, DocumentStatus, Id, SearchResult, SortDirection, TextEditMode}; +use crate::page::{DEFAULT_PAGE_SIZE, PageParams, Paginated, Paginator}; + +const INFO: MethodDef = MethodDef { + name: "documents.info", + idempotent: true, +}; +const LIST: MethodDef = MethodDef { + name: "documents.list", + idempotent: true, +}; +const SEARCH: MethodDef = MethodDef { + name: "documents.search", + idempotent: true, +}; +const CREATE: MethodDef = MethodDef { + name: "documents.create", + idempotent: false, +}; +const UPDATE: MethodDef = MethodDef { + name: "documents.update", + idempotent: false, +}; +const DELETE: MethodDef = MethodDef { + name: "documents.delete", + idempotent: false, +}; + +/// A way to look up a single document: by id, by its short `urlId`, or by a share id. +/// +/// Outline's `documents.info` accepts a UUID or `urlId` interchangeably in +/// its `id` field, so both are represented by [`DocumentRef::Id`]. +#[derive(Debug, Clone)] +pub enum DocumentRef { + /// Look up by UUID or `urlId`. + Id(Id), + /// Look up the document associated with a share link. + ShareId(Id), +} + +impl DocumentRef { + /// Looks up the document associated with a share link. + pub fn share_id(id: impl Into) -> Self { + DocumentRef::ShareId(id.into()) + } +} + +impl From for DocumentRef { + fn from(id: Id) -> Self { + DocumentRef::Id(id) + } +} + +impl From<&Id> for DocumentRef { + fn from(id: &Id) -> Self { + DocumentRef::Id(id.clone()) + } +} + +impl From<&str> for DocumentRef { + fn from(id: &str) -> Self { + DocumentRef::Id(Id::from(id)) + } +} + +impl From for DocumentRef { + fn from(id: String) -> Self { + DocumentRef::Id(Id::from(id)) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct InfoParams { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + share_id: Option, +} + +impl From for InfoParams { + fn from(reference: DocumentRef) -> Self { + match reference { + DocumentRef::Id(id) => InfoParams { + id: Some(id), + share_id: None, + }, + DocumentRef::ShareId(id) => InfoParams { + id: None, + share_id: Some(id), + }, + } + } +} + +/// Access to the `documents.*` endpoints. +#[derive(Debug)] +pub struct DocumentsApi<'a> { + client: &'a Client, +} + +impl<'a> DocumentsApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + DocumentsApi { client } + } + + /// Retrieves a single document by id, `urlId`, or share id. + pub async fn info(&self, reference: impl Into) -> Result { + let params: InfoParams = reference.into().into(); + self.client.rpc(INFO, ¶ms).await + } + + /// Permanently or soft-deletes a document. + /// + /// By default the document is moved to the trash; pass `permanent: true` + /// to destroy it immediately with no way to recover it. + pub async fn delete(&self, id: impl Into, permanent: bool) -> Result<()> { + #[derive(Serialize)] + struct Params { + id: Id, + permanent: bool, + } + self.client + .rpc_ok( + DELETE, + &Params { + id: id.into(), + permanent, + }, + ) + .await + } + + /// Starts building a `documents.list` request. + pub fn list(&self) -> ListDocuments<'a> { + ListDocuments::new(self.client) + } + + /// Starts building a `documents.search` request for the given full-text query. + pub fn search(&self, query: impl Into) -> SearchDocuments<'a> { + SearchDocuments::new(self.client, query.into()) + } + + /// Starts building a `documents.create` request for a new document titled `title`. + pub fn create(&self, title: impl Into) -> CreateDocument<'a> { + CreateDocument::new(self.client, title.into()) + } + + /// Starts building a `documents.update` request for the document identified by `id`. + pub fn update(&self, id: impl Into) -> UpdateDocument<'a> { + UpdateDocument::new(self.client, id.into()) + } +} + +/// Request parameters for `documents.list`. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListDocumentsParams { + /// Pagination parameters. + #[serde(flatten)] + pub page: PageParams, + /// Restricts results to a specific collection. + #[serde(skip_serializing_if = "Option::is_none")] + pub collection_id: Option, + /// Restricts results to direct children of a specific document. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_document_id: Option, + /// Restricts results to documents created by a specific user. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + /// Restricts results to documents with the given publication statuses. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub status_filter: Vec, +} + +impl Paginated for ListDocumentsParams { + fn page_params_mut(&mut self) -> &mut PageParams { + &mut self.page + } +} + +/// A builder for `documents.list`, obtained from [`DocumentsApi::list`]. +#[derive(Debug)] +pub struct ListDocuments<'a> { + client: &'a Client, + params: ListDocumentsParams, +} + +impl<'a> ListDocuments<'a> { + fn new(client: &'a Client) -> Self { + ListDocuments { + client, + params: ListDocumentsParams::default(), + } + } + + /// Restricts results to documents in the given collection. + pub fn collection_id(mut self, id: impl Into) -> Self { + self.params.collection_id = Some(id.into()); + self + } + + /// Restricts results to direct children of the given document. + pub fn parent_document_id(mut self, id: impl Into) -> Self { + self.params.parent_document_id = Some(id.into()); + self + } + + /// Restricts results to documents created by the given user. + pub fn user_id(mut self, id: impl Into) -> Self { + self.params.user_id = Some(id.into()); + self + } + + /// Restricts results to documents with the given publication statuses. + pub fn status_filter(mut self, statuses: impl IntoIterator) -> Self { + self.params.status_filter = statuses.into_iter().collect(); + self + } + + /// Sorts results by the given field (e.g. `"updatedAt"`) and direction. + pub fn sort(mut self, field: impl Into, 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 + } + + /// Sets the full request parameters directly, overriding any prior builder calls. + pub fn params(mut self, params: ListDocumentsParams) -> Self { + self.params = params; + self + } + + /// Fetches a single page of results. + pub async fn send(self) -> Result> { + 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, ListDocumentsParams, Document> { + let page_size = self.params.page.limit.unwrap_or(DEFAULT_PAGE_SIZE); + Paginator::new(self.client, LIST, self.params, page_size) + } +} + +/// Request parameters for `documents.search`. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchDocumentsParams { + /// Pagination parameters. + #[serde(flatten)] + pub page: PageParams, + /// The full-text search query. + pub query: String, + /// Restricts results to a specific collection. + #[serde(skip_serializing_if = "Option::is_none")] + pub collection_id: Option, + /// Restricts results to within a specific document. + #[serde(skip_serializing_if = "Option::is_none")] + pub document_id: Option, + /// Restricts results to documents edited by a specific user. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + /// Restricts results to documents with the given publication statuses. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub status_filter: Vec, +} + +impl Paginated for SearchDocumentsParams { + fn page_params_mut(&mut self) -> &mut PageParams { + &mut self.page + } +} + +/// A builder for `documents.search`, obtained from [`DocumentsApi::search`]. +#[derive(Debug)] +pub struct SearchDocuments<'a> { + client: &'a Client, + params: SearchDocumentsParams, +} + +impl<'a> SearchDocuments<'a> { + fn new(client: &'a Client, query: String) -> Self { + SearchDocuments { + client, + params: SearchDocumentsParams { + query, + ..Default::default() + }, + } + } + + /// Restricts results to documents in the given collection. + pub fn collection_id(mut self, id: impl Into) -> Self { + self.params.collection_id = Some(id.into()); + self + } + + /// Restricts results to within the given document. + pub fn document_id(mut self, id: impl Into) -> Self { + self.params.document_id = Some(id.into()); + self + } + + /// Restricts results to documents edited by the given user. + pub fn user_id(mut self, id: impl Into) -> Self { + self.params.user_id = Some(id.into()); + self + } + + /// Restricts results to documents with the given publication statuses. + pub fn status_filter(mut self, statuses: impl IntoIterator) -> Self { + self.params.status_filter = statuses.into_iter().collect(); + 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> { + self.client.rpc_list(SEARCH, &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, SearchDocumentsParams, SearchResult> { + let page_size = self.params.page.limit.unwrap_or(DEFAULT_PAGE_SIZE); + Paginator::new(self.client, SEARCH, self.params, page_size) + } +} + +/// Request parameters for `documents.create`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateDocumentParams { + /// The title of the new document. + pub title: String, + /// The markdown body of the new document. + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + /// The collection to publish the document into. + #[serde(skip_serializing_if = "Option::is_none")] + pub collection_id: Option, + /// The parent document to nest the new document under. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_document_id: Option, + /// Whether to immediately publish the document. + #[serde(skip_serializing_if = "Option::is_none")] + pub publish: Option, + /// A caller-chosen id for the new document, making the call idempotent. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +/// A builder for `documents.create`, obtained from [`DocumentsApi::create`]. +#[derive(Debug)] +pub struct CreateDocument<'a> { + client: &'a Client, + params: CreateDocumentParams, +} + +impl<'a> CreateDocument<'a> { + fn new(client: &'a Client, title: String) -> Self { + CreateDocument { + client, + params: CreateDocumentParams { + title, + text: None, + collection_id: None, + parent_document_id: None, + publish: None, + id: None, + }, + } + } + + /// Sets the markdown body of the document. + pub fn text(mut self, text: impl Into) -> Self { + self.params.text = Some(text.into()); + self + } + + /// Sets the collection to publish this document into. + pub fn collection_id(mut self, id: impl Into) -> Self { + self.params.collection_id = Some(id.into()); + self + } + + /// Sets the parent document this should be nested under. + pub fn parent_document_id(mut self, id: impl Into) -> Self { + self.params.parent_document_id = Some(id.into()); + self + } + + /// Whether to immediately publish the document (default: left as a draft). + pub fn publish(mut self, publish: bool) -> Self { + self.params.publish = Some(publish); + self + } + + /// Sets a caller-chosen id for the new document. + /// + /// Supplying a fixed id makes the create call idempotent: retrying with + /// the same id will not create a duplicate document. + pub fn id(mut self, id: impl Into) -> Self { + self.params.id = Some(id.into()); + self + } + + /// Sends the create request. + pub async fn send(self) -> Result { + self.client.rpc(CREATE, &self.params).await + } +} + +/// Request parameters for `documents.update`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDocumentParams { + /// The document to update, by UUID or `urlId`. + pub id: Id, + /// The new title, if changing it. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// The markdown text to apply, according to `edit_mode`. + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + /// How `text` should be applied to the existing content. + #[serde(skip_serializing_if = "Option::is_none")] + pub edit_mode: Option, + /// The text to find and replace, required when `edit_mode` is `patch`. + #[serde(skip_serializing_if = "Option::is_none")] + pub find_text: Option, + /// A new collection to move the document to. + #[serde(skip_serializing_if = "Option::is_none")] + pub collection_id: Option, + /// Whether to publish the document, if it was a draft. + #[serde(skip_serializing_if = "Option::is_none")] + pub publish: Option, +} + +/// A builder for `documents.update`, obtained from [`DocumentsApi::update`]. +#[derive(Debug)] +pub struct UpdateDocument<'a> { + client: &'a Client, + params: UpdateDocumentParams, +} + +impl<'a> UpdateDocument<'a> { + fn new(client: &'a Client, id: Id) -> Self { + UpdateDocument { + client, + params: UpdateDocumentParams { + id, + title: None, + text: None, + edit_mode: None, + find_text: None, + collection_id: None, + publish: None, + }, + } + } + + /// Sets a new title. + pub fn title(mut self, title: impl Into) -> Self { + self.params.title = Some(title.into()); + self + } + + /// Sets the markdown text to apply, according to `edit_mode` (default: replace). + pub fn text(mut self, text: impl Into) -> Self { + self.params.text = Some(text.into()); + self + } + + /// Controls how `text` is applied to the existing content. + /// + /// When set to [`TextEditMode::Patch`], [`Self::find_text`] must also be set. + pub fn edit_mode(mut self, mode: TextEditMode) -> Self { + self.params.edit_mode = Some(mode); + self + } + + /// The text to find and replace when `edit_mode` is [`TextEditMode::Patch`]. + pub fn find_text(mut self, text: impl Into) -> Self { + self.params.find_text = Some(text.into()); + self + } + + /// Moves the document to a different collection. + pub fn collection_id(mut self, id: impl Into) -> Self { + self.params.collection_id = Some(id.into()); + self + } + + /// Whether to publish the document, if it was a draft. + pub fn publish(mut self, publish: bool) -> Self { + self.params.publish = Some(publish); + self + } + + /// Sends the update request. + pub async fn send(self) -> Result { + self.client.rpc(UPDATE, &self.params).await + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..b1ca1fa --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,15 @@ +//! Resource-scoped access to the Outline API, e.g. `client.documents().list()`. + +mod auth; +mod collections; +mod documents; +mod users; + +pub use auth::AuthApi; +pub use collections::{CollectionsApi, ListCollections, ListCollectionsParams}; +pub use documents::{ + CreateDocument, CreateDocumentParams, DocumentRef, DocumentsApi, ListDocuments, + ListDocumentsParams, SearchDocuments, SearchDocumentsParams, UpdateDocument, + UpdateDocumentParams, +}; +pub use users::{ListUsers, ListUsersParams, UsersApi}; diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..188ce70 --- /dev/null +++ b/src/api/users.rs @@ -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) -> Result { + #[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, + /// Restricts results to the given email addresses. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub emails: Vec, + /// Restricts results to users with the given role. + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +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) -> Self { + self.params.query = Some(query.into()); + self + } + + /// Restricts results to the given email addresses. + pub fn emails(mut self, emails: impl IntoIterator) -> 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, 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> { + 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) + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6ff0d0d --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,44 @@ +use std::fmt; + +use secrecy::{ExposeSecret, SecretString}; + +/// Credentials used to authenticate requests to the Outline API. +/// +/// Both an API key (created under Settings → API & Apps) and an OAuth 2.0 +/// access token are sent the same way (`Authorization: Bearer `), so +/// this type simply distinguishes them for documentation purposes. +#[derive(Clone)] +pub enum Auth { + /// A personal or workspace API key, in the form `ol_api_...`. + ApiKey(SecretString), + /// An OAuth 2.0 access token obtained via the authorization code flow. + AccessToken(SecretString), +} + +impl Auth { + /// Creates credentials from an API key. + pub fn api_key(key: impl Into) -> Self { + Auth::ApiKey(SecretString::from(key.into())) + } + + /// Creates credentials from an OAuth 2.0 access token. + pub fn access_token(token: impl Into) -> Self { + Auth::AccessToken(SecretString::from(token.into())) + } + + pub(crate) fn bearer_header_value(&self) -> String { + let secret = match self { + Auth::ApiKey(secret) | Auth::AccessToken(secret) => secret, + }; + format!("Bearer {}", secret.expose_secret()) + } +} + +impl fmt::Debug for Auth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Auth::ApiKey(_) => f.write_str("Auth::ApiKey(***)"), + Auth::AccessToken(_) => f.write_str("Auth::AccessToken(***)"), + } + } +} diff --git a/src/builder.rs b/src/builder.rs new file mode 100644 index 0000000..a8cde4f --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,228 @@ +use std::time::Duration; + +use url::Url; + +use crate::auth::Auth; +use crate::client::Client; +use crate::error::{Error, Result}; + +const DEFAULT_BASE_URL: &str = "https://app.getoutline.com"; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Builds a configured [`Client`]. +/// +/// ```no_run +/// # fn main() -> outline::Result<()> { +/// let client = outline::Client::builder() +/// .api_key("ol_api_...") +/// .build()?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct ClientBuilder { + base_url: String, + auth: Option, + timeout: Duration, + connect_timeout: Option, + user_agent: String, + http_client: Option, +} + +impl Default for ClientBuilder { + fn default() -> Self { + ClientBuilder { + base_url: DEFAULT_BASE_URL.to_string(), + auth: None, + timeout: DEFAULT_TIMEOUT, + connect_timeout: None, + user_agent: concat!("outline-rs/", env!("CARGO_PKG_VERSION")).to_string(), + http_client: None, + } + } +} + +impl ClientBuilder { + /// Creates a new builder with defaults (Outline Cloud, 30s timeout, no credentials). + pub fn new() -> Self { + Self::default() + } + + /// Sets the base URL of the Outline instance to talk to. + /// + /// Accepts the instance root (`https://app.getoutline.com`), with or + /// without a trailing slash, or an already-suffixed `/api` path — all + /// forms are normalized to the same request base. Defaults to Outline + /// Cloud. Non-`https` URLs are only accepted for `localhost`/`127.0.0.1` + /// (useful for pointing at a local mock server in tests). + pub fn base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Authenticates using an Outline API key (`ol_api_...`). + pub fn api_key(mut self, key: impl Into) -> Self { + self.auth = Some(Auth::api_key(key)); + self + } + + /// Authenticates using an OAuth 2.0 access token. + pub fn access_token(mut self, token: impl Into) -> Self { + self.auth = Some(Auth::access_token(token)); + self + } + + /// Sets the credentials directly. + pub fn auth(mut self, auth: Auth) -> Self { + self.auth = Some(auth); + self + } + + /// Sets the per-request timeout (default: 30 seconds). + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Sets the connection timeout. + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = Some(timeout); + self + } + + /// Overrides the `User-Agent` header sent with every request. + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + /// Supplies a preconfigured [`reqwest::Client`] instead of building one + /// from `timeout`/`connect_timeout`/`user_agent`. + /// + /// Useful for injecting a client with custom proxy or TLS settings. + pub fn http_client(mut self, http_client: reqwest::Client) -> Self { + self.http_client = Some(http_client); + self + } + + /// Builds the [`Client`], validating the base URL and credentials. + pub fn build(self) -> Result { + let base = normalize_base_url(&self.base_url)?; + let auth = self.auth.ok_or_else(|| { + Error::Config( + "missing credentials: call `.api_key(...)` or `.access_token(...)`".into(), + ) + })?; + + let http = match self.http_client { + Some(http) => http, + None => { + let mut http_builder = reqwest::Client::builder() + .timeout(self.timeout) + .user_agent(self.user_agent) + // The API only redirects on `attachments.redirect` / + // `fileOperations.redirect`, where the `Location` header + // itself is the useful result — never the redirect target. + .redirect(reqwest::redirect::Policy::none()); + if let Some(connect_timeout) = self.connect_timeout { + http_builder = http_builder.connect_timeout(connect_timeout); + } + http_builder.build().map_err(|source| { + Error::Config(format!("failed to build HTTP client: {source}")) + })? + } + }; + + Ok(Client::from_parts(http, base, auth)) + } +} + +/// Normalizes a user-supplied base URL to an absolute request base ending in +/// `/api/`, e.g. `https://app.getoutline.com` -> `https://app.getoutline.com/api/`. +/// +/// The trailing slash matters: [`Url::join`] replaces the last path segment +/// of a base URL that doesn't end in `/`, which would otherwise silently +/// drop `/api` from every request. +fn normalize_base_url(input: &str) -> Result { + let mut url = Url::parse(input) + .map_err(|source| Error::Config(format!("invalid base url `{input}`: {source}")))?; + + let is_local = matches!( + url.host_str(), + Some("localhost") | Some("127.0.0.1") | Some("::1") + ); + if url.scheme() != "https" && !is_local { + return Err(Error::Config(format!( + "base url `{input}` must use https (non-https is only allowed for localhost)" + ))); + } + + let trimmed = url.path().trim_end_matches('/'); + let with_api = if trimmed.ends_with("/api") || trimmed == "api" { + trimmed.to_string() + } else { + format!("{trimmed}/api") + }; + url.set_path(&format!("{with_api}/")); + url.set_query(None); + url.set_fragment(None); + + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_bare_host() { + let url = normalize_base_url("https://app.getoutline.com").unwrap(); + assert_eq!(url.as_str(), "https://app.getoutline.com/api/"); + } + + #[test] + fn normalizes_trailing_slash() { + let url = normalize_base_url("https://wiki.example.com/").unwrap(); + assert_eq!(url.as_str(), "https://wiki.example.com/api/"); + } + + #[test] + fn normalizes_existing_api_suffix() { + let url = normalize_base_url("https://wiki.example.com/api").unwrap(); + assert_eq!(url.as_str(), "https://wiki.example.com/api/"); + } + + #[test] + fn normalizes_existing_api_suffix_with_trailing_slash() { + let url = normalize_base_url("https://wiki.example.com/api/").unwrap(); + assert_eq!(url.as_str(), "https://wiki.example.com/api/"); + } + + #[test] + fn allows_plain_http_for_localhost() { + let url = normalize_base_url("http://127.0.0.1:38211").unwrap(); + assert_eq!(url.as_str(), "http://127.0.0.1:38211/api/"); + } + + #[test] + fn rejects_plain_http_for_remote_hosts() { + let err = normalize_base_url("http://wiki.example.com").unwrap_err(); + assert!(matches!(err, Error::Config(_))); + } + + #[test] + fn join_produces_expected_method_url() { + let base = normalize_base_url("https://app.getoutline.com").unwrap(); + let joined = base.join("documents.list").unwrap(); + assert_eq!( + joined.as_str(), + "https://app.getoutline.com/api/documents.list" + ); + } + + #[test] + fn build_requires_credentials() { + let err = ClientBuilder::new().build().unwrap_err(); + assert!(matches!(err, Error::Config(_))); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..64cfa65 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,201 @@ +use std::sync::Arc; + +use serde::Serialize; +use serde::de::DeserializeOwned; +use url::Url; + +use crate::api::{AuthApi, CollectionsApi, DocumentsApi, UsersApi}; +use crate::auth::Auth; +use crate::builder::ClientBuilder; +use crate::envelope::Envelope; +use crate::error::{ApiError, Error, Result}; +use crate::page::Page; +use crate::rate_limit::RateLimit; + +/// An RPC method on the Outline API, e.g. `documents.list`. +#[derive(Debug, Clone, Copy)] +pub(crate) struct MethodDef { + /// The method name, used as the request path (`POST /api/`). + pub name: &'static str, + /// Whether this call is safe to retry automatically. + /// + /// Currently informational only — used once automatic retries land. + #[allow(dead_code)] + pub idempotent: bool, +} + +/// A client for the Outline API. +/// +/// Cheaply cloneable: internally reference-counted, so cloning shares the +/// same connection pool and configuration. All methods take `&self`, making +/// `Client` safe to share across threads and hold in application state +/// (CLI or GUI) without wrapping it in a mutex. +#[derive(Clone)] +pub struct Client { + inner: Arc, +} + +pub(crate) struct Inner { + pub(crate) http: reqwest::Client, + pub(crate) base: Url, + pub(crate) auth: Auth, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client") + .field("base_url", &self.inner.base.as_str()) + .finish_non_exhaustive() + } +} + +impl Client { + /// Creates a client for the Outline Cloud API (`https://app.getoutline.com`) + /// authenticated with the given API key. + /// + /// For self-hosted instances or OAuth access tokens, use [`Client::builder`]. + pub fn new(api_key: impl Into) -> Result { + ClientBuilder::new().api_key(api_key).build() + } + + /// Creates a client from the `OUTLINE_API_KEY` (required) and `OUTLINE_URL` + /// (optional, defaults to Outline Cloud) environment variables. + pub fn from_env() -> Result { + let api_key = std::env::var("OUTLINE_API_KEY") + .map_err(|_| Error::Config("OUTLINE_API_KEY environment variable is not set".into()))?; + let mut builder = ClientBuilder::new().api_key(api_key); + if let Ok(url) = std::env::var("OUTLINE_URL") { + builder = builder.base_url(url); + } + builder.build() + } + + /// Starts building a client with custom configuration (base URL, timeouts, ...). + pub fn builder() -> ClientBuilder { + ClientBuilder::new() + } + + /// The normalized base URL requests are sent to, e.g. `https://app.getoutline.com/api/`. + pub fn base_url(&self) -> &Url { + &self.inner.base + } + + /// Access to the `auth.*` endpoints. + pub fn auth(&self) -> AuthApi<'_> { + AuthApi::new(self) + } + + /// Access to the `documents.*` endpoints. + pub fn documents(&self) -> DocumentsApi<'_> { + DocumentsApi::new(self) + } + + /// Access to the `collections.*` endpoints. + pub fn collections(&self) -> CollectionsApi<'_> { + CollectionsApi::new(self) + } + + /// Access to the `users.*` endpoints. + pub fn users(&self) -> UsersApi<'_> { + UsersApi::new(self) + } + + pub(crate) fn from_parts(http: reqwest::Client, base: Url, auth: Auth) -> Self { + Client { + inner: Arc::new(Inner { http, base, auth }), + } + } + + /// Calls an RPC method that returns a single object, and returns its `data` field. + pub(crate) async fn rpc(&self, method: MethodDef, params: &P) -> Result + where + P: Serialize + ?Sized, + R: DeserializeOwned, + { + let body = self.send(method, params).await?; + serde_json::from_slice::>(&body) + .map(|envelope| envelope.data) + .map_err(|source| Error::decode(method.name, source, &body)) + } + + /// Calls an RPC list method, returning a [`Page`] with items, pagination and policies. + pub(crate) async fn rpc_list(&self, method: MethodDef, params: &P) -> Result> + where + P: Serialize + ?Sized, + R: DeserializeOwned, + { + let body = self.send(method, params).await?; + let envelope = serde_json::from_slice::>>(&body) + .map_err(|source| Error::decode(method.name, source, &body))?; + Ok(Page { + items: envelope.data, + pagination: envelope.pagination.unwrap_or_default(), + policies: envelope.policies, + }) + } + + /// Calls an RPC method for its side effect only, ignoring the shape of its response body. + /// + /// Used for endpoints such as `documents.delete` whose success payload + /// isn't a `{data: ...}` envelope. + pub(crate) async fn rpc_ok

(&self, method: MethodDef, params: &P) -> Result<()> + where + P: Serialize + ?Sized, + { + self.send(method, params).await?; + Ok(()) + } + + /// Sends the request and returns the raw response body, translating a + /// non-2xx status into [`Error::Api`]. + async fn send

(&self, method: MethodDef, params: &P) -> Result + where + P: Serialize + ?Sized, + { + let url = self.inner.base.join(method.name).map_err(|source| { + Error::Config(format!("invalid method name `{}`: {source}", method.name)) + })?; + + let response = self + .inner + .http + .post(url) + .header( + reqwest::header::AUTHORIZATION, + self.inner.auth.bearer_header_value(), + ) + .json(params) + .send() + .await + .map_err(|source| Error::Transport { + method: method.name, + source, + })?; + + let status = response.status(); + let rate_limit = RateLimit::from_headers(response.headers()); + let body = response.bytes().await.map_err(|source| Error::Transport { + method: method.name, + source, + })?; + + if status.is_success() { + Ok(body) + } else { + let api = serde_json::from_slice::(&body).unwrap_or_else(|_| ApiError { + error: status + .canonical_reason() + .unwrap_or("unknown_error") + .to_string(), + message: Some(String::from_utf8_lossy(&body).into_owned()), + data: None, + }); + Err(Error::Api(Box::new(crate::error::ApiErrorContext { + method: method.name, + status, + api, + rate_limit, + }))) + } + } +} diff --git a/src/envelope.rs b/src/envelope.rs new file mode 100644 index 0000000..148680a --- /dev/null +++ b/src/envelope.rs @@ -0,0 +1,18 @@ +use serde::Deserialize; + +use crate::models::Policy; +use crate::page::Pagination; + +/// The success envelope returned by the Outline API (`ok: true`). +/// +/// `ok` and `status` are intentionally not modeled here: the HTTP status +/// code and success/failure are already known from the transport response +/// by the time this is deserialized. +#[derive(Debug, Deserialize)] +pub(crate) struct Envelope { + pub data: T, + #[serde(default)] + pub pagination: Option, + #[serde(default)] + pub policies: Vec, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..fad2939 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,183 @@ +use std::time::Duration; + +/// Result type used throughout this crate. +pub type Result = std::result::Result; + +/// The error type returned by all fallible operations in this crate. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The client was misconfigured (e.g. an invalid base URL). + #[error("invalid client configuration: {0}")] + Config(String), + + /// The HTTP request itself failed (connection, TLS, timeout, ...). + #[error("HTTP transport error calling `{method}`: {source}")] + Transport { + /// The Outline RPC method that was being called, e.g. `documents.info`. + method: &'static str, + /// The underlying transport error. + #[source] + source: reqwest::Error, + }, + + /// The response body could not be decoded as the expected shape. + #[error("failed to decode response of `{method}`: {source}")] + Decode { + /// The Outline RPC method that was being called. + method: &'static str, + /// The underlying JSON deserialization error. + #[source] + source: serde_json::Error, + /// The raw response body (truncated), for debugging. + body: String, + }, + + /// The Outline API returned an error envelope (`ok: false`). + #[error("{0}")] + Api(Box), +} + +/// The details behind [`Error::Api`], boxed to keep [`Error`] itself small. +#[derive(Debug)] +#[non_exhaustive] +pub struct ApiErrorContext { + /// The Outline RPC method that was being called. + pub method: &'static str, + /// The HTTP status code of the response. + pub status: http::StatusCode, + /// The parsed error envelope. + pub api: ApiError, + /// Rate limit information, if present on the response. + pub rate_limit: Option, +} + +impl std::fmt::Display for ApiErrorContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Outline API error {} on `{}`: {}", + self.status, self.method, self.api + ) + } +} + +/// The maximum number of bytes of a response body kept for [`Error::Decode`]. +const MAX_DEBUG_BODY_LEN: usize = 4096; + +impl Error { + pub(crate) fn decode(method: &'static str, source: serde_json::Error, body: &[u8]) -> Self { + let mut body = String::from_utf8_lossy(body).into_owned(); + if body.len() > MAX_DEBUG_BODY_LEN { + body.truncate(MAX_DEBUG_BODY_LEN); + body.push_str("... (truncated)"); + } + Error::Decode { + method, + source, + body, + } + } + + /// A coarse classification of this error, useful for `match`-free handling. + pub fn kind(&self) -> ErrorKind { + match self { + Error::Config(_) => ErrorKind::Config, + Error::Transport { .. } => ErrorKind::Transport, + Error::Decode { .. } => ErrorKind::Decode, + Error::Api(ctx) => match ctx.status { + http::StatusCode::UNAUTHORIZED => ErrorKind::Unauthenticated, + http::StatusCode::FORBIDDEN => ErrorKind::Unauthorized, + http::StatusCode::NOT_FOUND => ErrorKind::NotFound, + http::StatusCode::BAD_REQUEST => ErrorKind::Validation, + http::StatusCode::TOO_MANY_REQUESTS => ErrorKind::RateLimited, + s if s.is_server_error() => ErrorKind::Server, + _ => match ctx.api.error.as_str() { + "rate_limit_exceeded" => ErrorKind::RateLimited, + "authentication_required" => ErrorKind::Unauthenticated, + "permission_required" => ErrorKind::Unauthorized, + "not_found" => ErrorKind::NotFound, + "validation_error" => ErrorKind::Validation, + _ => ErrorKind::Other, + }, + }, + } + } + + /// The HTTP status code associated with this error, if any. + pub fn status(&self) -> Option { + match self { + Error::Api(ctx) => Some(ctx.status), + _ => None, + } + } + + /// Whether this error represents a "not found" response. + pub fn is_not_found(&self) -> bool { + self.kind() == ErrorKind::NotFound + } + + /// The duration the caller should wait before retrying, if the server indicated one. + pub fn retry_after(&self) -> Option { + match self { + Error::Api(ctx) => ctx.rate_limit.as_ref().and_then(|rl| rl.retry_after), + _ => None, + } + } + + /// Whether this error is generally safe to retry. + pub fn is_retryable(&self) -> bool { + matches!( + self.kind(), + ErrorKind::RateLimited | ErrorKind::Server | ErrorKind::Transport + ) + } +} + +/// A coarse classification of [`Error`] variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ErrorKind { + /// No or invalid credentials were supplied. + Unauthenticated, + /// The credentials were valid but lack permission for this action. + Unauthorized, + /// The requested resource does not exist. + NotFound, + /// The request was malformed or failed server-side validation. + Validation, + /// The client is being rate limited. + RateLimited, + /// The server encountered an internal error. + Server, + /// The HTTP transport failed (connection, TLS, timeout, ...). + Transport, + /// The response body could not be decoded. + Decode, + /// The client was misconfigured. + Config, + /// Any other API error not covered above. + Other, +} + +/// The error envelope returned by the Outline API when `ok` is `false`. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ApiError { + /// A short machine-readable error code, e.g. `"rate_limit_exceeded"`. + pub error: String, + /// A human-readable error message. + #[serde(default)] + pub message: Option, + /// Additional error-specific data, if any. + #[serde(default)] + pub data: Option, +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.message { + Some(message) => write!(f, "{} ({message})", self.error), + None => write!(f, "{}", self.error), + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9d78a97 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,26 @@ +#![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] +#![warn(missing_docs, missing_debug_implementations, clippy::all)] + +mod api; +mod auth; +mod builder; +mod client; +mod envelope; +mod error; +mod rate_limit; + +pub mod models; +pub mod page; + +pub use api::{ + AuthApi, CollectionsApi, CreateDocument, CreateDocumentParams, DocumentRef, DocumentsApi, + ListCollections, ListCollectionsParams, ListDocuments, ListDocumentsParams, ListUsers, + ListUsersParams, SearchDocuments, SearchDocumentsParams, UpdateDocument, UpdateDocumentParams, + UsersApi, +}; +pub use auth::Auth; +pub use builder::ClientBuilder; +pub use client::Client; +pub use error::{ApiError, ApiErrorContext, Error, ErrorKind, Result}; +pub use rate_limit::RateLimit; diff --git a/src/models/collection.rs b/src/models/collection.rs new file mode 100644 index 0000000..84c5624 --- /dev/null +++ b/src/models/collection.rs @@ -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, + /// A short unique identifier that can be used in place of the UUID. + #[serde(default)] + pub url_id: Option, + /// The name of the collection. + pub name: String, + /// A description of the collection, may contain markdown formatting. + #[serde(default)] + pub description: Option, + /// The position of the collection in the sidebar. + #[serde(default)] + pub index: Option, + /// A color representing the collection, in `#RRGGBB` format. + #[serde(default)] + pub color: Option, + /// An icon name or emoji associated with the collection. + #[serde(default)] + pub icon: Option, + /// The sharing permission level for this collection. + #[serde(default)] + pub permission: Option, + /// 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, + /// The date and time this collection was created. + #[serde(default)] + pub created_at: Option, + /// The date and time this collection was last changed. + #[serde(default)] + pub updated_at: Option, + /// The date and time this collection was archived, if applicable. + #[serde(default)] + pub archived_at: Option, + /// The user who archived this collection, if applicable. + #[serde(default)] + pub archived_by: Option, +} + +/// 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), +} diff --git a/src/models/common.rs b/src/models/common.rs new file mode 100644 index 0000000..0f4540b --- /dev/null +++ b/src/models/common.rs @@ -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; + +/// 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) -> 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 { + self.0.parse().ok() + } +} + +impl From<&str> for Id { + fn from(value: &str) -> Self { + Id(value.to_string()) + } +} + +impl From 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), +} + +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, +} + +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, + /// The document title. + #[serde(default)] + pub title: String, + /// Child documents nested under this one. + #[serde(default)] + pub children: Vec, +} diff --git a/src/models/document.rs b/src/models/document.rs new file mode 100644 index 0000000..db66ebc --- /dev/null +++ b/src/models/document.rs @@ -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, + /// The document this is a child of, if any. + #[serde(default)] + pub parent_document_id: Option, + /// 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, + /// The color of the document icon, in `#RRGGBB` format. + #[serde(default)] + pub color: Option, + /// The text content of the document, in markdown. + #[serde(default)] + pub text: Option, + /// A URL path at which the document can be accessed. + #[serde(default)] + pub url: Option, + /// A short unique id that can be used in place of the UUID. + #[serde(default)] + pub url_id: Option, + /// Identifiers of users who have edited the document. + #[serde(default)] + pub collaborator_ids: Vec, + /// Task completion counts for the document, if it contains checklists. + #[serde(default)] + pub tasks: Option, + /// The revision number, incremented on every save. + #[serde(default)] + pub revision: Option, + /// The date and time this document was created. + #[serde(default)] + pub created_at: Option, + /// The user who created this document. + #[serde(default)] + pub created_by: Option, + /// The date and time this document was last changed. + #[serde(default)] + pub updated_at: Option, + /// The user who last updated this document. + #[serde(default)] + pub updated_by: Option, + /// The date and time this document was published, if applicable. + #[serde(default)] + pub published_at: Option, + /// The date and time this document was archived, if applicable. + #[serde(default)] + pub archived_at: Option, + /// The date and time this document was deleted, if applicable. + #[serde(default)] + pub deleted_at: Option, +} + +/// 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), +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..f420ed0 --- /dev/null +++ b/src/models/mod.rs @@ -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::*; diff --git a/src/models/search.rs b/src/models/search.rs new file mode 100644 index 0000000..e0fe755 --- /dev/null +++ b/src/models/search.rs @@ -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, + /// The relevance ranking used to order search results. + #[serde(default)] + pub ranking: Option, + /// The matching document. + pub document: Document, +} diff --git a/src/models/team.rs b/src/models/team.rs new file mode 100644 index 0000000..a80884f --- /dev/null +++ b/src/models/team.rs @@ -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, + /// The URL of the workspace's avatar image, if any. + #[serde(default)] + pub avatar_url: Option, + /// 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, + /// The fully qualified URL at which this workspace can be accessed. + #[serde(default)] + pub url: Option, + /// The subdomain at which this workspace can be accessed. + #[serde(default)] + pub subdomain: Option, +} + +/// 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, + /// The hostname at which this workspace can be accessed. + #[serde(default)] + pub hostname: Option, + /// Available single sign-on services. + #[serde(default)] + pub services: Vec, +} + +/// 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, + /// The human-readable service name, e.g. `"Slack"`. + #[serde(default)] + pub name: Option, + /// The URL to redirect to in order to authenticate with this service. + #[serde(default)] + pub auth_url: Option, +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..7f2aa2e --- /dev/null +++ b/src/models/user.rs @@ -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, + /// A color representing the user, used for avatars without an image. + #[serde(default)] + pub color: Option, + /// The user's email address. + #[serde(default)] + pub email: Option, + /// The user's role within the workspace. + #[serde(default)] + pub role: Option, + /// 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, + /// The date and time this user first signed in or was invited. + #[serde(default)] + pub created_at: Option, + /// The date and time this user was last updated. + #[serde(default)] + pub updated_at: Option, +} + +/// 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), +} diff --git a/src/page.rs b/src/page.rs new file mode 100644 index 0000000..96bfd23 --- /dev/null +++ b/src/page.rs @@ -0,0 +1,184 @@ +//! Pagination types shared by all `*.list` endpoints. + +#[cfg(feature = "stream")] +use std::collections::VecDeque; + +use serde::{Deserialize, Serialize}; + +use crate::client::{Client, MethodDef}; +use crate::error::Result; +use crate::models::{Policy, SortDirection}; + +/// The default page size used when a [`Paginator`] size is not overridden. +pub(crate) const DEFAULT_PAGE_SIZE: u32 = 25; + +/// Pagination and sorting parameters accepted by Outline's list endpoints. +/// +/// Embed this via `#[serde(flatten)]` in a request params struct. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageParams { + /// The maximum number of items to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// The number of items to skip before starting to return results. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// The field to sort by, e.g. `"updatedAt"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, + /// The sort direction. + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, +} + +/// Implemented by request params structs that embed [`PageParams`], so that +/// [`Paginator`] can advance `offset` between pages without knowing about the +/// rest of the params shape. +pub trait Paginated { + /// Returns a mutable reference to the embedded page parameters. + fn page_params_mut(&mut self) -> &mut PageParams; +} + +/// Pagination metadata echoed back by the Outline API alongside list results. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Pagination { + /// The `limit` that was applied to this request. + #[serde(default)] + pub limit: Option, + /// The `offset` that was applied to this request. + #[serde(default)] + pub offset: Option, + /// A relative path that can be used to fetch the next page, if any. + /// + /// Not part of the published OpenAPI schema; treat as best-effort and do + /// not rely on its absence to detect the last page (use the returned + /// item count instead, see [`Paginator`]). + #[serde(default)] + pub next_path: Option, + /// The total number of items available, if known. + #[serde(default)] + pub total: Option, +} + +/// A single page of results from a list endpoint. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Page { + /// The items returned on this page. + pub items: Vec, + /// Pagination metadata for this page. + pub pagination: Pagination, + /// Access-control policies for each returned item, if the endpoint provides them. + pub policies: Vec, +} + +/// Iterates over all pages of a list endpoint, advancing `offset` automatically. +/// +/// Obtained from a resource's list builder, e.g. `client.documents().list().paginate()`. +pub struct Paginator<'a, P, T> { + client: &'a Client, + method: MethodDef, + params: P, + limit: u32, + done: bool, + _marker: std::marker::PhantomData T>, +} + +impl<'a, P, T> std::fmt::Debug for Paginator<'a, P, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Paginator") + .field("method", &self.method.name) + .field("limit", &self.limit) + .field("done", &self.done) + .finish_non_exhaustive() + } +} + +impl<'a, P, T> Paginator<'a, P, T> +where + P: Paginated + Serialize + Clone, + T: serde::de::DeserializeOwned, +{ + pub(crate) fn new(client: &'a Client, method: MethodDef, mut params: P, limit: u32) -> Self { + { + let page = params.page_params_mut(); + page.limit = Some(limit); + if page.offset.is_none() { + page.offset = Some(0); + } + } + Paginator { + client, + method, + params, + limit, + done: false, + _marker: std::marker::PhantomData, + } + } + + /// Fetches the next page, or `None` if there are no more results. + /// + /// A page is considered the last one whenever it returns fewer items + /// than the requested `limit` (including zero) — the API's `nextPath` + /// hint is not part of the published schema and is not used here. + pub async fn next_page(&mut self) -> Result>> { + if self.done { + return Ok(None); + } + let page = self + .client + .rpc_list::(self.method, &self.params) + .await?; + let received = page.items.len() as u32; + if let Some(offset) = self.params.page_params_mut().offset { + self.params.page_params_mut().offset = Some(offset + received); + } + if received == 0 || received < self.limit { + self.done = true; + } + Ok(Some(page)) + } + + /// Fetches every remaining page and collects all items into a single `Vec`. + pub async fn collect_all(mut self) -> Result> { + let mut items = Vec::new(); + while let Some(page) = self.next_page().await? { + items.extend(page.items); + } + Ok(items) + } + + /// Turns this paginator into a [`futures_core::Stream`] of individual items, + /// fetching further pages lazily as the stream is polled. + #[cfg(feature = "stream")] + pub fn into_stream(self) -> impl futures_core::Stream> + 'a + where + P: 'a, + T: 'a, + { + futures_util::stream::unfold( + (self, VecDeque::new()), + |(mut paginator, mut buf)| async move { + loop { + if let Some(item) = buf.pop_front() { + return Some((Ok(item), (paginator, buf))); + } + match paginator.next_page().await { + Ok(Some(page)) => { + buf.extend(page.items); + if buf.is_empty() { + return None; + } + } + Ok(None) => return None, + Err(err) => return Some((Err(err), (paginator, buf))), + } + } + }, + ) + } +} diff --git a/src/rate_limit.rs b/src/rate_limit.rs new file mode 100644 index 0000000..7bffdbf --- /dev/null +++ b/src/rate_limit.rs @@ -0,0 +1,45 @@ +use std::time::Duration; + +use http::HeaderMap; + +/// Rate limit information parsed from Outline API response headers. +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct RateLimit { + /// How long to wait before retrying, parsed from the `Retry-After` header. + pub retry_after: Option, + /// The maximum number of requests allowed in the current window (`RateLimit-Limit`). + pub limit: Option, + /// The number of requests remaining in the current window (`RateLimit-Remaining`). + pub remaining: Option, + /// The raw value of the `RateLimit-Reset` header, if present. + /// + /// Kept as a raw string rather than a parsed timestamp: Outline documents + /// this header only loosely, and getting the format wrong would be worse + /// than not parsing it at all. + pub reset: Option, +} + +impl RateLimit { + pub(crate) fn from_headers(headers: &HeaderMap) -> Option { + let header_str = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()); + + let retry_after = header_str("retry-after") + .and_then(|s| s.parse::().ok()) + .map(Duration::from_secs); + let limit = header_str("ratelimit-limit").and_then(|s| s.parse::().ok()); + let remaining = header_str("ratelimit-remaining").and_then(|s| s.parse::().ok()); + let reset = header_str("ratelimit-reset").map(str::to_string); + + if retry_after.is_none() && limit.is_none() && remaining.is_none() && reset.is_none() { + None + } else { + Some(RateLimit { + retry_after, + limit, + remaining, + reset, + }) + } + } +} diff --git a/tests/auth.rs b/tests/auth.rs new file mode 100644 index 0000000..aea0707 --- /dev/null +++ b/tests/auth.rs @@ -0,0 +1,70 @@ +mod common; + +use serde_json::json; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn auth_info_returns_user_and_team() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/auth.info")) + .and(header( + "authorization", + "Bearer ol_api_test000000000000000000000000000000", + )) + .and(body_json(json!({}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "status": 200, + "data": { + "user": { + "id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2", + "name": "Jane Doe", + "email": "jane@example.com", + "role": "admin", + "isSuspended": false + }, + "team": { + "id": "9d3d1b2e-3c9a-4c67-8a2a-7f5d3f0e9c11", + "name": "Acme Inc", + "sharing": true + } + } + }))) + .mount(&server) + .await; + + let auth = client.auth().info().await.unwrap(); + assert_eq!(auth.user.name, "Jane Doe"); + assert_eq!(auth.team.name, "Acme Inc"); +} + +#[tokio::test] +async fn auth_config_requires_no_authorization_header() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/auth.config")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "status": 200, + "data": { + "name": "Acme Inc", + "hostname": "acme-inc.getoutline.com", + "services": [ + { "id": "slack", "name": "Slack", "authUrl": "https://acme-inc.getoutline.com/auth/slack" } + ] + } + }))) + .mount(&server) + .await; + + let config = client.auth().config().await.unwrap(); + assert_eq!(config.name.as_deref(), Some("Acme Inc")); + assert_eq!(config.services.len(), 1); + assert_eq!(config.services[0].id.as_deref(), Some("slack")); +} diff --git a/tests/collections.rs b/tests/collections.rs new file mode 100644 index 0000000..ae58fd1 --- /dev/null +++ b/tests/collections.rs @@ -0,0 +1,78 @@ +mod common; + +use serde_json::json; +use wiremock::matchers::{body_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn collections_info_returns_collection() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/collections.info")) + .and(body_json(json!({ "id": "col-1" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": { "id": "col-1", "name": "Human Resources", "sharing": false } + }))) + .mount(&server) + .await; + + let collection = client.collections().info("col-1").await.unwrap(); + assert_eq!(collection.name, "Human Resources"); +} + +#[tokio::test] +async fn collections_list_returns_page_with_policies() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/collections.list")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [{ "id": "col-1", "name": "Human Resources", "sharing": false }], + "pagination": { "limit": 25, "offset": 0 }, + "policies": [{ "id": "col-1", "abilities": { "update": true, "delete": false } }] + }))) + .mount(&server) + .await; + + let page = client.collections().list().send().await.unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(page.policies.len(), 1); + assert!(page.policies[0].can("update")); + assert!(!page.policies[0].can("delete")); + assert!(!page.policies[0].can("archive")); +} + +#[tokio::test] +async fn collections_documents_returns_nested_tree() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/collections.documents")) + .and(body_json(json!({ "id": "col-1" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [ + { + "id": "doc-1", + "title": "Parent", + "children": [ + { "id": "doc-2", "title": "Child", "children": [] } + ] + } + ] + }))) + .mount(&server) + .await; + + let tree = client.collections().documents("col-1").await.unwrap(); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].title, "Parent"); + assert_eq!(tree[0].children.len(), 1); + assert_eq!(tree[0].children[0].title, "Child"); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..8d6b3ae --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,11 @@ +use outline::Client; +use wiremock::MockServer; + +/// Spins up a mock Outline API server and a [`Client`] pointed at it. +pub async fn client_for(server: &MockServer) -> Client { + Client::builder() + .base_url(server.uri()) + .api_key("ol_api_test000000000000000000000000000000") + .build() + .unwrap() +} diff --git a/tests/documents.rs b/tests/documents.rs new file mode 100644 index 0000000..7d3b4ce --- /dev/null +++ b/tests/documents.rs @@ -0,0 +1,155 @@ +mod common; + +use serde_json::json; +use wiremock::matchers::{body_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn sample_document(id: &str, title: &str) -> serde_json::Value { + json!({ + "id": id, + "collectionId": "col-1", + "title": title, + "text": "Hello world", + "urlId": "abc123", + }) +} + +#[tokio::test] +async fn documents_info_by_id() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.info")) + .and(body_json(json!({ "id": "doc-1" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": sample_document("doc-1", "Welcome") + }))) + .mount(&server) + .await; + + let document = client.documents().info("doc-1").await.unwrap(); + assert_eq!(document.title, "Welcome"); + assert_eq!(document.collection_id.unwrap().as_str(), "col-1"); +} + +#[tokio::test] +async fn documents_list_returns_page() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.list")) + .and(body_json(json!({ "limit": 2, "collectionId": "col-1" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [sample_document("doc-1", "One"), sample_document("doc-2", "Two")], + "pagination": { "limit": 2, "offset": 0 } + }))) + .mount(&server) + .await; + + let page = client + .documents() + .list() + .collection_id("col-1") + .limit(2) + .send() + .await + .unwrap(); + assert_eq!(page.items.len(), 2); + assert_eq!(page.items[0].title, "One"); + assert_eq!(page.pagination.limit, Some(2)); +} + +#[tokio::test] +async fn documents_create_sends_title_and_collection() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.create")) + .and(body_json( + json!({ "title": "New Doc", "collectionId": "col-1", "publish": true }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": sample_document("doc-3", "New Doc") + }))) + .mount(&server) + .await; + + let document = client + .documents() + .create("New Doc") + .collection_id("col-1") + .publish(true) + .send() + .await + .unwrap(); + assert_eq!(document.id.as_str(), "doc-3"); +} + +#[tokio::test] +async fn documents_update_sends_only_set_fields() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.update")) + .and(body_json(json!({ "id": "doc-1", "title": "Renamed" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": sample_document("doc-1", "Renamed") + }))) + .mount(&server) + .await; + + let document = client + .documents() + .update("doc-1") + .title("Renamed") + .send() + .await + .unwrap(); + assert_eq!(document.title, "Renamed"); +} + +#[tokio::test] +async fn documents_delete_ignores_response_shape() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.delete")) + .and(body_json(json!({ "id": "doc-1", "permanent": false }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "success": true }))) + .mount(&server) + .await; + + client.documents().delete("doc-1", false).await.unwrap(); +} + +#[tokio::test] +async fn documents_search_returns_ranked_results() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.search")) + .and(body_json(json!({ "query": "hiring" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [ + { "context": "our hiring practices", "ranking": 1.5, "document": sample_document("doc-4", "Hiring Guide") } + ], + "pagination": { "limit": 25, "offset": 0 } + }))) + .mount(&server) + .await; + + let page = client.documents().search("hiring").send().await.unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].document.title, "Hiring Guide"); +} diff --git a/tests/errors.rs b/tests/errors.rs new file mode 100644 index 0000000..b72500a --- /dev/null +++ b/tests/errors.rs @@ -0,0 +1,106 @@ +mod common; + +use outline::ErrorKind; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn maps_401_to_unauthenticated() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/auth.info")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "ok": false, + "status": 401, + "error": "authentication_required", + "message": "Authentication required" + }))) + .mount(&server) + .await; + + let err = client.auth().info().await.unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unauthenticated); + assert_eq!(err.status(), Some(http::StatusCode::UNAUTHORIZED)); + assert!(!err.is_retryable()); +} + +#[tokio::test] +async fn maps_404_to_not_found() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/collections.info")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "ok": false, + "status": 404, + "error": "not_found", + "message": "Collection not found" + }))) + .mount(&server) + .await; + + let err = client.collections().info("missing-id").await.unwrap_err(); + assert!(err.is_not_found()); + assert_eq!(err.kind(), ErrorKind::NotFound); +} + +#[tokio::test] +async fn maps_429_to_rate_limited_and_parses_retry_after() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.list")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("Retry-After", "12") + .set_body_json(json!({ + "ok": false, + "status": 429, + "error": "rate_limit_exceeded", + "message": "Rate limit exceeded" + })), + ) + .mount(&server) + .await; + + let err = client.documents().list().send().await.unwrap_err(); + assert_eq!(err.kind(), ErrorKind::RateLimited); + assert!(err.is_retryable()); + assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(12))); +} + +#[tokio::test] +async fn maps_5xx_to_server_error() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/auth.info")) + .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) + .mount(&server) + .await; + + let err = client.auth().info().await.unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Server); + assert!(err.is_retryable()); +} + +#[tokio::test] +async fn malformed_success_body_yields_decode_error() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/auth.info")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json")) + .mount(&server) + .await; + + let err = client.auth().info().await.unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Decode); +} diff --git a/tests/fixtures/document.json b/tests/fixtures/document.json new file mode 100644 index 0000000..c1d4d5a --- /dev/null +++ b/tests/fixtures/document.json @@ -0,0 +1,27 @@ +{ + "id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2", + "collectionId": "9d3d1b2e-3c9a-4c67-8a2a-7f5d3f0e9c11", + "parentDocumentId": null, + "title": "Welcome to Acme Inc", + "fullWidth": false, + "icon": "🎉", + "color": null, + "text": "# Welcome\n\nThis is the text of the document.", + "url": "/doc/welcome-to-acme-inc-hDYep1TPAM", + "urlId": "hDYep1TPAM", + "collaboratorIds": ["5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2"], + "tasks": { "completed": 1, "total": 4 }, + "revision": 12, + "createdAt": "2024-01-15T10:00:00.000Z", + "createdBy": { + "id": "5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2", + "name": "Jane Doe", + "email": "jane@example.com", + "role": "admin", + "isSuspended": false + }, + "updatedAt": "2024-02-20T09:30:00.000Z", + "publishedAt": "2024-01-15T11:00:00.000Z", + "archivedAt": null, + "deletedAt": null +} diff --git a/tests/live.rs b/tests/live.rs new file mode 100644 index 0000000..5ccfed2 --- /dev/null +++ b/tests/live.rs @@ -0,0 +1,29 @@ +//! Smoke tests against the real Outline API. +//! +//! Ignored by default. Run with: +//! `OUTLINE_API_KEY=ol_api_... cargo test -- --ignored` + +use outline::Client; + +#[tokio::test] +#[ignore] +async fn auth_info_and_collections_list_succeed() { + let client = + Client::from_env().expect("OUTLINE_API_KEY (and optionally OUTLINE_URL) must be set"); + + let auth = client + .auth() + .info() + .await + .expect("auth.info should succeed with valid credentials"); + println!("Signed in as {} ({})", auth.user.name, auth.team.name); + + let page = client + .collections() + .list() + .limit(5) + .send() + .await + .expect("collections.list should succeed"); + println!("Fetched {} collection(s)", page.items.len()); +} diff --git a/tests/models.rs b/tests/models.rs new file mode 100644 index 0000000..b861f5a --- /dev/null +++ b/tests/models.rs @@ -0,0 +1,101 @@ +use outline::models::{Ability, DocumentTasks, NavigationNode, Policy, User, UserRole}; +use serde_json::json; + +#[test] +fn deserializes_full_document_fixture() { + let raw = include_str!("fixtures/document.json"); + let document: outline::models::Document = serde_json::from_str(raw).unwrap(); + + assert_eq!(document.title, "Welcome to Acme Inc"); + assert_eq!(document.url_id.as_deref(), Some("hDYep1TPAM")); + assert_eq!( + document.tasks, + Some(DocumentTasks { + completed: 1, + total: 4 + }) + ); + assert!(document.parent_document_id.is_none()); + assert!(document.archived_at.is_none()); + assert_eq!(document.created_by.unwrap().name, "Jane Doe"); +} + +/// A server (self-hosted, newer than this crate) adding a brand new field to +/// a response must not break deserialization of the rest of the object. +#[test] +fn unknown_top_level_field_is_ignored() { + let raw = json!({ + "id": "doc-1", + "title": "Still works", + "aBrandNewFieldFromTheFuture": { "nested": true }, + }); + + let document: outline::models::Document = serde_json::from_value(raw).unwrap(); + assert_eq!(document.title, "Still works"); +} + +/// A server returning a role value this crate doesn't know about yet must +/// round-trip instead of failing to deserialize the whole `User`. +#[test] +fn unknown_enum_value_round_trips_via_unknown_variant() { + let raw = json!({ + "id": "user-1", + "name": "Future User", + "role": "super_admin", + }); + + let user: User = serde_json::from_value(raw).unwrap(); + match &user.role { + Some(UserRole::Unknown(value)) => assert_eq!(value.0, "super_admin"), + other => panic!("expected UserRole::Unknown, got {other:?}"), + } + + let round_tripped = serde_json::to_value(&user).unwrap(); + assert_eq!(round_tripped["role"], json!("super_admin")); +} + +#[test] +fn known_enum_value_deserializes_normally() { + let raw = json!({ "id": "user-1", "name": "Jane", "role": "member" }); + let user: User = serde_json::from_value(raw).unwrap(); + assert_eq!(user.role, Some(UserRole::Member)); +} + +#[test] +fn policy_ability_accepts_boolean_or_membership_list() { + let raw = json!({ + "id": "doc-1", + "abilities": { + "update": true, + "delete": false, + "restrictedUpdate": ["group-a", "group-b"], + } + }); + + let policy: Policy = serde_json::from_value(raw).unwrap(); + assert!(policy.can("update")); + assert!(!policy.can("delete")); + assert!(policy.can("restrictedUpdate")); + assert!(!policy.can("missingAbility")); + assert_eq!( + policy.abilities["restrictedUpdate"], + Ability::Memberships(vec!["group-a".into(), "group-b".into()]) + ); +} + +#[test] +fn navigation_node_deserializes_recursively() { + let raw = json!({ + "id": "doc-1", + "title": "Parent", + "children": [ + { "id": "doc-2", "title": "Child", "children": [ + { "id": "doc-3", "title": "Grandchild" } + ]} + ] + }); + + let node: NavigationNode = serde_json::from_value(raw).unwrap(); + assert_eq!(node.children.len(), 1); + assert_eq!(node.children[0].children[0].title, "Grandchild"); +} diff --git a/tests/pagination.rs b/tests/pagination.rs new file mode 100644 index 0000000..39aaedd --- /dev/null +++ b/tests/pagination.rs @@ -0,0 +1,113 @@ +mod common; + +use serde_json::json; +use wiremock::matchers::{body_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn doc(id: &str) -> serde_json::Value { + json!({ "id": id, "title": id }) +} + +#[tokio::test] +async fn paginator_advances_offset_and_stops_on_short_page() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.list")) + .and(body_json(json!({ "limit": 2, "offset": 0 }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [doc("doc-1"), doc("doc-2")], + "pagination": { "limit": 2, "offset": 0 } + }))) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/documents.list")) + .and(body_json(json!({ "limit": 2, "offset": 2 }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [doc("doc-3")], + "pagination": { "limit": 2, "offset": 2 } + }))) + .mount(&server) + .await; + + let items = client + .documents() + .list() + .limit(2) + .paginate() + .collect_all() + .await + .unwrap(); + let ids: Vec<_> = items + .into_iter() + .map(|d| d.id.as_str().to_string()) + .collect(); + assert_eq!(ids, vec!["doc-1", "doc-2", "doc-3"]); +} + +#[tokio::test] +async fn paginator_stops_immediately_on_empty_first_page() { + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.list")) + .and(body_json(json!({ "limit": 25, "offset": 0 }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [], + "pagination": { "limit": 25, "offset": 0 } + }))) + .mount(&server) + .await; + + let mut paginator = client.documents().list().paginate(); + let page = paginator + .next_page() + .await + .unwrap() + .expect("first page always returned"); + assert!(page.items.is_empty()); + assert!(paginator.next_page().await.unwrap().is_none()); +} + +#[cfg(feature = "stream")] +#[tokio::test] +async fn into_stream_yields_items_across_pages() { + use futures_util::StreamExt; + + let server = MockServer::start().await; + let client = common::client_for(&server).await; + + Mock::given(method("POST")) + .and(path("/api/documents.list")) + .and(body_json(json!({ "limit": 2, "offset": 0 }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [doc("doc-1"), doc("doc-2")], + "pagination": { "limit": 2, "offset": 0 } + }))) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/documents.list")) + .and(body_json(json!({ "limit": 2, "offset": 2 }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "data": [doc("doc-3")], + "pagination": { "limit": 2, "offset": 2 } + }))) + .mount(&server) + .await; + + let stream = client.documents().list().limit(2).paginate().into_stream(); + let items: Vec<_> = stream.collect().await; + assert_eq!(items.len(), 3); + assert!(items.iter().all(|item| item.is_ok())); +} diff --git a/tests/spec_coverage.rs b/tests/spec_coverage.rs new file mode 100644 index 0000000..fed8bf9 --- /dev/null +++ b/tests/spec_coverage.rs @@ -0,0 +1,38 @@ +//! Verifies that every RPC method name used by this crate still exists as a +//! path in the vendored OpenAPI spec, catching typos and upstream renames. + +const IMPLEMENTED_METHODS: &[&str] = &[ + "auth.info", + "auth.config", + "documents.info", + "documents.list", + "documents.search", + "documents.create", + "documents.update", + "documents.delete", + "collections.list", + "collections.info", + "collections.documents", + "users.list", + "users.info", +]; + +#[test] +fn implemented_methods_exist_in_vendored_spec() { + let spec_raw = include_str!("../spec/spec3.json"); + let spec: serde_json::Value = serde_json::from_str(spec_raw).unwrap(); + let paths = spec["paths"] + .as_object() + .expect("spec should have a `paths` object"); + + let missing: Vec<&str> = IMPLEMENTED_METHODS + .iter() + .copied() + .filter(|method| !paths.contains_key(&format!("/{method}"))) + .collect(); + + assert!( + missing.is_empty(), + "methods missing from vendored spec: {missing:?}" + ); +}