use serde::{Deserialize, Serialize};
use crate::api::Client;
use crate::api::error::ApiError;
pub const DEFAULT_WIKI_URL: &str = "https://api.wiki.yandex.net";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WikiPage {
pub id: i64,
pub slug: String,
pub title: String,
#[serde(default)]
pub page_type: Option<String>,
#[serde(default)]
pub modified_at: Option<String>,
#[serde(default)]
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WikiPageRef {
pub id: i64,
pub slug: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorPage<T> {
pub results: Vec<T>,
#[serde(default)]
pub next_cursor: Option<String>,
}
pub const LAST_SEARCH_PAGE: u32 = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WikiHit {
pub slug: String,
pub title: String,
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub modified_at: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default, rename(deserialize = "content"))]
pub snippet: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WikiHits {
pub results: Vec<WikiHit>,
pub next_page: Option<u32>,
}
#[derive(Deserialize)]
struct SearchAnswer {
results: Vec<WikiHit>,
#[serde(default)]
next_cursor: Option<String>,
}
#[derive(Deserialize)]
struct Answer {
id: i64,
slug: String,
title: String,
#[serde(default)]
page_type: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
attributes: Option<Attributes>,
}
#[derive(Deserialize)]
struct Attributes {
#[serde(default)]
modified_at: Option<String>,
}
impl From<Answer> for WikiPage {
fn from(answer: Answer) -> Self {
Self {
id: answer.id,
slug: answer.slug,
title: answer.title,
page_type: answer.page_type,
modified_at: answer
.attributes
.and_then(|attributes| attributes.modified_at),
content: answer.content,
}
}
}
impl Client {
pub async fn wiki_page(&self, slug: &str) -> Result<WikiPage, ApiError> {
let url = format!(
"{}/v1/pages?slug={}&fields=content,attributes",
self.wiki_url,
encode(slug)
);
let (value, _) = self
.send_url(
reqwest::Method::GET,
&url,
None,
&format!("wiki page `{slug}`"),
)
.await
.map_err(refused)?;
serde_json::from_value::<Answer>(value)
.map(WikiPage::from)
.map_err(ApiError::Decode)
}
}
impl Client {
pub async fn wiki_descendants(
&self,
slug: &str,
cursor: Option<&str>,
page_size: u32,
) -> Result<CursorPage<WikiPageRef>, ApiError> {
use std::fmt::Write as _;
let mut url = format!(
"{}/v1/pages/descendants?slug={}&page_size={page_size}",
self.wiki_url,
encode(slug)
);
if let Some(cursor) = cursor {
let _ = write!(url, "&cursor={}", encode(cursor));
}
let (value, _) = self
.send_url(
reqwest::Method::GET,
&url,
None,
&format!("wiki page `{slug}`"),
)
.await
.map_err(refused)?;
serde_json::from_value(value).map_err(ApiError::Decode)
}
pub async fn wiki_search(
&self,
query: &str,
kind: Option<&str>,
page: u32,
limit: u32,
) -> Result<WikiHits, ApiError> {
let mut body = serde_json::json!({ "query": query, "cursor": page, "limit": limit });
if let Some(kind) = kind {
body["filters"] = serde_json::json!({ "type": kind });
}
let url = format!("{}/v1/search", self.wiki_url);
let (value, _) = self
.send_url(reqwest::Method::POST, &url, Some(&body), "wiki search")
.await
.map_err(refused)?;
let answer: SearchAnswer = serde_json::from_value(value).map_err(ApiError::Decode)?;
let more =
answer.next_cursor.is_some_and(|cursor| !cursor.is_empty()) && page < LAST_SEARCH_PAGE;
Ok(WikiHits {
results: answer.results,
next_page: more.then_some(page + 1),
})
}
pub async fn wiki_reachable(&self) -> Result<(), ApiError> {
let url = format!("{}/v1/users/me", self.wiki_url);
self.send_url(reqwest::Method::GET, &url, None, "the Wiki's current user")
.await
.map(|_| ())
.map_err(refused)
}
#[cfg(feature = "live")]
pub async fn probe_wiki(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let url = format!("{}{path}", self.wiki_url);
let (value, _) = self.send_url(method, &url, body, path).await?;
Ok(value)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct WikiComment {
pub id: u64,
pub author: Option<String>,
pub created_at: Option<String>,
pub body: String,
pub resolved: bool,
pub deleted: bool,
pub quote: Option<String>,
pub thread_posts: Option<u64>,
}
#[derive(Deserialize)]
struct CommentAnswer {
id: u64,
#[serde(default)]
body: String,
#[serde(default)]
author: Option<Person>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
is_deleted: bool,
#[serde(default)]
resolve_status: Option<String>,
#[serde(default)]
inline_text: Option<String>,
#[serde(default)]
thread_info: Option<ThreadInfo>,
}
#[derive(Deserialize)]
struct Person {
username: String,
}
#[derive(Deserialize)]
struct ThreadInfo {
total_posts: u64,
}
impl From<CommentAnswer> for WikiComment {
fn from(answer: CommentAnswer) -> Self {
Self {
id: answer.id,
author: answer.author.map(|person| person.username),
created_at: answer.created_at,
body: answer.body,
resolved: answer.resolve_status.as_deref() == Some("resolved"),
deleted: answer.is_deleted,
quote: answer.inline_text.filter(|text| !text.is_empty()),
thread_posts: answer.thread_info.map(|info| info.total_posts),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct WikiAttachment {
pub id: u64,
pub name: String,
pub size: String,
pub mimetype: Option<String>,
pub created_at: Option<String>,
pub author: Option<String>,
pub download_url: Option<String>,
}
#[derive(Deserialize)]
struct AttachmentAnswer {
id: u64,
name: String,
#[serde(default)]
size: serde_json::Value,
#[serde(default)]
mimetype: Option<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
user: Option<Person>,
#[serde(default)]
download_url: Option<String>,
}
impl From<AttachmentAnswer> for WikiAttachment {
fn from(answer: AttachmentAnswer) -> Self {
Self {
id: answer.id,
name: answer.name,
size: match answer.size {
serde_json::Value::String(size) => size,
serde_json::Value::Null => "-".to_owned(),
other => other.to_string(),
},
mimetype: answer.mimetype,
created_at: answer.created_at,
author: answer.user.map(|person| person.username),
download_url: answer.download_url,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WikiGridRef {
#[serde(deserialize_with = "text_id")]
pub id: String,
pub title: String,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WikiResource {
pub kind: String,
pub id: String,
pub name: String,
pub created_at: Option<String>,
}
#[derive(Deserialize)]
struct ResourceAnswer {
#[serde(rename = "type")]
kind: String,
item: serde_json::Value,
}
impl From<ResourceAnswer> for WikiResource {
fn from(answer: ResourceAnswer) -> Self {
let text = |field: &str| {
answer.item.get(field).and_then(|value| match value {
serde_json::Value::String(text) => Some(text.clone()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
})
};
Self {
id: text("id").unwrap_or_default(),
name: text("name").or_else(|| text("title")).unwrap_or_default(),
created_at: text("created_at"),
kind: answer.kind,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct WikiGrid {
pub id: String,
pub title: String,
pub page: Option<WikiPageRef>,
pub revision: String,
pub columns: Vec<GridColumn>,
pub rows: Vec<GridRow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridColumn {
pub slug: String,
pub title: String,
#[serde(rename = "type")]
pub kind: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct GridRow {
pub id: String,
pub cells: Vec<serde_json::Value>,
}
#[derive(Deserialize)]
struct GridAnswer {
#[serde(deserialize_with = "text_id")]
id: String,
title: String,
#[serde(default)]
page: Option<WikiPageRef>,
#[serde(default, deserialize_with = "text_id")]
revision: String,
#[serde(default)]
structure: Structure,
#[serde(default)]
rows: Vec<RowAnswer>,
}
#[derive(Default, Deserialize)]
struct Structure {
#[serde(default)]
columns: Vec<GridColumn>,
}
#[derive(Deserialize)]
struct RowAnswer {
#[serde(deserialize_with = "text_id")]
id: String,
#[serde(default)]
row: Vec<serde_json::Value>,
}
impl From<GridAnswer> for WikiGrid {
fn from(answer: GridAnswer) -> Self {
Self {
id: answer.id,
title: answer.title,
page: answer.page,
revision: answer.revision,
columns: answer.structure.columns,
rows: answer
.rows
.into_iter()
.map(|row| GridRow {
id: row.id,
cells: row.row,
})
.collect(),
}
}
}
fn text_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
Ok(match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::String(text) => text,
serde_json::Value::Null => String::new(),
other => other.to_string(),
})
}
#[derive(Debug, Clone, Copy, Default)]
pub struct GridQuery<'a> {
pub filter: Option<&'a str>,
pub sort: Option<&'a str>,
pub columns: Option<&'a str>,
pub rows: Option<&'a str>,
pub revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WikiAccess {
pub slug: String,
pub policy: Option<String>,
pub inherited_policy: Option<String>,
pub all_staff_role: Option<String>,
pub entries: Vec<AccessEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AccessEntry {
pub id: String,
pub role: String,
pub kind: String,
pub who: String,
pub via: String,
pub inheritance: Option<String>,
}
fn scalar(value: Option<&serde_json::Value>) -> Option<String> {
match value? {
serde_json::Value::String(text) => Some(text.clone()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
}
}
impl WikiAccess {
fn from_page(slug: &str, page: &serde_json::Value) -> Self {
let policy = page.get("access_policy");
let field = |name: &str| scalar(policy.and_then(|policy| policy.get(name)));
let mut entries = Vec::new();
if let Some(lists) = page.get("access_lists") {
for via in ["direct", "by_link", "inherited"] {
let items = lists.get(via).and_then(serde_json::Value::as_array);
for item in items.into_iter().flatten() {
entries.push(AccessEntry::from_item(item, via));
}
}
}
Self {
slug: slug.to_owned(),
policy: field("access_type"),
inherited_policy: field("inherited_access_type"),
all_staff_role: field("all_staff_role"),
entries,
}
}
}
impl AccessEntry {
fn from_item(item: &serde_json::Value, via: &str) -> Self {
let present = |name: &str| item.get(name).filter(|value| !value.is_null());
let (kind, who) = if let Some(user) = present("user") {
("user", scalar(user.get("username")))
} else if let Some(group) = present("group") {
("group", scalar(group.get("name")))
} else {
("-", None)
};
Self {
id: scalar(item.get("id")).unwrap_or_default(),
role: scalar(item.get("role")).unwrap_or_default(),
kind: kind.to_owned(),
who: who.unwrap_or_default(),
via: via.to_owned(),
inheritance: scalar(item.get("inheritance")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WikiOperation {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct OperationStatus {
pub status: String,
pub percentage: Option<f64>,
pub details: Option<String>,
pub result: Option<serde_json::Value>,
}
impl OperationStatus {
#[must_use]
pub fn is_done(&self) -> bool {
matches!(self.status.as_str(), "success" | "failed")
}
#[must_use]
pub fn page_slug(&self) -> Option<&str> {
self.result.as_ref()?.get("page")?.get("slug")?.as_str()
}
#[must_use]
pub fn grid_id(&self) -> Option<String> {
scalar(self.result.as_ref()?.get("grid_id"))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploadSession {
pub session_id: String,
}
pub const UPLOAD_PART: usize = 8 * 1024 * 1024;
#[must_use]
pub fn upload_parts(len: usize, size: usize) -> Vec<std::ops::Range<usize>> {
if len == 0 {
return std::iter::once(0..0).collect();
}
let size = size.max(1);
(0..len)
.step_by(size)
.map(|start| start..(start + size).min(len))
.collect()
}
fn write_refused(error: ApiError) -> ApiError {
match error {
ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiWriteForbidden,
other => other,
}
}
#[derive(Debug, Clone, Copy)]
pub enum CommentScope<'a> {
Page { status: Option<&'a str> },
Thread(u64),
}
impl Client {
pub async fn wiki_page_id(&self, slug: &str) -> Result<i64, ApiError> {
#[derive(Deserialize)]
struct Identity {
id: i64,
}
let url = format!("{}/v1/pages?slug={}", self.wiki_url, encode(slug));
let (value, _) = self
.send_url(
reqwest::Method::GET,
&url,
None,
&format!("wiki page `{slug}`"),
)
.await
.map_err(refused)?;
serde_json::from_value::<Identity>(value)
.map(|identity| identity.id)
.map_err(ApiError::Decode)
}
pub async fn wiki_comments(
&self,
slug: &str,
scope: CommentScope<'_>,
cursor: Option<&str>,
page_size: u32,
) -> Result<CursorPage<WikiComment>, ApiError> {
let id = self.wiki_page_id(slug).await?;
let (tail, what) = match scope {
CommentScope::Page { status } => (
format!(
"comments?{}",
status.map_or_else(String::new, |status| format!("status_filter={status}&"))
),
format!("comments on wiki page `{slug}`"),
),
CommentScope::Thread(comment) => (
format!("comments/{comment}/thread?"),
format!("comment {comment} on wiki page `{slug}`"),
),
};
let page: CursorPage<CommentAnswer> = self
.wiki_listing(id, &tail, cursor, page_size, &what)
.await?;
Ok(CursorPage {
results: page.results.into_iter().map(WikiComment::from).collect(),
next_cursor: page.next_cursor,
})
}
pub async fn wiki_attachments(
&self,
slug: &str,
cursor: Option<&str>,
page_size: u32,
) -> Result<CursorPage<WikiAttachment>, ApiError> {
let id = self.wiki_page_id(slug).await?;
let page: CursorPage<AttachmentAnswer> = self
.wiki_listing(
id,
"attachments?",
cursor,
page_size,
&format!("attachments of wiki page `{slug}`"),
)
.await?;
Ok(CursorPage {
results: page.results.into_iter().map(WikiAttachment::from).collect(),
next_cursor: page.next_cursor,
})
}
pub async fn wiki_grids(
&self,
slug: &str,
cursor: Option<&str>,
page_size: u32,
) -> Result<CursorPage<WikiGridRef>, ApiError> {
let id = self.wiki_page_id(slug).await?;
self.wiki_listing(
id,
"grids?",
cursor,
page_size,
&format!("grids of wiki page `{slug}`"),
)
.await
}
pub async fn wiki_resources(
&self,
slug: &str,
kind: Option<&str>,
query: Option<&str>,
cursor: Option<&str>,
page_size: u32,
) -> Result<CursorPage<WikiResource>, ApiError> {
use std::fmt::Write as _;
let id = self.wiki_page_id(slug).await?;
let mut tail = "resources?".to_owned();
if let Some(kind) = kind {
let _ = write!(tail, "types={}&", encode(kind));
}
if let Some(query) = query {
let _ = write!(tail, "q={}&", encode(query));
}
let page: CursorPage<ResourceAnswer> = self
.wiki_listing(
id,
&tail,
cursor,
page_size,
&format!("resources of wiki page `{slug}`"),
)
.await?;
Ok(CursorPage {
results: page.results.into_iter().map(WikiResource::from).collect(),
next_cursor: page.next_cursor,
})
}
pub async fn wiki_grid(&self, id: &str, query: GridQuery<'_>) -> Result<WikiGrid, ApiError> {
use std::fmt::Write as _;
let mut url = format!("{}/v1/grids/{}", self.wiki_url, encode(id));
let mut separator = '?';
for (name, value) in [
("filter", query.filter),
("sort", query.sort),
("only_cols", query.columns),
("only_rows", query.rows),
] {
if let Some(value) = value {
let _ = write!(url, "{separator}{name}={}", encode(value));
separator = '&';
}
}
if let Some(revision) = query.revision {
let _ = write!(url, "{separator}revision={revision}");
}
let (value, _) = self
.send_url(
reqwest::Method::GET,
&url,
None,
&format!("wiki grid `{id}`"),
)
.await
.map_err(refused)?;
serde_json::from_value::<GridAnswer>(value)
.map(WikiGrid::from)
.map_err(ApiError::Decode)
}
pub async fn wiki_attachment_named(
&self,
slug: &str,
wanted: &str,
) -> Result<(i64, WikiAttachment), ApiError> {
const PAGES: usize = 20;
let id = self.wiki_page_id(slug).await?;
let what = format!("attachments of wiki page `{slug}`");
let mut cursor: Option<String> = None;
for _ in 0..PAGES {
let page: CursorPage<AttachmentAnswer> = self
.wiki_listing(id, "attachments?", cursor.as_deref(), 100, &what)
.await?;
if let Some(found) = page
.results
.into_iter()
.map(WikiAttachment::from)
.find(|file| file.id.to_string() == wanted || file.name == wanted)
{
return Ok((id, found));
}
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Err(ApiError::NotFound(format!(
"attachment `{wanted}` on wiki page `{slug}`"
)))
}
pub async fn wiki_attachment_bytes(&self, page: i64, file: u64) -> Result<Vec<u8>, ApiError> {
let url = format!(
"{}/v1/pages/{page}/attachments/{file}/download",
self.wiki_url
);
self.wiki_bytes(&url, &format!("attachment {file}")).await
}
pub async fn wiki_file_bytes(&self, path: &str) -> Result<Vec<u8>, ApiError> {
let url = format!(
"{}/v1/pages/attachments/download_by_url?url={}",
self.wiki_url,
encode(path)
);
self.wiki_bytes(&url, &format!("wiki file `{path}`")).await
}
async fn wiki_bytes(&self, url: &str, what: &str) -> Result<Vec<u8>, ApiError> {
let response = self.http.get(url).send().await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
401 | 403 => ApiError::WikiForbidden,
404 => ApiError::NotFound(what.to_owned()),
_ => ApiError::Rejected {
status,
message: String::new(),
},
});
}
Ok(response.bytes().await?.to_vec())
}
async fn wiki_listing<T: serde::de::DeserializeOwned>(
&self,
id: i64,
tail: &str,
cursor: Option<&str>,
page_size: u32,
what: &str,
) -> Result<CursorPage<T>, ApiError> {
use std::fmt::Write as _;
let mut url = format!(
"{}/v1/pages/{id}/{tail}page_size={page_size}",
self.wiki_url
);
if let Some(cursor) = cursor {
let _ = write!(url, "&cursor={}", encode(cursor));
}
let (value, _) = self
.send_url(reqwest::Method::GET, &url, None, what)
.await
.map_err(refused)?;
let page: CursorPage<T> = serde_json::from_value(value).map_err(ApiError::Decode)?;
Ok(CursorPage {
next_cursor: page.next_cursor.filter(|cursor| !cursor.is_empty()),
results: page.results,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WikiRestored {
pub id: i64,
pub slug: String,
#[serde(default)]
pub pages_count: Option<u64>,
}
impl Client {
pub async fn wiki_create(
&self,
body: &serde_json::Value,
silent: bool,
) -> Result<WikiPageRef, ApiError> {
let url = format!(
"{}/v1/pages{}",
self.wiki_url,
query(&[silent.then_some("is_silent=true")])
);
self.wiki_write(reqwest::Method::POST, &url, Some(body), "the new wiki page")
.await
}
pub async fn wiki_update(
&self,
id: i64,
body: &serde_json::Value,
merge: bool,
silent: bool,
) -> Result<WikiPageRef, ApiError> {
let url = format!(
"{}/v1/pages/{id}{}",
self.wiki_url,
query(&[
merge.then_some("allow_merge=true"),
silent.then_some("is_silent=true"),
])
);
self.wiki_write(
reqwest::Method::POST,
&url,
Some(body),
&format!("wiki page {id}"),
)
.await
}
pub async fn wiki_append(
&self,
id: i64,
body: &serde_json::Value,
silent: bool,
) -> Result<WikiPageRef, ApiError> {
let url = format!(
"{}/v1/pages/{id}/append-content{}",
self.wiki_url,
query(&[silent.then_some("is_silent=true")])
);
self.wiki_write(
reqwest::Method::POST,
&url,
Some(body),
&format!("wiki page {id}"),
)
.await
}
pub async fn wiki_delete(&self, id: i64, recursive: bool) -> Result<String, ApiError> {
#[derive(Deserialize)]
struct Deleted {
recovery_token: String,
}
let url = format!(
"{}/v1/pages/{id}{}",
self.wiki_url,
query(&[
recursive.then_some("recursive=true"),
recursive.then_some("allow_recursive=true"),
])
);
let deleted: Deleted = self
.wiki_write(
reqwest::Method::DELETE,
&url,
None,
&format!("wiki page {id}"),
)
.await?;
Ok(deleted.recovery_token)
}
pub async fn wiki_comment(
&self,
page: i64,
body: &serde_json::Value,
) -> Result<WikiComment, ApiError> {
let url = format!("{}/v1/pages/{page}/comments", self.wiki_url);
let answer: CommentAnswer = self
.wiki_write(
reqwest::Method::POST,
&url,
Some(body),
&format!("wiki page {page}"),
)
.await?;
Ok(WikiComment::from(answer))
}
pub async fn wiki_delete_comment(
&self,
page: i64,
comment: u64,
) -> Result<Option<u64>, ApiError> {
#[derive(Deserialize)]
struct Left {
#[serde(default)]
comments_count: Option<u64>,
}
let url = format!("{}/v1/pages/{page}/comments/{comment}", self.wiki_url);
let left: Left = self
.wiki_write(
reqwest::Method::DELETE,
&url,
None,
&format!("comment {comment} on wiki page {page}"),
)
.await?;
Ok(left.comments_count)
}
pub async fn wiki_access(&self, slug: &str) -> Result<WikiAccess, ApiError> {
let url = format!(
"{}/v1/pages?slug={}&fields=access_policy,access_lists",
self.wiki_url,
encode(slug)
);
let (value, _) = self
.send_url(
reqwest::Method::GET,
&url,
None,
&format!("wiki page `{slug}`"),
)
.await
.map_err(refused)?;
Ok(WikiAccess::from_page(slug, &value))
}
pub async fn wiki_grant(
&self,
page: i64,
body: &serde_json::Value,
allow_selflock: bool,
) -> Result<AccessEntry, ApiError> {
let url = format!(
"{}/v1/pages/{page}/access{}",
self.wiki_url,
query(&[(!allow_selflock).then_some("prevent_selflock=true")])
);
let item: serde_json::Value = self
.wiki_write(
reqwest::Method::POST,
&url,
Some(body),
&format!("access to wiki page {page}"),
)
.await?;
Ok(AccessEntry::from_item(&item, "direct"))
}
pub async fn wiki_regrant(
&self,
page: i64,
access: &str,
body: &serde_json::Value,
allow_selflock: bool,
) -> Result<AccessEntry, ApiError> {
let url = format!(
"{}/v1/pages/{page}/access/{}{}",
self.wiki_url,
encode(access),
query(&[(!allow_selflock).then_some("prevent_selflock=true")])
);
let item: serde_json::Value = self
.wiki_write(
reqwest::Method::POST,
&url,
Some(body),
&format!("access {access} on wiki page {page}"),
)
.await?;
Ok(AccessEntry::from_item(&item, "direct"))
}
pub async fn wiki_revoke(
&self,
page: i64,
access: Option<&str>,
allow_selflock: bool,
) -> Result<(), ApiError> {
let one = access.map_or_else(String::new, |access| format!("/{}", encode(access)));
let url = format!(
"{}/v1/pages/{page}/access{one}{}",
self.wiki_url,
query(&[(!allow_selflock).then_some("prevent_selflock=true")])
);
let _: serde_json::Value = self
.wiki_write(
reqwest::Method::DELETE,
&url,
None,
&format!("access to wiki page {page}"),
)
.await?;
Ok(())
}
pub async fn wiki_clone_page(
&self,
page: i64,
body: &serde_json::Value,
) -> Result<WikiOperation, ApiError> {
let url = format!("{}/v1/pages/{page}/clone", self.wiki_url);
self.wiki_started(&url, body, &format!("wiki page {page}"))
.await
}
pub async fn wiki_clone_grid(
&self,
grid: &str,
body: &serde_json::Value,
) -> Result<WikiOperation, ApiError> {
let url = format!("{}/v1/grids/{}/clone", self.wiki_url, encode(grid));
self.wiki_started(&url, body, &format!("wiki grid `{grid}`"))
.await
}
async fn wiki_started(
&self,
url: &str,
body: &serde_json::Value,
what: &str,
) -> Result<WikiOperation, ApiError> {
#[derive(Deserialize)]
struct Started {
operation: WikiOperation,
}
let started: Started = self
.wiki_write(reqwest::Method::POST, url, Some(body), what)
.await?;
Ok(started.operation)
}
pub async fn wiki_operation(
&self,
operation: &WikiOperation,
) -> Result<OperationStatus, ApiError> {
let url = format!(
"{}/v1/operations/{}/{}",
self.wiki_url,
encode(&operation.kind),
encode(&operation.id)
);
let (value, _) = self
.send_url(
reqwest::Method::GET,
&url,
None,
&format!("operation {}/{}", operation.kind, operation.id),
)
.await
.map_err(refused)?;
let progress = value.get("progress");
Ok(OperationStatus {
status: scalar(value.get("status")).unwrap_or_default(),
percentage: progress
.and_then(|progress| progress.get("percentage"))
.and_then(serde_json::Value::as_f64),
details: scalar(progress.and_then(|progress| progress.get("details")))
.filter(|details| !details.is_empty()),
result: value
.get("result")
.filter(|result| !result.is_null())
.cloned(),
})
}
pub async fn wiki_grid_create(&self, body: &serde_json::Value) -> Result<WikiGrid, ApiError> {
let url = format!("{}/v1/grids", self.wiki_url);
let answer: GridAnswer = self
.wiki_write(reqwest::Method::POST, &url, Some(body), "the new wiki grid")
.await?;
Ok(WikiGrid::from(answer))
}
pub async fn wiki_grid_write(
&self,
method: reqwest::Method,
grid: &str,
tail: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let url = format!("{}/v1/grids/{}{tail}", self.wiki_url, encode(grid));
self.wiki_write(method, &url, body, &format!("wiki grid `{grid}`"))
.await
}
pub async fn wiki_upload_start(
&self,
name: &str,
size: usize,
) -> Result<UploadSession, ApiError> {
let url = format!("{}/v1/upload_sessions", self.wiki_url);
let body = serde_json::json!({ "file_name": name, "file_size": size });
self.wiki_write(
reqwest::Method::POST,
&url,
Some(&body),
&format!("an upload of {name}"),
)
.await
}
pub async fn wiki_upload_part(
&self,
session: &str,
part: u32,
bytes: Vec<u8>,
) -> Result<(), ApiError> {
let url = format!(
"{}/v1/upload_sessions/{}/upload_part?part_number={part}",
self.wiki_url,
encode(session)
);
let response = self
.http
.put(&url)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.body(bytes)
.send()
.await?;
super::classify(response, &format!("upload session {session}"))
.await
.map(|_| ())
.map_err(write_refused)
}
pub async fn wiki_upload_finish(&self, session: &str) -> Result<(), ApiError> {
let url = format!(
"{}/v1/upload_sessions/{}/finish",
self.wiki_url,
encode(session)
);
let _: serde_json::Value = self
.wiki_write(
reqwest::Method::POST,
&url,
None,
&format!("upload session {session}"),
)
.await?;
Ok(())
}
pub async fn wiki_upload_abort(&self, session: &str) -> Result<(), ApiError> {
let url = format!(
"{}/v1/upload_sessions/{}/abort",
self.wiki_url,
encode(session)
);
let _: serde_json::Value = self
.wiki_write(
reqwest::Method::POST,
&url,
None,
&format!("upload session {session}"),
)
.await?;
Ok(())
}
pub async fn wiki_attach(
&self,
page: i64,
sessions: &[String],
) -> Result<Vec<WikiAttachment>, ApiError> {
#[derive(Deserialize)]
struct Attached {
#[serde(default)]
results: Vec<AttachmentAnswer>,
}
let url = format!("{}/v1/pages/{page}/attachments", self.wiki_url);
let body = serde_json::json!({ "upload_sessions": sessions });
let attached: Attached = self
.wiki_write(
reqwest::Method::POST,
&url,
Some(&body),
&format!("wiki page {page}"),
)
.await?;
Ok(attached
.results
.into_iter()
.map(WikiAttachment::from)
.collect())
}
pub async fn wiki_delete_attachment(&self, page: i64, file: u64) -> Result<(), ApiError> {
let url = format!("{}/v1/pages/{page}/attachments/{file}", self.wiki_url);
let _: serde_json::Value = self
.wiki_write(
reqwest::Method::DELETE,
&url,
None,
&format!("attachment {file}"),
)
.await?;
Ok(())
}
pub async fn wiki_restore(&self, token: &str) -> Result<WikiRestored, ApiError> {
let url = format!(
"{}/v1/recovery_tokens/{}/recover",
self.wiki_url,
encode(token)
);
self.wiki_write(
reqwest::Method::POST,
&url,
Some(&serde_json::json!({})),
&format!("recovery token `{token}`"),
)
.await
}
async fn wiki_write<T: serde::de::DeserializeOwned>(
&self,
method: reqwest::Method,
url: &str,
body: Option<&serde_json::Value>,
what: &str,
) -> Result<T, ApiError> {
let (value, _) =
self.send_url(method, url, body, what)
.await
.map_err(|error| match error {
ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiWriteForbidden,
other => other,
})?;
serde_json::from_value(value).map_err(ApiError::Decode)
}
}
fn query(parts: &[Option<&str>]) -> String {
let present: Vec<&str> = parts.iter().flatten().copied().collect();
if present.is_empty() {
String::new()
} else {
format!("?{}", present.join("&"))
}
}
fn refused(error: ApiError) -> ApiError {
match error {
ApiError::Forbidden | ApiError::Unauthorized => ApiError::WikiForbidden,
other => other,
}
}
#[must_use]
pub fn slug_of(target: &str) -> String {
let path = match target.split_once("://") {
Some((_, rest)) => rest.split_once('/').map_or("", |(_, path)| path),
None => target,
};
decode(
path.split(['?', '#'])
.next()
.unwrap_or_default()
.trim_matches('/'),
)
}
fn decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| std::str::from_utf8(pair).ok())
.and_then(|pair| u8::from_str_radix(pair, 16).ok());
match (bytes[at], hex) {
(b'%', Some(byte)) => {
decoded.push(byte);
at += 3;
}
(byte, _) => {
decoded.push(byte);
at += 1;
}
}
}
String::from_utf8(decoded).unwrap_or_else(|_| text.to_owned())
}
fn encode(text: &str) -> String {
use std::fmt::Write as _;
let mut encoded = String::with_capacity(text.len());
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_upload_is_cut_into_parts_the_wiki_accepts() {
let one = |range: std::ops::Range<usize>| std::iter::once(range).collect::<Vec<_>>();
assert_eq!(upload_parts(5, UPLOAD_PART), one(0..5));
assert_eq!(upload_parts(0, UPLOAD_PART), one(0..0));
assert_eq!(upload_parts(10, 4), vec![0..4, 4..8, 8..10]);
assert_eq!(upload_parts(8, 4), vec![0..4, 4..8]);
}
#[test]
fn a_slug_is_taken_as_it_is() {
assert_eq!(
slug_of("users/ilubenets/runbook"),
"users/ilubenets/runbook"
);
}
#[test]
fn an_address_is_reduced_to_its_slug() {
assert_eq!(
slug_of("https://wiki.yandex.ru/users/ilubenets/runbook/?from=search#deploy"),
"users/ilubenets/runbook"
);
}
#[test]
fn a_copied_address_is_decoded() {
assert_eq!(
slug_of(
"https://wiki.yandex.ru/users/%D1%8F%D0%BD/%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8/"
),
"users/ян/заметки"
);
assert_eq!(slug_of("users/100%/x"), "users/100%/x");
assert_eq!(slug_of("users/%FF"), "users/%FF");
}
#[test]
fn a_bare_host_names_no_page() {
assert_eq!(slug_of("https://wiki.yandex.ru/"), "");
assert_eq!(slug_of("https://wiki.yandex.ru"), "");
}
#[test]
fn a_slug_survives_being_put_in_a_query() {
assert_eq!(
encode("users/ян/заметки"),
"users%2F%D1%8F%D0%BD%2F%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8"
);
}
}