use std::sync::Arc;
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Number, Value, json};
use snafu::prelude::*;
use tracing::error;
use crate::resolve::{MAX_RESOLVE_SCAN_ITEMS, RESOLVE_PAGE_SIZE, resolution_limit};
use crate::{
Result,
cache::AnytypeCache,
client::AnytypeClient,
error::OtherSnafu,
filters::{Query, QueryWithFilters},
http_client::{GetPaged, HttpClient},
prelude::*,
tags::{CreateTagRequest, ListTagsRequest},
validation::looks_like_object_id,
verify::{VerifyConfig, VerifyPolicy, resolve_verify, verify_available},
};
#[derive(
Debug,
Default,
Copy,
Serialize,
Deserialize,
Clone,
Eq,
PartialEq,
strum::Display,
strum::EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum PropertyFormat {
#[default]
Text,
Number,
Select,
MultiSelect,
Date,
Files,
Checkbox,
Url,
Email,
Phone,
Objects,
}
#[derive(Debug, Deserialize, Clone, Serialize)]
pub struct Property {
#[serde(default = "property_data_model")]
pub object: DataModel,
pub name: String,
pub key: String,
pub id: String,
format: PropertyFormat,
tags: Option<Vec<Tag>>,
}
fn property_data_model() -> DataModel {
DataModel::Property
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PropertyWithValue {
pub name: String,
pub key: String,
pub id: String,
#[serde(flatten)]
pub value: PropertyValue,
}
impl PropertyWithValue {
pub fn format(&self) -> PropertyFormat {
self.value.format()
}
}
impl Property {
pub fn new_from(other: &PropertyWithValue) -> Self {
Self {
object: DataModel::Property,
format: other.format(),
id: other.id.clone(),
key: other.key.clone(),
name: other.name.clone(),
tags: None,
}
}
pub fn format(&self) -> PropertyFormat {
self.format
}
pub fn tags(&self) -> Option<&[Tag]> {
self.tags.as_deref()
}
pub fn lookup_tag(&self, value: impl AsRef<str>) -> Result<Tag> {
let check = value.as_ref().to_lowercase();
self.tags()
.and_then(|tags| {
tags.iter()
.find(|tag| {
tag.id == check || tag.name.to_lowercase() == check || tag.key == check
})
.cloned()
})
.map_or_else(
|| {
Err(AnytypeError::NotFound {
obj_type: "Tag".into(),
key: value.as_ref().to_string(),
})
},
Ok,
)
}
pub fn tag_by_id(&self, tag_id: impl AsRef<str>) -> Option<&Tag> {
let id = tag_id.as_ref();
if !looks_like_object_id(id) {
return None;
}
self.tags()
.and_then(|tags| tags.iter().find(|tag| tag.id == id))
}
pub fn tag_by_key(&self, tag_key: impl AsRef<str>) -> Option<&Tag> {
let key = tag_key.as_ref();
self.tags()
.and_then(|tags| tags.iter().find(|tag| tag.key == key))
}
pub fn tag_by_name(&self, tag_name: impl AsRef<str>) -> Option<&Tag> {
let name = tag_name.as_ref();
self.tags()
.and_then(|tags| tags.iter().find(|tag| tag.name == name))
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "format", rename_all = "snake_case")]
pub enum PropertyValue {
Text { text: String },
Number { number: Number },
Select { select: Tag },
MultiSelect {
#[serde(default, deserialize_with = "deserialize_vec_tag_or_null")]
multi_select: Vec<Tag>,
},
Date { date: String },
Files {
#[serde(default, deserialize_with = "deserialize_vec_string_or_null")]
files: Vec<String>,
},
Checkbox { checkbox: bool },
Url { url: String },
Email { email: String },
Phone { phone: String },
Objects {
#[serde(default, deserialize_with = "deserialize_vec_string_or_null")]
objects: Vec<String>,
},
}
fn deserialize_vec_string_or_null<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Vec<String>>::deserialize(deserializer)?;
Ok(value.unwrap_or_default())
}
fn deserialize_vec_tag_or_null<'de, D>(deserializer: D) -> Result<Vec<Tag>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Vec<Tag>>::deserialize(deserializer)?;
Ok(value.unwrap_or_default())
}
impl PropertyValue {
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text.as_str()),
Self::Select { select } => Some(&select.key),
Self::Date { date } => Some(date.as_str()),
Self::Url { url } => Some(url.as_str()),
Self::Email { email } => Some(email.as_str()),
Self::Phone { phone } => Some(phone.as_str()),
Self::Checkbox { checkbox } => Some(if *checkbox { "true" } else { "false" }),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Checkbox { checkbox } => Some(*checkbox),
_ => None,
}
}
pub fn as_number(&self) -> Option<&Number> {
match self {
Self::Number { number } => Some(number),
_ => None,
}
}
pub fn as_date(&self) -> Option<DateTime<FixedOffset>> {
match self {
Self::Date { date } => match DateTime::parse_from_rfc3339(date) {
Err(err) => {
error!(?err, "Date property has invalid format \"{date}\"");
None
}
Ok(date) => Some(date),
},
_ => None,
}
}
pub fn as_array(&self) -> Option<Vec<String>> {
match self {
Self::Files { files } => Some(files.clone()),
Self::MultiSelect { multi_select } => {
Some(multi_select.iter().map(|tag| tag.key.clone()).collect())
}
Self::Objects { objects } => Some(objects.clone()),
_ => None,
}
}
pub fn as_tag(&self) -> Option<&Tag> {
match self {
Self::Select { select } => Some(select),
_ => None,
}
}
pub fn as_tags(&self) -> Option<&[Tag]> {
match self {
Self::MultiSelect { multi_select } => Some(multi_select),
_ => None,
}
}
pub fn format(&self) -> PropertyFormat {
match self {
Self::Text { .. } => PropertyFormat::Text,
Self::Number { .. } => PropertyFormat::Number,
Self::Select { .. } => PropertyFormat::Select,
Self::MultiSelect { .. } => PropertyFormat::MultiSelect,
Self::Date { .. } => PropertyFormat::Date,
Self::Files { .. } => PropertyFormat::Files,
Self::Checkbox { .. } => PropertyFormat::Checkbox,
Self::Url { .. } => PropertyFormat::Url,
Self::Email { .. } => PropertyFormat::Email,
Self::Phone { .. } => PropertyFormat::Phone,
Self::Objects { .. } => PropertyFormat::Objects,
}
}
}
fn try_parse_num(key: &str, value: &str) -> Result<serde_json::Number> {
value.parse::<u64>().map_or_else(
|_| {
value.parse::<i64>().map_or_else(
|_| {
value.parse::<f64>().map_or_else(
|_| {
Err(AnytypeError::Validation {
message: format!("Invalid number for property {key}: {value}"),
})
},
|num| Ok(serde_json::Number::from_f64(num).unwrap()),
)
},
|num| Ok(Number::from(num)),
)
},
|num| Ok(Number::from(num)),
)
}
fn try_tag(prop: &Property, key: &str, value: &str) -> Result<String> {
let value = if looks_like_object_id(value) {
value
} else if let Some(tag) = prop.tag_by_name(value) {
&tag.id
} else if let Some(tag) = prop.tag_by_key(value) {
&tag.id
} else {
return NotFoundSnafu {
obj_type: "Tag".to_string(),
key: format!("property {key} tag: {value}"),
}
.fail();
};
Ok(value.to_string())
}
impl AnytypeClient {
pub async fn set_properties<
K: AsRef<str> + Sync,
V: AsRef<str> + Sync,
SP: SetProperty + Send,
>(
&self,
space_id: &str,
obj: SP,
typ: &Type,
props: &[(K, V)],
) -> Result<SP> {
let mut obj = obj;
for (key, value) in props {
let key = key.as_ref();
let value = value.as_ref();
if let Some(prop) = typ.get_property_by_key(key) {
match prop.format() {
PropertyFormat::Text => {
obj = obj.set_text(key, value);
}
PropertyFormat::Number => {
obj = obj.set_number(key, try_parse_num(key, value)?);
}
PropertyFormat::Select => {
let prop = self.property(space_id, &prop.id).get().await?;
obj = obj.set_select(key, &try_tag(&prop, key, value)?);
}
PropertyFormat::MultiSelect => {
let prop = self.property(space_id, &prop.id).get().await?;
let mut values = Vec::new();
for id_or_tag in value.split(',') {
values.push(try_tag(&prop, key, id_or_tag)?);
}
obj = obj.set_multi_select(key, values);
}
PropertyFormat::Date => {
obj = obj.set_date(key, value);
}
PropertyFormat::Files => {
let files = value.split(',').collect::<Vec<&str>>();
obj = obj.set_files(key, files);
}
PropertyFormat::Checkbox => {
if let Ok(val) = value.parse::<bool>() {
obj = obj.set_checkbox(key, val);
} else {
return ValidationSnafu {
message: format!("Invalid bool value for property {key}: {value}"),
}
.fail();
}
}
PropertyFormat::Url => {
obj = obj.set_url(key, value);
}
PropertyFormat::Email => {
obj = obj.set_email(key, value);
}
PropertyFormat::Phone => {
obj = obj.set_phone(key, value);
}
PropertyFormat::Objects => {
let ids = value.split(',').collect::<Vec<&str>>();
obj = obj.set_objects(key, ids);
}
}
} else {
return ValidationSnafu {
message: format!("invalid property {key} for type {}", typ.key),
}
.fail();
}
}
Ok(obj)
}
}
pub trait SetProperty: Sized {
#[must_use]
fn add_property(self, property: Value) -> Self;
#[must_use]
fn set_text(self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.add_property(json!({
"key": key.into(),
"text": value.into(),
}))
}
#[must_use]
fn set_number(self, key: impl Into<String>, value: impl Into<Number>) -> Self {
self.add_property(json!({
"key": key.into(),
"number": value.into(),
}))
}
#[must_use]
fn set_date(self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.add_property(json!({
"key": key.into(),
"date": value.into(),
}))
}
#[must_use]
fn set_url(self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.add_property(json!({
"key": key.into(),
"url": value.into(),
}))
}
#[must_use]
fn set_email(self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.add_property(json!({
"key": key.into(),
"email": value.into(),
}))
}
#[must_use]
fn set_phone(self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.add_property(json!({
"key": key.into(),
"phone": value.into(),
}))
}
#[must_use]
fn set_checkbox(self, key: impl Into<String>, value: bool) -> Self {
self.add_property(json!({
"key": key.into(),
"checkbox": value,
}))
}
#[must_use]
fn set_select(self, key: impl Into<String>, tag_id: impl Into<String>) -> Self {
let key = key.into();
let tag_id = tag_id.into();
if !looks_like_object_id(&tag_id) {
error!("set_select({key},...): invalid tag id: {tag_id}");
}
self.add_property(json!({
"key": key,
"select": tag_id,
}))
}
#[must_use]
fn set_objects(
self,
key: impl Into<String>,
objects: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.add_property(json!({
"key": key.into(),
"objects": objects.into_iter().map(Into::into).collect::<Vec<String>>(),
}))
}
#[must_use]
fn set_files(
self,
key: impl Into<String>,
files: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.add_property(json!({
"key": key.into(),
"files": files.into_iter().map(Into::into).collect::<Vec<String>>(),
}))
}
#[must_use]
fn set_multi_select(
self,
key: impl Into<String>,
values: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let key = key.into();
let values = values.into_iter().map(Into::into).collect::<Vec<String>>();
for value in &values {
if !looks_like_object_id(value) {
error!("set_multi_select({key}, ...) invalid tag id: {value}");
}
}
self.add_property(json!({
"key": key,
"multi_select": values
}))
}
}
#[derive(Debug, Deserialize)]
struct PropertyResponse {
property: Property,
}
#[derive(Debug, Serialize)]
struct CreatePropertyRequestBody {
name: String,
format: PropertyFormat,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<CreateTagRequest>,
}
#[derive(Debug, Serialize)]
struct UpdatePropertyRequestBody {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
}
#[derive(Debug)]
pub struct PropertyRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
property_id: String,
with_tags: bool,
cache: Arc<AnytypeCache>,
}
pub(crate) async fn set_property_tags(
client: &Arc<HttpClient>,
limits: &ValidationLimits,
space_id: &str,
property: &mut Property,
) -> Result<(), AnytypeError> {
if property.format == PropertyFormat::Select || property.format == PropertyFormat::MultiSelect {
let tags = ListTagsRequest::new(client.clone(), limits.clone(), space_id, &property.id)
.list()
.await?
.collect_all()
.await?;
property.tags = Some(tags);
}
Ok(())
}
async fn prime_cache_properties(
client: &Arc<HttpClient>,
cache: &Arc<AnytypeCache>,
limits: &ValidationLimits,
space_id: &str,
) -> Result<()> {
let mut properties: Vec<Property> = client
.get_request_paged(
&format!("/v1/spaces/{space_id}/properties"),
QueryWithFilters::default(),
)
.await?
.collect_all()
.await?;
for prop in &mut properties {
set_property_tags(client, limits, space_id, prop).await?;
}
cache.set_properties(space_id, properties);
Ok(())
}
impl PropertyRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
property_id: impl Into<String>,
with_tags: bool,
cache: Arc<AnytypeCache>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
property_id: property_id.into(),
with_tags,
cache,
}
}
#[must_use]
pub fn with_tags(mut self) -> Self {
self.with_tags = true;
self
}
pub async fn get(self) -> Result<Property> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.property_id, "property_id")?;
if self.cache.is_enabled() {
if let Some(property) = self.cache.get_property(&self.space_id, &self.property_id) {
return Ok((*property).clone());
}
if !self.cache.has_properties(&self.space_id) {
prime_cache_properties(&self.client, &self.cache, &self.limits, &self.space_id)
.await?;
if let Some(property) = self.cache.get_property(&self.space_id, &self.property_id) {
let mut property = (*property).clone();
if !self.with_tags {
property.tags = None;
}
return Ok(property);
}
}
return NotFoundSnafu {
obj_type: "Property".to_string(),
key: self.property_id,
}
.fail();
}
let mut property = self.fetch_direct_metadata().await?;
if self.with_tags {
set_property_tags(&self.client, &self.limits, &self.space_id, &mut property).await?;
}
Ok(property)
}
pub async fn get_direct(self) -> Result<Property> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.property_id, "property_id")?;
let mut property = self.fetch_direct_metadata().await?;
property.tags = None;
Ok(property)
}
async fn fetch_direct_metadata(&self) -> Result<Property> {
let response: PropertyResponse = self
.client
.get_request(
&format!(
"/v1/spaces/{}/properties/{}",
self.space_id, self.property_id
),
QueryWithFilters::default(),
)
.await?;
let property = response.property;
if property.id != self.property_id {
return OtherSnafu {
message: "Anytype returned a mismatched property identity".to_string(),
}
.fail();
}
Ok(property)
}
pub async fn delete(self) -> Result<Property> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.property_id, "property_id")?;
let response: PropertyResponse = self
.client
.delete_request(&format!(
"/v1/spaces/{}/properties/{}",
self.space_id, self.property_id
))
.await?;
self.cache
.delete_property(&self.space_id, &self.property_id);
Ok(response.property)
}
}
#[derive(Debug)]
pub struct NewPropertyRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
name: String,
format: PropertyFormat,
key: Option<String>,
tags: Vec<CreateTagRequest>,
cache: Arc<AnytypeCache>,
verify_policy: VerifyPolicy,
verify_config: Option<VerifyConfig>,
refresh_cache: bool,
}
impl NewPropertyRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
name: impl Into<String>,
format: PropertyFormat,
cache: Arc<AnytypeCache>,
verify_config: Option<VerifyConfig>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
name: name.into(),
format,
key: None,
tags: Vec::new(),
cache,
verify_policy: VerifyPolicy::Default,
verify_config,
refresh_cache: true,
}
}
#[must_use]
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
#[must_use]
pub fn tag(mut self, name: &str, key: Option<String>, color: Color) -> Self {
self.tags.push(CreateTagRequest {
name: name.into(),
key,
color,
});
self
}
#[must_use]
pub fn tags(mut self, tags: impl IntoIterator<Item = CreateTagRequest>) -> Self {
self.tags.extend(tags);
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 no_cache_refresh(mut self) -> Self {
self.refresh_cache = false;
self
}
pub async fn create(self) -> Result<Property> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_name(&self.name, "property")?;
let create_with_tags = !self.tags.is_empty();
if let Some(ref key) = self.key {
self.limits.validate_name(key, "property key")?;
}
ensure!(
self.tags.is_empty()
|| self.format == PropertyFormat::Select
|| self.format == PropertyFormat::MultiSelect,
ValidationSnafu {
message: format!(
"Property {} format {} cannot be created with tags, because tags are only supported for formats Select and MultiSelect",
self.name, self.format
),
}
);
let request_body = CreatePropertyRequestBody {
name: self.name,
key: self.key,
format: self.format,
tags: self.tags,
};
let response: PropertyResponse = self
.client
.post_request(
&format!("/v1/spaces/{}/properties", self.space_id),
&request_body,
QueryWithFilters::default(),
)
.await?;
if self.refresh_cache && self.cache.has_properties(&self.space_id) {
let mut property = response.property.clone();
if create_with_tags {
set_property_tags(&self.client, &self.limits, &self.space_id, &mut property)
.await?;
}
self.cache.set_property(&self.space_id, property);
} else if !self.refresh_cache {
self.cache.clear_properties(Some(&self.space_id));
}
let property = response.property;
if let Some(config) = resolve_verify(self.verify_policy, self.verify_config.as_ref()) {
return verify_available(&config, "Property", &property.id, || async {
let response: PropertyResponse = self
.client
.get_request(
&format!("/v1/spaces/{}/properties/{}", self.space_id, property.id),
QueryWithFilters::default(),
)
.await?;
Ok(response.property)
})
.await;
}
Ok(property)
}
}
#[derive(Debug)]
pub struct UpdatePropertyRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
property_id: String,
name: Option<String>,
key: Option<String>,
cache: Arc<AnytypeCache>,
verify_policy: VerifyPolicy,
verify_config: Option<VerifyConfig>,
refresh_cache: bool,
}
impl UpdatePropertyRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
property_id: impl Into<String>,
cache: Arc<AnytypeCache>,
verify_config: Option<VerifyConfig>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
property_id: property_id.into(),
name: None,
key: None,
cache,
verify_policy: VerifyPolicy::Default,
verify_config,
refresh_cache: true,
}
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(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
}
#[must_use]
pub fn no_cache_refresh(mut self) -> Self {
self.refresh_cache = false;
self
}
pub async fn update(self) -> Result<Property> {
self.limits.validate_id(&self.space_id, "space_id")?;
self.limits.validate_id(&self.property_id, "property_id")?;
ensure!(
self.name.is_some(),
ValidationSnafu {
message:
"update_property: name is required by the REST API (including for key changes)"
.to_string(),
}
);
let name = self.name.expect("property name checked above");
self.limits.validate_name(&name, "property name")?;
if let Some(ref key) = self.key {
self.limits.validate_name(key, "property key")?;
}
let request_body = UpdatePropertyRequestBody {
name,
key: self.key,
};
let response: PropertyResponse = self
.client
.patch_request(
&format!(
"/v1/spaces/{}/properties/{}",
self.space_id, self.property_id
),
&request_body,
)
.await?;
if self.refresh_cache && self.cache.has_properties(&self.space_id) {
let mut property = response.property.clone();
set_property_tags(&self.client, &self.limits, &self.space_id, &mut property).await?;
self.cache.set_property(&self.space_id, property);
} else if !self.refresh_cache {
self.cache.clear_properties(Some(&self.space_id));
}
let property = response.property;
if let Some(config) = resolve_verify(self.verify_policy, self.verify_config.as_ref()) {
return verify_available(&config, "Property", &property.id, || async {
let response: PropertyResponse = self
.client
.get_request(
&format!("/v1/spaces/{}/properties/{}", self.space_id, property.id),
QueryWithFilters::default(),
)
.await?;
Ok(response.property)
})
.await;
}
Ok(property)
}
}
#[derive(Debug)]
pub struct ListPropertiesRequest {
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: String,
limit: Option<u32>,
offset: Option<u32>,
filters: Vec<Filter>,
cache: Arc<AnytypeCache>,
}
impl ListPropertiesRequest {
#[must_use]
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: impl Into<String>,
cache: Arc<AnytypeCache>,
) -> Self {
Self {
client,
limits,
space_id: space_id.into(),
limit: None,
offset: None,
filters: Vec::new(),
cache,
}
}
#[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<Property>> {
self.limits.validate_id(&self.space_id, "space_id")?;
if self.cache.is_enabled()
&& self.limit.is_none()
&& (self.offset.unwrap_or_default() == 0)
&& self.filters.is_empty()
{
if !self.cache.has_properties(&self.space_id) {
prime_cache_properties(&self.client, &self.cache, &self.limits, &self.space_id)
.await?;
}
return Ok(PagedResult::from_items(
self.cache
.properties_for_space(&self.space_id)
.unwrap_or_default(),
));
}
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/{}/properties", self.space_id), query)
.await
}
}
impl AnytypeClient {
pub fn property(
&self,
space_id: impl Into<String>,
property_id: impl Into<String>,
) -> PropertyRequest {
PropertyRequest::new(
self.client.clone(),
self.config.limits.clone(),
space_id,
property_id,
false,
self.cache.clone(),
)
}
pub fn new_property(
&self,
space_id: impl Into<String>,
name: impl Into<String>,
format: PropertyFormat,
) -> NewPropertyRequest {
NewPropertyRequest::new(
self.client.clone(),
self.config.limits.clone(),
space_id,
name,
format,
self.cache.clone(),
self.config.verify.clone(),
)
}
pub fn update_property(
&self,
space_id: impl Into<String>,
property_id: impl Into<String>,
) -> UpdatePropertyRequest {
UpdatePropertyRequest::new(
self.client.clone(),
self.config.limits.clone(),
space_id,
property_id,
self.cache.clone(),
self.config.verify.clone(),
)
}
pub fn properties(&self, space_id: impl Into<String>) -> ListPropertiesRequest {
ListPropertiesRequest::new(
self.client.clone(),
self.config.limits.clone(),
space_id,
self.cache.clone(),
)
}
pub async fn lookup_properties(
&self,
space_id: &str,
text: impl AsRef<str>,
) -> Result<Vec<Property>> {
ensure!(self.cache.is_enabled(), CacheDisabledSnafu);
if !self.cache.has_properties(space_id) {
prime_cache_properties(&self.client, &self.cache, &self.config.limits, space_id)
.await?;
}
match self.cache.lookup_property(space_id, text.as_ref()) {
Some(properties) if !properties.is_empty() => {
Ok(properties.into_iter().map(|arc| (*arc).clone()).collect())
}
_ => Err(AnytypeError::NotFound {
obj_type: "Property".into(),
key: text.as_ref().to_string(),
}),
}
}
pub async fn lookup_property_by_key(
&self,
space_id: &str,
text: impl AsRef<str>,
) -> Result<Property> {
ensure!(self.cache.is_enabled(), CacheDisabledSnafu);
if !self.cache.has_properties(space_id) {
prime_cache_properties(&self.client, &self.cache, &self.config.limits, space_id)
.await?;
}
self.cache
.lookup_property_by_key(space_id, text.as_ref())
.map_or_else(
|| {
Err(AnytypeError::NotFound {
obj_type: "Property".into(),
key: text.as_ref().to_string(),
})
},
|property| Ok((*property).clone()),
)
}
pub async fn lookup_property_tag(
&self,
space_id: &str,
property_key: impl AsRef<str>,
tag_name: impl AsRef<str>,
) -> Result<Tag> {
let prop_key_or_id = property_key.as_ref();
let tag_key_or_id = tag_name.as_ref();
if looks_like_object_id(prop_key_or_id) {
let property = self.property(space_id, prop_key_or_id).get_direct().await?;
if !matches!(
property.format(),
PropertyFormat::Select | PropertyFormat::MultiSelect
) {
return NotFoundSnafu {
obj_type: "Tag".to_owned(),
key: tag_key_or_id.to_owned(),
}
.fail();
}
return self
.lookup_property_tag_bounded(space_id, prop_key_or_id, tag_key_or_id)
.await;
}
self.lookup_property_by_key(space_id, prop_key_or_id)
.await?
.lookup_tag(tag_key_or_id)
}
async fn lookup_property_tag_bounded(
&self,
space_id: &str,
property_id: &str,
tag_key_or_id: &str,
) -> Result<Tag> {
const MAX_PAGES: usize = MAX_RESOLVE_SCAN_ITEMS.div_ceil(RESOLVE_PAGE_SIZE as usize);
let needle = tag_key_or_id.to_lowercase();
let mut offset = 0_u32;
let mut scanned = 0_usize;
let mut advertised_total = None;
for _ in 0..MAX_PAGES {
let remaining = MAX_RESOLVE_SCAN_ITEMS.saturating_sub(scanned);
if remaining == 0 {
return Err(resolution_limit("tag", tag_key_or_id));
}
let requested_limit = RESOLVE_PAGE_SIZE.min(remaining as u32);
let page = self
.tags(space_id, property_id)
.limit(requested_limit)
.offset(offset)
.list()
.await?
.into_response();
let total = page.pagination.total;
if let Some(expected_total) = advertised_total {
if total != expected_total {
return Err(malformed_tag_pagination());
}
} else {
advertised_total = Some(total);
}
if total > MAX_RESOLVE_SCAN_ITEMS {
return Err(resolution_limit("tag", tag_key_or_id));
}
let Ok(page_offset) = usize::try_from(page.pagination.offset) else {
return Err(malformed_tag_pagination());
};
let Some(page_end) = page_offset.checked_add(page.items.len()) else {
return Err(malformed_tag_pagination());
};
let has_remaining = page_end < total;
if page.pagination.offset != offset
|| page_offset != scanned
|| page.pagination.limit != requested_limit
|| page.items.len() > remaining
|| page.items.len() > requested_limit as usize
|| page_end > total
|| page.pagination.has_more != has_remaining
|| (page.pagination.has_more && page.items.len() != requested_limit as usize)
{
return Err(malformed_tag_pagination());
}
for tag in &page.items {
if tag.id == needle || tag.key == needle || tag.name.to_lowercase() == needle {
return Ok(tag.clone());
}
}
scanned = page_end;
if scanned == MAX_RESOLVE_SCAN_ITEMS && page.pagination.has_more {
return Err(resolution_limit("tag", tag_key_or_id));
}
if !page.pagination.has_more {
return NotFoundSnafu {
obj_type: "Tag".to_owned(),
key: tag_key_or_id.to_owned(),
}
.fail();
}
offset = offset
.checked_add(requested_limit)
.ok_or_else(|| resolution_limit("tag", tag_key_or_id))?;
}
Err(resolution_limit("tag", tag_key_or_id))
}
}
fn malformed_tag_pagination() -> AnytypeError {
AnytypeError::Other {
message: "Anytype returned malformed tag pagination".to_owned(),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
const TEST_SPACE_ID: &str =
"bafyreid5fvqlnsobih2keakcxjrrlpmly6kf37klzjzen4ibfdgalcdp4y.2tq5w93cr6oe7";
const TEST_PROPERTY_ID: &str = "bafyreid5fvqlnsobih2keakcxjrrlpmly6kf37klzjzen4ibfdgalcdp4y";
const OTHER_PROPERTY_ID: &str = "bafyreid5fvqlnsobih2keakcxjrrlpmly6kf37klzjzen4ibfdgalcdp4z";
const TEST_TAG_ID: &str = "bafyreid5fvqlnsobih2keakcxjrrlpmly6kf37klzjzen4ibfdgalcdp4x";
#[test]
fn property_schema_preserves_discriminator() {
let response: PropertyResponse = serde_json::from_value(serde_json::json!({
"property": {
"object": "property",
"name": "Description",
"key": "description",
"id": "property-id",
"format": "text"
}
}))
.expect("property response schema");
assert_eq!(response.property.object, DataModel::Property);
let serialized = serde_json::to_value(response.property).expect("serialize property");
assert_eq!(serialized["object"], "property");
}
#[test]
fn property_discriminator_defaults_when_omitted_and_preserves_present_value() {
let property_without_discriminator: Property = serde_json::from_value(serde_json::json!({
"name": "Description",
"key": "description",
"id": "property-id",
"format": "text"
}))
.expect("property without discriminator");
assert_eq!(property_without_discriminator.object, DataModel::Property);
let property_with_observed_tag: Property = serde_json::from_value(serde_json::json!({
"object": "tag",
"name": "Description",
"key": "description",
"id": "property-id",
"format": "text"
}))
.expect("property with observed discriminator");
assert_eq!(property_with_observed_tag.object, DataModel::Tag);
}
#[derive(Debug, Default)]
struct PropertyRouteTraffic {
requests: Vec<String>,
property_list_pages: usize,
direct_property_gets: usize,
tag_list_pages: usize,
}
#[derive(Clone, Copy)]
enum TagRoute {
Single,
TargetSecondPage,
OverLimitTarget,
FalseTerminal,
ChangingTotal,
ValidAbsentSecondPage,
}
fn full_unrelated_tag_page() -> Vec<serde_json::Value> {
(0..99)
.map(|index| {
serde_json::json!({
"id": format!("other-tag-{index}"),
"key": format!("other_{index}"),
"name": format!("Other {index}"),
"color": "grey"
})
})
.collect()
}
async fn route_aware_property_server(
returned_property_id: &'static str,
tag_route: TagRoute,
) -> (
String,
tokio::sync::oneshot::Sender<()>,
tokio::task::JoinHandle<PropertyRouteTraffic>,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind route-aware property fixture");
let address = listener.local_addr().expect("property fixture address");
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
let mut traffic = PropertyRouteTraffic::default();
loop {
let accepted = tokio::select! {
_ = &mut shutdown_rx => break,
accepted = listener.accept() => accepted,
};
let (mut stream, _) = accepted.expect("accept property fixture request");
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let read = stream
.read(&mut buffer)
.await
.expect("read property fixture request");
assert!(
read > 0,
"property fixture connection closed before headers"
);
request.extend_from_slice(&buffer[..read]);
assert!(
request.len() <= 64 * 1024,
"property fixture headers too large"
);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request = String::from_utf8(request).expect("property request is utf-8");
let request_line = request.lines().next().expect("property request line");
let mut parts = request_line.split_ascii_whitespace();
assert_eq!(parts.next(), Some("GET"));
let target = parts.next().expect("property request target");
assert_eq!(parts.next(), Some("HTTP/1.1"));
assert_eq!(parts.next(), None, "extra property request-line field");
let (path, raw_query) = target
.split_once('?')
.map_or((target, ""), |(path, query)| (path, query));
let mut query = BTreeMap::new();
for (key, value) in url::form_urlencoded::parse(raw_query.as_bytes()) {
let previous = query.insert(key.into_owned(), value.into_owned());
assert!(previous.is_none(), "duplicate property query key");
}
let collection_path = format!("/v1/spaces/{TEST_SPACE_ID}/properties");
let direct_path = format!("{collection_path}/{TEST_PROPERTY_ID}");
let tags_path = format!("{direct_path}/tags");
let body = if path == collection_path {
let page = traffic.property_list_pages;
traffic.property_list_pages += 1;
if page == 0 {
assert_eq!(query, BTreeMap::new(), "first property-list query");
serde_json::json!({
"data": [{
"id": OTHER_PROPERTY_ID,
"key": "other",
"name": "Other",
"format": "text",
"tags": null
}],
"pagination": {
"has_more": true,
"limit": 100,
"offset": 0,
"total": 101
}
})
.to_string()
} else {
assert_eq!(
query,
BTreeMap::from([
("limit".to_owned(), "100".to_owned()),
("offset".to_owned(), "100".to_owned()),
]),
"continued property-list query"
);
serde_json::json!({
"data": [{
"id": TEST_PROPERTY_ID,
"key": "status",
"name": "Status",
"format": "select",
"tags": null
}],
"pagination": {
"has_more": false,
"limit": 100,
"offset": 100,
"total": 101
}
})
.to_string()
}
} else if path == direct_path {
assert_eq!(query, BTreeMap::new(), "direct property query");
traffic.direct_property_gets += 1;
serde_json::json!({
"property": {
"id": returned_property_id,
"key": "status",
"name": "private-property-body-marker",
"format": "select",
"tags": [{
"id": OTHER_PROPERTY_ID,
"key": "embedded-private-tag",
"name": "embedded private tag",
"color": "red"
}]
}
})
.to_string()
} else if path == tags_path {
let page = traffic.tag_list_pages;
traffic.tag_list_pages += 1;
let expected_query = if page == 0 {
BTreeMap::from([("limit".to_owned(), "99".to_owned())])
} else {
BTreeMap::from([
("limit".to_owned(), "99".to_owned()),
("offset".to_owned(), "99".to_owned()),
])
};
assert_eq!(query, expected_query, "tag-list query for page {page}");
match (tag_route, page) {
(TagRoute::Single, 0) => serde_json::json!({
"data": [{
"id": TEST_TAG_ID,
"key": "open",
"name": "Open",
"color": "blue"
}],
"pagination": {
"has_more": false,
"limit": 99,
"offset": 0,
"total": 1
}
})
.to_string(),
(
TagRoute::TargetSecondPage
| TagRoute::ChangingTotal
| TagRoute::ValidAbsentSecondPage,
0,
) => {
let tags = full_unrelated_tag_page();
serde_json::json!({
"data": tags,
"pagination": {
"has_more": true,
"limit": 99,
"offset": 0,
"total": 100
}
})
.to_string()
}
(TagRoute::TargetSecondPage, 1) => serde_json::json!({
"data": [{
"id": TEST_TAG_ID,
"key": "open",
"name": "Open",
"color": "blue"
}],
"pagination": {
"has_more": false,
"limit": 99,
"offset": 99,
"total": 100
}
})
.to_string(),
(TagRoute::OverLimitTarget, 0) => serde_json::json!({
"data": [{
"id": TEST_TAG_ID,
"key": "open",
"name": "Open",
"color": "blue"
}],
"pagination": {
"has_more": true,
"limit": 99,
"offset": 0,
"total": 1001
}
})
.to_string(),
(TagRoute::FalseTerminal, 0) => serde_json::json!({
"data": [{
"id": "other-tag",
"key": "other",
"name": "Other",
"color": "grey"
}],
"pagination": {
"has_more": false,
"limit": 99,
"offset": 0,
"total": 1000
}
})
.to_string(),
(TagRoute::ChangingTotal, 1) => serde_json::json!({
"data": [{
"id": TEST_TAG_ID,
"key": "open",
"name": "Open",
"color": "blue"
}],
"pagination": {
"has_more": true,
"limit": 99,
"offset": 99,
"total": 101
}
})
.to_string(),
(TagRoute::ValidAbsentSecondPage, 1) => serde_json::json!({
"data": [{
"id": "last-other-tag",
"key": "last_other",
"name": "Last Other",
"color": "grey"
}],
"pagination": {
"has_more": false,
"limit": 99,
"offset": 99,
"total": 100
}
})
.to_string(),
_ => panic!("unexpected tag fixture page {page}"),
}
} else {
panic!("unexpected property fixture route: {request_line}");
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
traffic.requests.push(request);
stream
.write_all(response.as_bytes())
.await
.expect("write property fixture response");
stream
.shutdown()
.await
.expect("shutdown property fixture response");
}
traffic
});
(format!("http://{address}"), shutdown_tx, task)
}
fn route_fixture_client(base_url: String) -> AnytypeClient {
let mut config = crate::client::ClientConfig::default().app_name("property-route-fixture");
config.base_url = Some(base_url);
config.keystore = Some("env".to_owned());
let client = AnytypeClient::with_config(config).expect("property fixture client");
client.set_api_key(crate::keystore::HttpCredentials::new("fixture-token"));
client
}
#[tokio::test]
async fn direct_property_get_is_cache_independent_and_exactly_scoped() {
let (base_url, shutdown, traffic) =
route_aware_property_server(TEST_PROPERTY_ID, TagRoute::Single).await;
let client = route_fixture_client(base_url);
assert!(
client.cache().is_enabled(),
"fixture must exercise cache-on behavior"
);
let property = client
.property(TEST_SPACE_ID, TEST_PROPERTY_ID)
.with_tags()
.get_direct()
.await
.expect("direct property");
assert_eq!(property.id, TEST_PROPERTY_ID);
assert!(
property.tags().is_none(),
"direct metadata must discard embedded tags"
);
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(
traffic.property_list_pages, 0,
"must not prime property cache"
);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(traffic.tag_list_pages, 0);
assert_eq!(traffic.requests.len(), 1);
assert_eq!(
traffic.requests[0].lines().next().unwrap(),
format!("GET /v1/spaces/{TEST_SPACE_ID}/properties/{TEST_PROPERTY_ID} HTTP/1.1")
);
}
#[tokio::test]
async fn explicit_id_tag_lookup_uses_direct_property_get_with_cold_cache() {
let (base_url, shutdown, traffic) =
route_aware_property_server(TEST_PROPERTY_ID, TagRoute::Single).await;
let client = route_fixture_client(base_url);
assert!(
client.cache().is_enabled(),
"fixture must exercise cache-on behavior"
);
let tag = client
.lookup_property_tag(TEST_SPACE_ID, TEST_PROPERTY_ID, "Open")
.await
.expect("explicit-id tag lookup");
assert_eq!(tag.id, TEST_TAG_ID);
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(
traffic.property_list_pages, 0,
"must not prime property cache"
);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(traffic.tag_list_pages, 1);
assert_eq!(traffic.requests.len(), 2);
}
#[tokio::test]
async fn explicit_id_tag_lookup_finds_second_page_target_within_budget() {
let (base_url, shutdown, traffic) =
route_aware_property_server(TEST_PROPERTY_ID, TagRoute::TargetSecondPage).await;
let client = route_fixture_client(base_url);
let tag = client
.lookup_property_tag(TEST_SPACE_ID, TEST_PROPERTY_ID, "open")
.await
.expect("bounded second-page tag lookup");
assert_eq!(tag.id, TEST_TAG_ID);
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(traffic.property_list_pages, 0);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(traffic.tag_list_pages, 2);
assert_eq!(traffic.requests.len(), 3);
}
#[tokio::test]
async fn explicit_id_tag_lookup_rejects_over_budget_total_before_target() {
let (base_url, shutdown, traffic) =
route_aware_property_server(TEST_PROPERTY_ID, TagRoute::OverLimitTarget).await;
let client = route_fixture_client(base_url);
let error = client
.lookup_property_tag(TEST_SPACE_ID, TEST_PROPERTY_ID, "open")
.await
.expect_err("over-budget total must precede a matching target");
assert!(matches!(
error,
AnytypeError::ResolutionLimitExceeded {
obj_type,
limit: MAX_RESOLVE_SCAN_ITEMS,
..
} if obj_type == "tag"
));
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(traffic.property_list_pages, 0);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(
traffic.tag_list_pages, 1,
"known over-budget total stops immediately"
);
assert_eq!(traffic.requests.len(), 2);
}
#[tokio::test]
async fn explicit_id_tag_lookup_rejects_false_terminal_page() {
let (base_url, shutdown, traffic) =
route_aware_property_server(TEST_PROPERTY_ID, TagRoute::FalseTerminal).await;
let client = route_fixture_client(base_url);
let error = client
.lookup_property_tag(TEST_SPACE_ID, TEST_PROPERTY_ID, "absent")
.await
.expect_err("incomplete terminal page must not report not-found");
assert_malformed_tag_pagination(error);
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(traffic.property_list_pages, 0);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(traffic.tag_list_pages, 1);
assert_eq!(traffic.requests.len(), 2);
}
#[tokio::test]
async fn explicit_id_tag_lookup_rejects_changing_total_before_target() {
let (base_url, shutdown, traffic) =
route_aware_property_server(TEST_PROPERTY_ID, TagRoute::ChangingTotal).await;
let client = route_fixture_client(base_url);
let error = client
.lookup_property_tag(TEST_SPACE_ID, TEST_PROPERTY_ID, "open")
.await
.expect_err("changing total must precede a matching target");
assert_malformed_tag_pagination(error);
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(traffic.property_list_pages, 0);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(traffic.tag_list_pages, 2);
assert_eq!(traffic.requests.len(), 3);
}
#[tokio::test]
async fn explicit_id_tag_lookup_returns_not_found_only_after_complete_last_page() {
let (base_url, shutdown, traffic) =
route_aware_property_server(TEST_PROPERTY_ID, TagRoute::ValidAbsentSecondPage).await;
let client = route_fixture_client(base_url);
let error = client
.lookup_property_tag(TEST_SPACE_ID, TEST_PROPERTY_ID, "absent")
.await
.expect_err("complete absent lookup must return not-found");
assert!(matches!(
error,
AnytypeError::NotFound { obj_type, key }
if obj_type == "Tag" && key == "absent"
));
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(traffic.property_list_pages, 0);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(traffic.tag_list_pages, 2);
assert_eq!(traffic.requests.len(), 3);
}
fn assert_malformed_tag_pagination(error: AnytypeError) {
let AnytypeError::Other { message } = &error else {
panic!("malformed pagination must be an upstream error: {error}");
};
assert_eq!(message, "Anytype returned malformed tag pagination");
let display = error.to_string();
for private in [TEST_SPACE_ID, TEST_PROPERTY_ID, TEST_TAG_ID] {
assert!(!display.contains(private), "pagination error leaked an id");
}
}
#[tokio::test]
async fn direct_property_identity_mismatch_is_secret_safe_and_skips_tags() {
let (base_url, shutdown, traffic) =
route_aware_property_server(OTHER_PROPERTY_ID, TagRoute::Single).await;
let client = route_fixture_client(base_url);
let error = client
.lookup_property_tag(TEST_SPACE_ID, TEST_PROPERTY_ID, "Open")
.await
.expect_err("mismatched direct property must fail closed");
let AnytypeError::Other { message } = &error else {
panic!("identity mismatch must be an upstream error: {error}");
};
assert_eq!(message, "Anytype returned a mismatched property identity");
let display = error.to_string();
for private in [
TEST_SPACE_ID,
TEST_PROPERTY_ID,
OTHER_PROPERTY_ID,
"private-property-body-marker",
] {
assert!(
!display.contains(private),
"error display leaked fixture data"
);
}
shutdown.send(()).expect("stop property fixture");
let traffic = traffic.await.expect("property fixture task");
assert_eq!(traffic.property_list_pages, 0);
assert_eq!(traffic.direct_property_gets, 1);
assert_eq!(
traffic.tag_list_pages, 0,
"mismatch must stop before tag lookup"
);
assert_eq!(traffic.requests.len(), 1);
}
#[tokio::test]
async fn direct_property_get_validates_both_ids_before_io() {
let client = route_fixture_client("http://127.0.0.1:1".to_owned());
for (space_id, property_id) in [
("unsafe/space", TEST_PROPERTY_ID),
(TEST_SPACE_ID, "unsafe/property"),
] {
let error = client
.property(space_id, property_id)
.get_direct()
.await
.expect_err("unsafe scoped id must fail before transport");
assert!(matches!(error, AnytypeError::Validation { .. }));
}
}
#[tokio::test]
async fn property_key_tag_lookup_retains_documented_cache_requirement() {
let mut config =
crate::client::ClientConfig::default().app_name("property-key-cache-fixture");
config.base_url = Some("http://127.0.0.1:1".to_owned());
config.keystore = Some("env".to_owned());
config.disable_cache = true;
let client = AnytypeClient::with_config(config).expect("cache-disabled fixture client");
client.set_api_key(crate::keystore::HttpCredentials::new("fixture-token"));
let error = client
.lookup_property_tag(TEST_SPACE_ID, "status", "Open")
.await
.expect_err("property-key lookup requires enabled cache");
assert!(matches!(error, AnytypeError::CacheDisabled));
}
#[test]
fn test_property_format_default() {
let format: PropertyFormat = PropertyFormat::default();
assert_eq!(format, PropertyFormat::Text);
}
#[test]
fn test_property_format_display() {
assert_eq!(PropertyFormat::Text.to_string(), "text");
assert_eq!(PropertyFormat::Select.to_string(), "select");
assert_eq!(PropertyFormat::MultiSelect.to_string(), "multi_select");
}
#[test]
fn test_property_format_from_string() {
use std::str::FromStr;
assert_eq!(
PropertyFormat::from_str("text").unwrap(),
PropertyFormat::Text
);
assert_eq!(
PropertyFormat::from_str("number").unwrap(),
PropertyFormat::Number
);
assert_eq!(
PropertyFormat::from_str("multi_select").unwrap(),
PropertyFormat::MultiSelect
);
}
#[test]
fn test_property_value_as_str() {
let text_val = PropertyValue::Text {
text: "hello".to_string(),
};
assert_eq!(text_val.as_str(), Some("hello"));
let url_val = PropertyValue::Url {
url: "https://example.com".to_string(),
};
assert_eq!(url_val.as_str(), Some("https://example.com"));
let files_val = PropertyValue::Files { files: vec![] };
assert_eq!(files_val.as_str(), None);
}
#[test]
fn test_property_value_as_bool() {
let checkbox_true = PropertyValue::Checkbox { checkbox: true };
assert_eq!(checkbox_true.as_bool(), Some(true));
let checkbox_false = PropertyValue::Checkbox { checkbox: false };
assert_eq!(checkbox_false.as_bool(), Some(false));
let text_val = PropertyValue::Text {
text: "true".to_string(),
};
assert_eq!(text_val.as_bool(), None);
}
#[test]
fn test_property_value_as_array() {
let files = PropertyValue::Files {
files: vec!["file1".to_string(), "file2".to_string()],
};
assert_eq!(
files.as_array(),
Some(vec!["file1".to_string(), "file2".to_string()])
);
let text = PropertyValue::Text {
text: "hello".to_string(),
};
assert_eq!(text.as_array(), None);
}
#[test]
fn test_create_property_request_body_serialization() {
let body = CreatePropertyRequestBody {
name: "Priority".to_string(),
key: Some("priority".to_string()),
format: PropertyFormat::Select,
tags: vec![],
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"name\":\"Priority\""));
assert!(json.contains("\"key\":\"priority\""));
assert!(json.contains("\"format\":\"select\""));
}
#[test]
fn test_update_property_request_body_requires_name_on_wire() {
let body = UpdatePropertyRequestBody {
name: "Priority".to_string(),
key: None,
};
let json = serde_json::to_string(&body).unwrap();
assert_eq!(json, r#"{"name":"Priority"}"#);
}
#[tokio::test]
async fn test_update_property_rejects_key_without_name() {
let mut config = crate::client::ClientConfig::default().app_name("property-update-unit");
config.keystore = Some("env".to_string());
let client = AnytypeClient::with_config(config).expect("client");
let valid_id = "bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ";
let error = client
.update_property(valid_id, valid_id)
.key("new_key")
.update()
.await
.expect_err("a key-only REST update must fail validation");
assert!(
matches!(error, AnytypeError::Validation { ref message } if message.contains("name is required"))
);
}
#[test]
fn test_property_info_deserialization() {
let json = r#"{
"name": "Status",
"format": "select",
"id": "prop123",
"key": "status"
}"#;
let prop: Property = serde_json::from_str(json).unwrap();
assert_eq!(prop.name, "Status");
assert_eq!(prop.format, PropertyFormat::Select);
assert_eq!(prop.id, "prop123");
assert_eq!(prop.key, "status");
}
}