use crate::{
common::timestamp,
limits::VALIDATION_LIMITS,
traits::{HasIdPath, HashId, Validatable},
APP_PATH, PUBLIC_PATH,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[cfg(target_arch = "wasm32")]
use crate::traits::Json;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppTag {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub uri: String,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub label: String,
pub created_at: i64,
}
impl PubkyAppTag {
pub fn new(uri: String, label: String) -> Self {
let created_at = timestamp();
Self {
uri,
label,
created_at,
}
.sanitize()
}
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppTag {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
Self::import_json(js_value)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
pub fn to_json(&self) -> Result<JsValue, String> {
self.export_json()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn uri(&self) -> String {
self.uri.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn label(&self) -> String {
self.label.clone()
}
}
#[cfg(target_arch = "wasm32")]
impl Json for PubkyAppTag {}
impl HasIdPath for PubkyAppTag {
const PATH_SEGMENT: &'static str = "tags/";
fn create_path(id: &str) -> String {
[PUBLIC_PATH, APP_PATH, Self::PATH_SEGMENT, id].concat()
}
}
impl HashId for PubkyAppTag {
fn get_id_data(&self) -> String {
format!("{}:{}", self.uri, self.label)
}
}
pub fn sanitize_tag_label(tag: &str) -> String {
tag.trim().to_lowercase()
}
pub fn validate_tag_label(tag: &str) -> Result<(), String> {
let tag_len = tag.chars().count();
if tag_len > VALIDATION_LIMITS.tag_label_max_length {
return Err(format!(
"Validation Error: Tag '{}' exceeds maximum length of {} characters",
tag, VALIDATION_LIMITS.tag_label_max_length
));
}
if tag_len < VALIDATION_LIMITS.tag_label_min_length {
return Err(format!(
"Validation Error: Tag '{}' is shorter than minimum length of {} character",
tag, VALIDATION_LIMITS.tag_label_min_length
));
}
if tag.chars().any(|c| c.is_whitespace()) {
return Err(format!(
"Validation Error: Tag '{}' contains whitespace characters",
tag
));
}
if let Some(c) = tag
.chars()
.find(|c| VALIDATION_LIMITS.tag_invalid_chars.contains(c))
{
return Err(format!(
"Validation Error: Tag '{}' contains invalid character: {}",
tag, c
));
}
Ok(())
}
impl Validatable for PubkyAppTag {
fn sanitize(self) -> Self {
let label = sanitize_tag_label(&self.label);
let uri = match Url::parse(&self.uri) {
Ok(url) => url.to_string(),
Err(_) => self.uri.trim().to_string(),
};
PubkyAppTag {
uri,
label,
created_at: self.created_at,
}
}
fn validate(&self, id: Option<&str>) -> Result<(), String> {
if let Some(id) = id {
self.validate_id(id)?;
}
validate_tag_label(&self.label)?;
Url::parse(&self.uri)
.map(|_| ())
.map_err(|_| format!("Validation Error: Invalid URI format: {}", self.uri))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{post_uri_builder, traits::Validatable, user_uri_builder, APP_PATH};
#[test]
fn test_label_id() {
let post_uri = post_uri_builder("user_id".into(), "post_id".into());
let tag_id = "CBYS8P6VJPHC5XXT4WDW26662W";
let tag = PubkyAppTag {
uri: post_uri.clone(),
created_at: 1627849723,
label: "cool".to_string(),
};
let new_tag_id = tag.create_id();
assert!(!tag_id.is_empty());
assert_eq!(new_tag_id, tag_id);
let wrong_tag = PubkyAppTag {
uri: post_uri,
created_at: 1627849723,
label: "co0l".to_string(),
};
assert_ne!(wrong_tag.create_id(), tag_id);
}
#[test]
fn test_create_id() {
let tag = PubkyAppTag {
uri: "https://example.com/post/1".to_string(),
created_at: 1627849723000,
label: "cool".to_string(),
};
let tag_id = tag.create_id();
println!("Generated Tag ID: {}", tag_id);
assert!(!tag_id.is_empty());
}
#[test]
fn test_new() {
let uri = "https://example.com/post/1".to_string();
let label = "interesting".to_string();
let tag = PubkyAppTag::new(uri.clone(), label.clone());
assert_eq!(tag.uri, uri);
assert_eq!(tag.label, label);
let now = timestamp();
assert!(tag.created_at <= now && tag.created_at >= now - 1_000_000); }
#[test]
fn test_create_path() {
let post_uri = post_uri_builder(
"operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo".into(),
"0032FNCGXE3R0".into(),
);
let tag = PubkyAppTag {
uri: post_uri,
created_at: 1627849723000,
label: "cool".to_string(),
};
let expected_id = tag.create_id();
let expected_path = format!("{}{}tags/{}", PUBLIC_PATH, APP_PATH, expected_id);
let path = PubkyAppTag::create_path(&expected_id);
assert_eq!(path, expected_path);
}
#[test]
fn test_sanitize() {
let post_uri = post_uri_builder("user_id".into(), "0000000000000".into());
let test_cases = vec![
("CoOl", "cool"),
(" CoOl ", "cool"),
("UPPERCASE", "uppercase"),
];
for (input, expected) in test_cases {
let tag = PubkyAppTag {
uri: post_uri.clone(),
label: input.to_string(),
created_at: 1627849723000,
};
let sanitized_tag = tag.sanitize();
assert_eq!(sanitized_tag.label, expected, "Failed for input: {}", input);
}
}
#[test]
fn test_validate() {
let post_uri = post_uri_builder("user_id".into(), "0000000000000".into());
let tag = PubkyAppTag {
uri: post_uri,
label: "cool".to_string(),
created_at: 1627849723000,
};
let id = tag.create_id();
let result = tag.validate(Some(&id));
assert!(result.is_ok());
}
#[test]
fn test_validate_invalid_label_length() {
let post_uri = post_uri_builder("user_id".into(), "0000000000000".into());
let tag = PubkyAppTag {
uri: post_uri,
label: "a".repeat(VALIDATION_LIMITS.tag_label_max_length + 1),
created_at: 1627849723000,
};
let id = tag.create_id();
let result = tag.validate(Some(&id));
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(
error_msg.contains("exceeds maximum length"),
"Expected error about maximum length, got: {}",
error_msg
);
}
#[test]
fn test_validate_invalid_id() {
let post_uri = post_uri_builder("user_id".into(), "0000000000000".into());
let tag = PubkyAppTag {
uri: post_uri,
label: "cool".to_string(),
created_at: 1627849723000,
};
let invalid_id = "INVALIDID";
let result = tag.validate(Some(invalid_id));
assert!(result.is_err());
}
#[test]
fn test_validate_invalid_char() {
let post_uri = post_uri_builder("user_id".into(), "0000000000000".into());
let tag = PubkyAppTag {
uri: post_uri,
label: format!("invalidchar{}", VALIDATION_LIMITS.tag_invalid_chars[0]),
created_at: 1627849723000,
};
let id = tag.create_id();
let result = tag.validate(Some(&id));
assert!(result.is_err());
}
#[test]
fn test_validate_invalid_uri() {
let tag = PubkyAppTag {
uri: "user_id/pub/pubky.app/posts/post_id".into(),
label: "cool".to_string(),
created_at: 1627849723000,
};
let id = tag.create_id();
let result = tag.validate(Some(&id));
assert!(result
.unwrap_err()
.starts_with("Validation Error: Invalid URI format"));
}
#[test]
fn test_try_from_valid() {
let user_uri = user_uri_builder("user_pubky_id".into());
let tag_label = "CoolTag".to_string();
let tag_json = format!(
r#"
{{
"uri": "{user_uri}",
"label": "{tag_label}",
"created_at": 1627849723000
}}
"#
);
let id = PubkyAppTag::new(user_uri.clone(), tag_label.clone()).create_id();
let blob = tag_json.as_bytes();
let sanitized_validated_tag = <PubkyAppTag as Validatable>::try_from(blob, &id).unwrap();
assert_eq!(sanitized_validated_tag.uri, user_uri);
assert_eq!(sanitized_validated_tag.label, "cooltag");
}
#[test]
fn test_try_from_invalid_uri() {
let tag_json = r#"
{
"uri": "invalid_uri",
"label": "CoolTag",
"created_at": 1627849723000
}
"#;
let id = "D2DV4EZDA03Q3KCRMVGMDYZ8C0";
let blob = tag_json.as_bytes();
let result = <PubkyAppTag as Validatable>::try_from(blob, id);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Validation Error: Invalid URI format: invalid_uri"
);
}
#[test]
fn test_whitespace_handling() {
let post_uri = post_uri_builder("user_id".into(), "post_id".into());
let trim_cases = vec![" cool", "cool ", "cool\n", " cool "];
for label in trim_cases {
let tag = PubkyAppTag {
uri: post_uri.clone(),
created_at: 1627849723,
label: label.to_string(),
};
let sanitized = tag.sanitize();
assert_eq!(sanitized.label, "cool", "Failed for: {}", label);
assert!(
sanitized.validate(None).is_ok(),
"Should pass after trimming: {}",
label
);
}
let tag = PubkyAppTag {
uri: post_uri,
created_at: 1627849723,
label: " co ol ".to_string(),
};
let sanitized = tag.sanitize();
assert_eq!(sanitized.label, "co ol"); assert!(sanitized.validate(None).is_err()); }
#[test]
fn test_unicode_tag_labels() {
let post_uri = post_uri_builder("user_id".into(), "post_id".into());
let unicode_cases = vec![
("比特币", "比特币"), ("ビットコイン", "ビットコイン"), ("🚀", "🚀"), ("café", "café"), ];
for (input, expected) in unicode_cases {
let tag = PubkyAppTag::new(post_uri.clone(), input.to_string());
assert_eq!(tag.label, expected, "Failed for input: {}", input);
assert!(
tag.validate(None).is_ok(),
"Should accept Unicode tag: {}",
input
);
}
let max_emoji_tag: String = "🔥".repeat(VALIDATION_LIMITS.tag_label_max_length);
assert_eq!(
max_emoji_tag.chars().count(),
VALIDATION_LIMITS.tag_label_max_length
);
let tag = PubkyAppTag::new(post_uri.clone(), max_emoji_tag);
assert!(
tag.validate(None).is_ok(),
"Should accept {} emoji characters as tag",
VALIDATION_LIMITS.tag_label_max_length
);
}
}