use std::sync::Arc;
#[cfg(feature = "grpc")]
use crate::grpc_util::{grpc_status, with_token_request};
#[cfg(feature = "grpc")]
use anytype_rpc::anytype::rpc::object::share_by_link;
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::{Number, Value};
use snafu::prelude::*;
#[cfg(feature = "grpc")]
use tonic::Request;
use crate::{
Result,
client::AnytypeClient,
filters::{Query, QueryWithFilters},
http_client::{GetPaged, HttpClient},
prelude::*,
verify::{VerifyConfig, VerifyPolicy, resolve_verify, verify_available},
};
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,
}
#[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)]
pub struct ObjectRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
object_id: String,
}
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(),
}
}
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: ObjectResponse = self
.client
.get_request(
&format!("/v1/spaces/{}/objects/{}", self.space_id, self.object_id),
QueryWithFilters::default(),
)
.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_request(&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_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(
&format!("/v1/spaces/{}/objects/{}", self.space_id, object.id),
QueryWithFilters::default(),
)
.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_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(
&format!("/v1/spaces/{}/objects/{}", self.space_id, object.id),
QueryWithFilters::default(),
)
.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)
.add_filters(&self.filters);
self.client
.get_request_paged(&format!("/v1/spaces/{}/objects", self.space_id), query)
.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)
}
#[cfg(feature = "grpc")]
pub async fn get_share_link(&self, object_id: impl AsRef<str>) -> Result<String> {
let object_id = object_id.as_ref();
self.config.limits.validate_id(object_id, "object_id")?;
let grpc = self.grpc_client().await?;
let mut commands = grpc.client_commands();
let request = share_by_link::Request {
object_id: object_id.to_string(),
};
let request = with_token_request(Request::new(request), grpc.token())?;
let response = commands
.object_share_by_link(request)
.await
.map_err(grpc_status)?
.into_inner();
if let Some(error) = response.error
&& error.code != 0
{
return Err(AnytypeError::Other {
message: format!(
"grpc share by link failed: {} (code {})",
error.description, error.code
),
});
}
Ok(response.link)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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\""));
}
}