use std::{borrow::Cow, sync::Arc};
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::{Number, Value};
use snafu::prelude::*;
use crate::{
Result,
client::AnytypeClient,
filters::{Query, QueryWithFilters},
http_client::{GetPaged, HttpClient},
prelude::*,
verify::{VerifyConfig, VerifyPolicy, resolve_verify, verify_available},
};
pub const ANYTYPE_PLAIN_MARKDOWN_SUFFIX: &str = " \n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlainMarkdownRepresentation<'a> {
wire: Cow<'a, str>,
canonical: Cow<'a, str>,
}
impl PlainMarkdownRepresentation<'_> {
#[must_use]
pub fn wire(&self) -> &str {
self.wire.as_ref()
}
#[must_use]
pub fn canonical(&self) -> &str {
self.canonical.as_ref()
}
}
#[must_use]
pub fn plain_markdown_representation(value: &str) -> Option<PlainMarkdownRepresentation<'_>> {
if value.is_empty() {
return Some(PlainMarkdownRepresentation {
wire: Cow::Borrowed(value),
canonical: Cow::Borrowed(value),
});
}
if let Some(core) = value.strip_suffix(ANYTYPE_PLAIN_MARKDOWN_SUFFIX)
&& let Some(wire) = canonical_plain_core_to_write(core)
{
return Some(PlainMarkdownRepresentation {
wire: Cow::Owned(wire),
canonical: Cow::Borrowed(value),
});
}
if value.starts_with(' ') || value.ends_with(' ') {
return None;
}
let mut canonical = String::with_capacity(
value
.len()
.saturating_add(ANYTYPE_PLAIN_MARKDOWN_SUFFIX.len()),
);
for character in value.chars() {
match character {
' ' => canonical.push(character),
'_' => canonical.push_str(r"\_"),
character if character.is_alphanumeric() => canonical.push(character),
_ => return None,
}
}
canonical.push_str(ANYTYPE_PLAIN_MARKDOWN_SUFFIX);
Some(PlainMarkdownRepresentation {
wire: Cow::Borrowed(value),
canonical: Cow::Owned(canonical),
})
}
fn canonical_plain_core_to_write(core: &str) -> Option<String> {
if core.is_empty() || core.starts_with(' ') || core.ends_with(' ') {
return None;
}
let mut write = String::with_capacity(core.len());
let mut characters = core.chars();
while let Some(character) = characters.next() {
match character {
' ' => write.push(character),
'\\' if characters.next() == Some('_') => write.push('_'),
character if character.is_alphanumeric() => write.push(character),
_ => return None,
}
}
Some(write)
}
pub fn object_link(space_id: &str, object_id: &str) -> String {
format!("https://object.any.coop/{object_id}?spaceId={space_id}")
}
pub fn object_link_shared(space_id: &str, object_id: &str, cid: &str, key: &str) -> String {
format!("https://object.any.coop/{object_id}?spaceId={space_id}&inviteId={cid}#{key}")
}
#[derive(
Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, strum::Display, strum::EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ObjectLayout {
#[default]
Basic,
Profile,
Action,
Note,
Bookmark,
Set,
Collection,
Participant,
Chat,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq, strum::Display, Default, Serialize)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DataModel {
Chat, Error,
Member,
#[default]
Object,
Property,
Space,
Tag,
Type,
}
#[derive(
Debug, Serialize, Deserialize, Clone, PartialEq, strum::Display, Eq, strum::EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Color {
Grey,
Yellow,
Orange,
Red,
Pink,
Purple,
Blue,
Ice,
Teal,
Lime,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "format", rename_all = "lowercase")]
pub enum Icon {
Emoji {
emoji: String,
},
File {
file: String,
},
Icon {
color: Color,
name: String,
},
}
impl Icon {
pub fn as_emoji(&self) -> Option<&str> {
if let Self::Emoji { emoji } = self {
Some(emoji.as_str())
} else {
None
}
}
pub fn as_file(&self) -> Option<&str> {
if let Self::File { file } = self {
Some(file.as_str())
} else {
None
}
}
pub fn as_icon(&self) -> Option<(&str, Color)> {
if let Self::Icon { name, color } = self {
Some((name.as_str(), color.clone()))
} else {
None
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Object {
pub archived: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<Icon>,
pub id: String,
#[serde(default)]
pub layout: ObjectLayout,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub markdown: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub object: DataModel,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<PropertyWithValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
pub space_id: String,
#[serde(rename = "type")]
pub r#type: Option<Type>,
}
impl Object {
pub fn get_type(&self) -> Option<Type> {
self.r#type.clone()
}
pub fn get_property(&self, key: &str) -> Option<&PropertyWithValue> {
self.properties.iter().find(|prop| prop.key == key)
}
pub fn get_property_str<'a>(&'a self, key: &str) -> Option<&'a str> {
self.get_property(key).and_then(|prop| prop.value.as_str())
}
pub fn get_property_number<'a>(&'a self, key: &str) -> Option<&'a Number> {
self.get_property(key)
.and_then(|prop| prop.value.as_number())
}
pub fn get_property_date(&self, key: &str) -> Option<DateTime<FixedOffset>> {
self.get_property(key).and_then(|prop| prop.value.as_date())
}
pub fn get_property_f64(&self, key: &str) -> Option<f64> {
self.get_property_number(key).and_then(Number::as_f64)
}
pub fn get_property_u64(&self, key: &str) -> Option<u64> {
self.get_property_number(key).and_then(Number::as_u64)
}
pub fn get_property_i64(&self, key: &str) -> Option<i64> {
self.get_property_number(key).and_then(Number::as_i64)
}
pub fn get_property_bool(&self, key: &str) -> Option<bool> {
self.get_property(key).and_then(|prop| prop.value.as_bool())
}
pub fn get_property_array(&self, key: &str) -> Option<Vec<String>> {
self.get_property(key)
.and_then(|prop| prop.value.as_array())
}
pub fn get_property_empty(&self, key: &str) -> Option<()> {
self.get_property(key).map(|_| ())
}
pub fn get_property_select(&self, key: &str) -> Option<&Tag> {
self.get_property(key).and_then(|prop| prop.value.as_tag())
}
pub fn get_property_multi_select(&self, key: &str) -> Option<&[Tag]> {
self.get_property(key).and_then(|prop| prop.value.as_tags())
}
pub fn get_link(&self) -> String {
object_link(&self.space_id, &self.id)
}
pub fn get_link_shared(&self, cid: &str, key: &str) -> Result<String> {
ensure!(
!cid.is_empty() && !key.is_empty(),
ValidationSnafu {
message: "Invalid share link".to_string()
}
);
Ok(object_link_shared(&self.space_id, &self.id, cid, key))
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct ObjectResponse {
pub object: Object,
}
#[derive(Debug, Serialize)]
struct CreateObjectRequestBody {
type_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<Icon>,
#[serde(skip_serializing_if = "Option::is_none")]
template_id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
properties: Vec<Value>,
}
#[derive(Debug, Serialize, Default)]
struct UpdateObjectRequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
markdown: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<Icon>,
#[serde(skip_serializing_if = "Option::is_none")]
type_key: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
properties: Vec<Value>,
}
#[derive(Debug, Serialize)]
struct FilteredObjectsRequestBody {
#[serde(skip_serializing_if = "Vec::is_empty")]
types: Vec<String>,
filters: FilterExpression,
}
fn split_single_type_filter(filters: Vec<Filter>) -> (Vec<String>, Vec<Filter>) {
let type_filter_count = filters
.iter()
.filter(|filter| {
matches!(
filter,
Filter::Select {
condition: Condition::In,
property_key,
..
} if property_key == "type"
)
})
.count();
if type_filter_count != 1 {
return (Vec::new(), filters);
}
let mut types = Vec::new();
let mut remaining = Vec::with_capacity(filters.len().saturating_sub(1));
for filter in filters {
match filter {
Filter::Select {
condition: Condition::In,
property_key,
select,
} if property_key == "type" => types = select,
filter => remaining.push(filter),
}
}
(types, remaining)
}
#[derive(Debug)]
pub struct ObjectRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
object_id: String,
response_limit_bytes: Option<u64>,
}
impl ObjectRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
object_id: impl Into<String>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
object_id: object_id.into(),
response_limit_bytes: None,
}
}
#[must_use]
pub const fn response_limit_bytes(mut self, response_limit_bytes: u64) -> Self {
self.response_limit_bytes = Some(response_limit_bytes);
self
}
pub async fn get(self) -> Result<Object> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.object_id, "object_id")?;
let response_limit = self
.response_limit_bytes
.unwrap_or_else(|| self.client.document_response_limit());
let response: ObjectResponse = self
.client
.get_request_with_limit(
&format!("/v1/spaces/{}/objects/{}", self.space_id, self.object_id),
QueryWithFilters::default(),
response_limit,
)
.await?;
Ok(response.object)
}
pub async fn delete(self) -> Result<Object> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.object_id, "object_id")?;
let response: ObjectResponse = self
.client
.delete_document_request(&format!(
"/v1/spaces/{}/objects/{}",
self.space_id, self.object_id
))
.await?;
Ok(response.object)
}
pub async fn delete_once(self) -> Result<Object> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.object_id, "object_id")?;
let response: ObjectResponse = self
.client
.delete_document_request_once(&format!(
"/v1/spaces/{}/objects/{}",
self.space_id, self.object_id
))
.await?;
Ok(response.object)
}
}
#[derive(Debug)]
pub struct NewObjectRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
type_key: String,
name: Option<String>,
body: Option<String>,
icon: Option<Icon>,
template_id: Option<String>,
properties: Vec<Value>,
verify_policy: VerifyPolicy,
verify_config: Option<VerifyConfig>,
}
impl NewObjectRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
type_key: impl Into<String>,
verify_config: Option<VerifyConfig>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
type_key: type_key.into(),
name: None,
body: None,
icon: None,
template_id: None,
properties: Vec::new(),
verify_policy: VerifyPolicy::Default,
verify_config,
}
}
pub fn get_type_key(&self) -> &str {
&self.type_key
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
#[must_use]
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
#[must_use]
pub fn template(mut self, template_id: impl Into<String>) -> Self {
self.template_id = Some(template_id.into());
self
}
#[must_use]
pub fn ensure_available(mut self) -> Self {
self.verify_policy = VerifyPolicy::Enabled;
self
}
#[must_use]
pub fn ensure_available_with(mut self, config: VerifyConfig) -> Self {
self.verify_policy = VerifyPolicy::Enabled;
self.verify_config = Some(config);
self
}
#[must_use]
pub fn no_verify(mut self) -> Self {
self.verify_policy = VerifyPolicy::Disabled;
self
}
#[must_use]
pub fn description(self, description: impl Into<String>) -> Self {
self.set_text("description", description)
}
#[must_use]
pub fn url(self, url: impl Into<String>) -> Self {
self.set_url("url", url)
}
pub async fn create(self) -> Result<Object> {
self.limits.validate_id(&self.space_id, "space_id")?;
if let Some(ref name) = self.name {
self.limits.validate_name(name, "name")?;
}
if let Some(ref body) = self.body {
self.limits.validate_markdown(body, "body")?;
}
let request_body = CreateObjectRequestBody {
type_key: self.type_key,
name: self.name,
body: self.body,
icon: self.icon,
template_id: self.template_id,
properties: self.properties,
};
let response: ObjectResponse = self
.client
.post_document_request(
&format!("/v1/spaces/{}/objects", self.space_id),
&request_body,
QueryWithFilters::default(),
)
.await?;
let object = response.object;
if let Some(config) = resolve_verify(self.verify_policy, self.verify_config.as_ref()) {
return verify_available(&config, "Object", &object.id, || async {
let response: ObjectResponse = self
.client
.get_request_with_limit(
&format!("/v1/spaces/{}/objects/{}", self.space_id, object.id),
QueryWithFilters::default(),
self.client.document_response_limit(),
)
.await?;
Ok(response.object)
})
.await;
}
Ok(object)
}
}
impl SetProperty for NewObjectRequest {
fn add_property(mut self, property: Value) -> Self {
self.properties.push(property);
self
}
}
#[derive(Debug)]
pub struct UpdateObjectRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
object_id: String,
name: Option<String>,
body: Option<String>,
icon: Option<Icon>,
type_key: Option<String>,
properties: Vec<Value>,
verify_policy: VerifyPolicy,
verify_config: Option<VerifyConfig>,
}
impl UpdateObjectRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
object_id: impl Into<String>,
verify_config: Option<VerifyConfig>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
object_id: object_id.into(),
name: None,
body: None,
icon: None,
type_key: None,
properties: Vec::new(),
verify_policy: VerifyPolicy::Default,
verify_config,
}
}
pub fn get_type_key(&self) -> Option<String> {
self.type_key.clone()
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
#[must_use]
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
#[must_use]
pub fn type_key(mut self, type_key: impl Into<String>) -> Self {
self.type_key = Some(type_key.into());
self
}
#[must_use]
pub fn ensure_available(mut self) -> Self {
self.verify_policy = VerifyPolicy::Enabled;
self
}
#[must_use]
pub fn ensure_available_with(mut self, config: VerifyConfig) -> Self {
self.verify_policy = VerifyPolicy::Enabled;
self.verify_config = Some(config);
self
}
#[must_use]
pub fn no_verify(mut self) -> Self {
self.verify_policy = VerifyPolicy::Disabled;
self
}
pub async fn update(self) -> Result<Object> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.object_id, "object_id")?;
ensure!(
self.name.is_some()
|| self.body.is_some()
|| self.icon.is_some()
|| self.type_key.is_some()
|| !self.properties.is_empty(),
ValidationSnafu {
message: "update_object: must set at least one field to update".to_string(),
}
);
if let Some(ref name) = self.name {
self.limits.validate_name(name, "name")?;
}
if let Some(ref body) = self.body {
self.limits.validate_markdown(body, "body")?;
}
let request_body = UpdateObjectRequestBody {
name: self.name,
markdown: self.body,
icon: self.icon,
type_key: self.type_key,
properties: self.properties,
};
let response: ObjectResponse = self
.client
.patch_document_request(
&format!("/v1/spaces/{}/objects/{}", self.space_id, self.object_id),
&request_body,
)
.await?;
let object = response.object;
if let Some(config) = resolve_verify(self.verify_policy, self.verify_config.as_ref()) {
return verify_available(&config, "Object", &object.id, || async {
let response: ObjectResponse = self
.client
.get_request_with_limit(
&format!("/v1/spaces/{}/objects/{}", self.space_id, object.id),
QueryWithFilters::default(),
self.client.document_response_limit(),
)
.await?;
Ok(response.object)
})
.await;
}
Ok(object)
}
}
impl SetProperty for UpdateObjectRequest {
fn add_property(mut self, property: Value) -> Self {
self.properties.push(property);
self
}
}
#[derive(Debug)]
pub struct ListObjectsRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
limit: Option<u32>,
offset: Option<u32>,
filters: Vec<Filter>,
}
impl ListObjectsRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
limit: None,
offset: None,
filters: Vec::new(),
}
}
#[must_use]
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn offset(mut self, offset: u32) -> Self {
self.offset = Some(offset);
self
}
#[must_use]
pub fn filter(mut self, filter: Filter) -> Self {
self.filters.push(filter);
self
}
#[must_use]
pub fn filters(mut self, filters: impl IntoIterator<Item = Filter>) -> Self {
self.filters.extend(filters);
self
}
pub async fn list(self) -> Result<PagedResult<Object>> {
self.limits.validate_id(&self.space_id, "space_id")?;
let query = Query::default()
.set_limit_opt(self.limit)
.set_offset_opt(self.offset);
let requires_typed_body = self.filters.iter().any(Filter::requires_typed_query_body);
if !requires_typed_body {
return self
.client
.get_request_paged(
&format!("/v1/spaces/{}/objects", self.space_id),
query.add_filters(&self.filters),
)
.await;
}
QueryWithFilters::from(self.filters.as_slice()).validate()?;
let (types, filters) = split_single_type_filter(self.filters);
let body = FilteredObjectsRequestBody {
types,
filters: FilterExpression::from(filters),
};
self.client
.post_request_paged(
&format!("/v1/spaces/{}/search", self.space_id),
&body,
query.add_filters(&[]),
)
.await
}
}
impl AnytypeClient {
pub fn object(
&self,
space_id: impl Into<String>,
object_id: impl Into<String>,
) -> ObjectRequest {
ObjectRequest::new(
self.client.clone(),
self.config.limits.clone(),
space_id,
object_id,
)
}
pub fn new_object(
&self,
space_id: impl Into<String>,
type_key: impl Into<String>,
) -> NewObjectRequest {
NewObjectRequest::new(
self.client.clone(),
self.config.limits.clone(),
space_id,
type_key,
self.config.verify.clone(),
)
}
pub fn update_object(
&self,
space_id: impl Into<String>,
object_id: impl Into<String>,
) -> UpdateObjectRequest {
UpdateObjectRequest::new(
self.client.clone(),
self.config.limits.clone(),
space_id,
object_id,
self.config.verify.clone(),
)
}
pub fn objects(&self, space_id: impl Into<String>) -> ListObjectsRequest {
ListObjectsRequest::new(self.client.clone(), self.config.limits.clone(), space_id)
}
pub fn get_share_link(
&self,
space_id: impl AsRef<str>,
object_id: impl AsRef<str>,
) -> Result<String> {
let space_id = space_id.as_ref();
let object_id = object_id.as_ref();
self.config.limits.validate_id(space_id, "space_id")?;
self.config.limits.validate_id(object_id, "object_id")?;
Ok(object_link(space_id, object_id))
}
}
#[cfg(test)]
mod tests {
use crate::test_util::scripted_http::{
ScriptedHttpContentType, ScriptedHttpFixture, ScriptedHttpResponse,
};
use super::*;
const EMPTY_PAGE: &str =
r#"{"data":[],"pagination":{"has_more":false,"limit":25,"offset":3,"total":0}}"#;
const SPACE_ID: &str = "bafyreiafl45wf5eaxiby44pxrkhia3y5jsyix3ov2jzqiftsxjotujqlh4";
async fn scripted_objects_client() -> (AnytypeClient, ScriptedHttpFixture) {
let fixture = ScriptedHttpFixture::start(vec![ScriptedHttpResponse::new(
reqwest::StatusCode::OK,
ScriptedHttpContentType::Json,
EMPTY_PAGE,
)])
.await
.expect("start scripted objects fixture");
let mut config = ClientConfig::default().app_name("objects-wire-shape");
config.base_url = Some(format!("http://{}", fixture.address()));
config.keystore = Some("env".to_owned());
let client = AnytypeClient::with_config(config).expect("create scripted objects client");
client.set_api_key(HttpCredentials::new("fixture-token"));
(client, fixture)
}
#[test]
fn share_link_is_constructed_locally_with_typed_validation() {
let mut config = ClientConfig::default().app_name("object-share-link");
config.keystore = Some("env".to_owned());
let client = AnytypeClient::with_config(config).expect("create share-link client");
let object_id = "bafyreifp4ycq5htv6x6c3vpx3xgmek5jxpz7o4qjmslvrk6jhnly3jx5he";
assert_eq!(
client
.get_share_link(SPACE_ID, object_id)
.expect("construct validated share link"),
format!("https://object.any.coop/{object_id}?spaceId={SPACE_ID}")
);
assert!(matches!(
client.get_share_link("", object_id),
Err(AnytypeError::Validation { .. })
));
assert!(matches!(
client.get_share_link(SPACE_ID, ""),
Err(AnytypeError::Validation { .. })
));
}
#[tokio::test]
async fn filtered_object_list_uses_scoped_search_with_typed_and_expression() {
let (client, fixture) = scripted_objects_client().await;
client
.objects(SPACE_ID)
.filter(Filter::type_in(["task"]))
.filter(Filter::number_greater("priority", 2))
.filter(Filter::checkbox_equal("done", true))
.limit(25)
.offset(3)
.list()
.await
.expect("filtered object list");
let requests = fixture.finish().await.expect("filtered object request");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method(), "POST");
assert_eq!(
requests[0].path(),
&format!("/v1/spaces/{SPACE_ID}/search?limit=25&offset=3")
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(requests[0].body())
.expect("filtered object body is JSON"),
serde_json::json!({
"types": ["task"],
"filters": {
"conditions": [
{"condition": "gt", "property_key": "priority", "number": 2},
{"condition": "eq", "property_key": "done", "checkbox": true}
],
"operator": "and"
}
})
);
}
#[tokio::test]
async fn non_typed_object_list_filters_keep_the_object_list_endpoint() {
let (client, fixture) = scripted_objects_client().await;
client
.objects(SPACE_ID)
.filter(Filter::text_contains("name", "draft"))
.limit(25)
.offset(3)
.list()
.await
.expect("non-typed filtered object list");
let requests = fixture
.finish()
.await
.expect("non-typed filtered object request");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method(), "GET");
assert_eq!(
requests[0].path(),
&format!("/v1/spaces/{SPACE_ID}/objects?name%5Bcontains%5D=draft&limit=25&offset=3")
);
assert!(requests[0].body().is_empty());
}
#[test]
fn plain_markdown_representation_is_closed_and_idempotent() {
for (input, wire, canonical) in [
("", "", ""),
(
"alpha stable body",
"alpha stable body",
"alpha stable body \n",
),
("alpha suffix_0", "alpha suffix_0", "alpha suffix\\_0 \n"),
(
"alpha stable body \n",
"alpha stable body",
"alpha stable body \n",
),
(
"alpha suffix\\_0 \n",
"alpha suffix_0",
"alpha suffix\\_0 \n",
),
("alpha café body", "alpha café body", "alpha café body \n"),
] {
let representation = plain_markdown_representation(input).expect("supported form");
assert_eq!(representation.wire(), wire);
assert_eq!(representation.canonical(), canonical);
let replay = plain_markdown_representation(representation.canonical())
.expect("canonical form is replayable");
assert_eq!(replay.wire(), wire);
assert_eq!(replay.canonical(), canonical);
}
for ambiguous in [
" alpha",
"alpha ",
"alpha ",
"alpha\n",
"alpha \n",
"under\\_score",
"under\\\\_score \n",
"\\*escaped\\*",
"# Heading",
"line one\nline two",
"plain.",
] {
assert!(
plain_markdown_representation(ambiguous).is_none(),
"ambiguous form must stay out of the special path: {ambiguous:?}"
);
}
}
#[test]
fn test_object_layout_default() {
let layout: ObjectLayout = ObjectLayout::default();
assert_eq!(layout, ObjectLayout::Basic);
}
#[test]
fn test_object_layout_display() {
assert_eq!(ObjectLayout::Basic.to_string(), "basic");
assert_eq!(ObjectLayout::Note.to_string(), "note");
assert_eq!(ObjectLayout::Bookmark.to_string(), "bookmark");
}
#[test]
fn test_object_layout_from_string() {
use std::str::FromStr;
assert_eq!(
ObjectLayout::from_str("basic").unwrap(),
ObjectLayout::Basic
);
assert_eq!(ObjectLayout::from_str("note").unwrap(), ObjectLayout::Note);
}
#[test]
fn test_create_object_request_body_serialization() {
let body = CreateObjectRequestBody {
type_key: "page".to_string(),
name: Some("Test".to_string()),
body: None,
icon: None,
template_id: None,
properties: vec![],
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"type_key\":\"page\""));
assert!(json.contains("\"name\":\"Test\""));
assert!(!json.contains("\"body\""));
}
#[test]
fn test_update_object_request_body_empty_fields_skipped() {
let body = UpdateObjectRequestBody::default();
let json = serde_json::to_string(&body).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn test_update_object_request_body_with_values() {
let body = UpdateObjectRequestBody {
name: Some("Updated".to_string()),
markdown: Some("# Content".to_string()),
icon: None,
type_key: None,
properties: vec![],
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"name\":\"Updated\""));
assert!(json.contains("\"markdown\":\"# Content\""));
}
}