use crate::{
algorithms::ptd::Type,
http::{Client, CONTENT_TYPE_JSON},
standards::{indieauth::AccessToken, micropub::convert_error},
traits::as_string_or_list,
};
use serde::{Deserialize, Serialize};
use super::{
extension::{self, PostStatus, Visibility},
paging,
};
use microformats::types::Item;
use secrecy::ExposeSecret;
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct Query {
#[serde(flatten)]
pub pagination: paging::Query,
#[serde(flatten)]
pub kind: QueryKind,
}
#[derive(Debug, Clone, Default, Eq)]
pub struct MatchingPropertyValuesMap(HashMap<String, Vec<serde_json::Value>>);
impl std::ops::Deref for MatchingPropertyValuesMap {
type Target = HashMap<String, Vec<serde_json::Value>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::hash::Hash for MatchingPropertyValuesMap {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
serde_json::to_string(&self.0)
.unwrap_or_default()
.hash(state);
}
}
impl PartialEq for MatchingPropertyValuesMap {
fn eq(&self, other: &Self) -> bool {
self.0.eq(&other.0)
}
}
const KNOWN_SOURCE_QUERY_PARAMS: &[&str] = &[
"url",
"destination",
"post_type",
"post-status",
"post_status",
"visibility",
"audience",
"syndicate_to",
"syndicate-to",
"category",
"exists",
"not_exists",
"not-exists",
"filter",
"limit",
"offset",
"before",
"after",
"order",
"q",
"h",
"type",
"channel",
"mp-channel",
"mime_type",
"mime-type",
];
fn is_known_query_param(key: &str) -> bool {
KNOWN_SOURCE_QUERY_PARAMS
.iter()
.any(|&known| key == known || key == known.replace('_', "-"))
}
struct PropertyValueVisitor;
impl<'de> serde::de::Visitor<'de> for PropertyValueVisitor {
type Value = MatchingPropertyValuesMap;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("expecting a map of property filters (with 'property-' prefix or shorthand)")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut props = HashMap::<String, Vec<serde_json::Value>>::default();
while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
let (property_name, should_capture) = if key.starts_with("property-") {
(key.replace("property-", ""), true)
} else if !is_known_query_param(&key) {
(key, true)
} else {
(key, false)
};
if !should_capture {
continue;
}
let values = value
.as_array()
.cloned()
.unwrap_or_else(|| vec![value.clone()]);
if let Some(list) = props.get_mut(&property_name) {
list.extend(values);
} else {
props.insert(property_name, values);
}
}
Ok(MatchingPropertyValuesMap(HashMap::from_iter(
props
.iter()
.map(|(name, values)| (name.to_owned(), values.to_vec()))
.collect::<Vec<_>>()
.into_iter()
.rev(),
)))
}
}
impl From<HashMap<String, Vec<serde_json::Value>>> for MatchingPropertyValuesMap {
fn from(v: HashMap<String, Vec<serde_json::Value>>) -> Self {
Self(v)
}
}
impl<'de> serde::Deserialize<'de> for MatchingPropertyValuesMap {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
deserializer.deserialize_map(PropertyValueVisitor)
}
}
impl serde::Serialize for MatchingPropertyValuesMap {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (k, v) in &self.0 {
serde::ser::SerializeMap::serialize_entry(&mut map, &format!("property-{}", k), &v)?;
}
serde::ser::SerializeMap::end(map)
}
}
impl TryFrom<serde_json::Value> for MatchingPropertyValuesMap {
type Error = serde_json::Error;
fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
serde_json::from_value(value)
}
}
impl FromIterator<(String, Vec<serde_json::Value>)> for MatchingPropertyValuesMap {
fn from_iter<T: IntoIterator<Item = (String, Vec<serde_json::Value>)>>(iter: T) -> Self {
Self(HashMap::from_iter(iter))
}
}
impl IntoIterator for MatchingPropertyValuesMap {
type Item = (String, Vec<serde_json::Value>);
type IntoIter = std::collections::hash_map::IntoIter<String, Vec<serde_json::Value>>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[test]
fn matching_property_values_map() {
assert_eq!(
serde_json::from_str::<MatchingPropertyValuesMap>(
r#"
{
"property-foo": null,
"property-bar": [3, "jump"]
}
"#
)
.map_err(|e| e.to_string()),
Ok(MatchingPropertyValuesMap(HashMap::from_iter([
("foo".to_string(), vec![serde_json::Value::Null]),
(
"bar".to_string(),
vec![
serde_json::Value::Number(3.into()),
serde_json::Value::String("jump".to_string())
]
)
])))
.map_err(|e: serde_json::Error| e.to_string()),
"deserializing from JSON with property- prefix"
);
assert_eq!(
serde_qs::from_str::<MatchingPropertyValuesMap>(
"property-foo=kick&property-bar[]=jump&property-bar[]=high"
)
.map_err(|e: serde_qs::Error| e.to_string()),
Ok(MatchingPropertyValuesMap(HashMap::from_iter([
(
"foo".to_string(),
vec![serde_json::Value::String("kick".to_string())]
),
(
"bar".to_string(),
vec![
serde_json::Value::String("jump".to_string()),
serde_json::Value::String("high".to_string()),
]
)
]))),
"deserializing from query string with property- prefix"
);
}
#[test]
fn matching_property_values_map_shorthand() {
assert_eq!(
serde_json::from_str::<MatchingPropertyValuesMap>(
r#"{ "rsvp": "yes" }"#
)
.map_err(|e| e.to_string()),
Ok(MatchingPropertyValuesMap(HashMap::from_iter([
("rsvp".to_string(), vec![serde_json::Value::String("yes".to_string())])
]))),
"deserializing shorthand property from JSON"
);
assert_eq!(
serde_qs::from_str::<MatchingPropertyValuesMap>("rsvp=yes")
.map_err(|e: serde_qs::Error| e.to_string()),
Ok(MatchingPropertyValuesMap(HashMap::from_iter([
("rsvp".to_string(), vec![serde_json::Value::String("yes".to_string())])
]))),
"deserializing shorthand property from query string"
);
assert_eq!(
serde_json::from_str::<MatchingPropertyValuesMap>(
r#"{ "property-foo": "bar", "rsvp": "yes" }"#
)
.map_err(|e| e.to_string()),
Ok(MatchingPropertyValuesMap(HashMap::from_iter([
("foo".to_string(), vec![serde_json::Value::String("bar".to_string())]),
("rsvp".to_string(), vec![serde_json::Value::String("yes".to_string())])
]))),
"deserializing mixed explicit and shorthand properties from JSON"
);
assert_eq!(
serde_json::from_str::<MatchingPropertyValuesMap>(
r#"{ "url": "https://example.com" }"#
)
.map_err(|e| e.to_string()),
Ok(MatchingPropertyValuesMap(HashMap::new())),
"known query params should not be captured as property filters"
);
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub struct SourceQuery {
#[serde(flatten)]
pub matching_properties: MatchingPropertyValuesMap,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub url: Option<url::Url>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub destination: Option<url::Url>,
#[serde(
with = "as_string_or_list",
skip_serializing_if = "Vec::is_empty",
default
)]
pub post_type: Vec<crate::algorithms::ptd::Type>,
#[serde(
default,
skip_serializing_if = "SourceQuery::is_non_serializable_status"
)]
pub post_status: Option<PostStatus>,
#[serde(
with = "as_string_or_list",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub audience: Vec<String>,
#[serde(
default,
skip_serializing_if = "SourceQuery::is_non_serializable_visibility"
)]
pub visibility: Option<Visibility>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default,
with = "as_string_or_list"
)]
#[cfg(feature = "experimental_channels")]
pub channel: Vec<extension::channel::Form>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default,
with = "as_string_or_list"
)]
pub syndicate_to: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default,
with = "as_string_or_list"
)]
pub category: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default,
with = "as_string_or_list"
)]
pub exists: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default,
with = "as_string_or_list"
)]
pub not_exists: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub filter: Option<String>,
#[cfg(feature = "experimental_media_query")]
#[serde(skip_serializing_if = "Option::is_none", default)]
pub mime_type: Option<extension::MimeType>,
}
impl SourceQuery {
pub fn post_status_is_published_by_default() -> PostStatus {
PostStatus::Published
}
pub fn visibility_is_public_by_default() -> Visibility {
Visibility::Public
}
pub fn is_non_serializable_visibility(visibility: &Option<Visibility>) -> bool {
visibility.is_none() || *visibility != Some(Visibility::Public)
}
pub fn is_default_post_type(post_types: &Vec<Type>) -> bool {
*post_types == vec![Type::default()]
}
pub fn is_non_serializable_status(post_status: &Option<PostStatus>) -> bool {
post_status.is_none() || *post_status != Some(PostStatus::default())
}
}
#[derive(Serialize, Deserialize, Debug, Eq)]
#[serde(rename_all = "kebab-case", tag = "q", deny_unknown_fields)]
#[non_exhaustive]
pub enum QueryKind {
#[serde(rename = "config")]
Configuration,
Source(Box<SourceQuery>),
#[cfg(feature = "experimental_channels")]
Channel,
#[serde(rename_all = "kebab-case")]
#[cfg(feature = "experimental_syndication")]
SyndicateTo {
#[serde(with = "as_string_or_list", skip_serializing_if = "Vec::is_empty")]
post_type: Vec<Type>,
},
#[serde(alias = "rel", rename_all = "kebab-case")]
#[cfg(feature = "experimental_relation")]
Relation {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rel: Vec<String>,
url: url::Url,
},
Category,
}
impl PartialEq for QueryKind {
fn eq(&self, other: &Self) -> bool {
use std::mem::discriminant;
discriminant(self) == discriminant(other)
}
}
impl Query {
#[tracing::instrument(skip(client))]
pub async fn send(
&self,
client: &impl Client,
endpoint: &url::Url,
access_token: &AccessToken,
) -> Result<Response, crate::Error> {
let mut url = endpoint.clone();
let self_string = serde_qs::to_string(&self).map_err(crate::Error::Qs)?;
if url.query().is_none() {
url.set_query(Some(self_string.as_str()));
} else {
url.query_pairs_mut()
.extend_pairs(url::form_urlencoded::parse(self_string.as_bytes()));
}
tracing::trace!(
endpoint = format!("{endpoint}"),
query = self_string,
"Sending request to Micropub server"
);
let req = http::Request::get(url.as_str())
.header(http::header::ACCEPT, CONTENT_TYPE_JSON)
.header(
http::header::AUTHORIZATION,
format!("Bearer {}", access_token.expose_secret()),
)
.body(crate::http::Body::Empty)?;
let resp = client.send_request(req).await?;
tracing::debug!(?resp, "Received Micropub query response");
crate::http::from_json_value(resp)
.and_then(|response: Response| Result::<Response, crate::Error>::from(response))
.map_err(convert_error)
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ConfigurationResponse {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub q: Vec<String>,
#[cfg(feature = "experimental_channels")]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<extension::channel::Form>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_endpoint: Option<url::Url>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub post_types: Vec<PostTypeInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg(feature = "experimental_syndication")]
pub syndicate_to: Vec<extension::syndication::Target>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub category: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
}
#[derive(PartialEq, Debug, Clone)]
pub enum PostTypeInfo {
Simple(Type),
Extended {
r#type: Type,
name: String,
properties: Option<Vec<String>>,
},
}
impl Serialize for PostTypeInfo {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
PostTypeInfo::Simple(t) => t.serialize(serializer),
PostTypeInfo::Extended {
r#type,
name,
properties,
} => {
use serde::ser::SerializeMap;
let mut map =
serializer.serialize_map(Some(if properties.is_some() { 3 } else { 2 }))?;
map.serialize_entry("type", r#type)?;
map.serialize_entry("name", name)?;
if let Some(props) = properties {
map.serialize_entry("properties", props)?;
}
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for PostTypeInfo {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let value = serde_json::Value::deserialize(deserializer)?;
if let Ok(t) = serde_json::from_value::<Type>(value.clone()) {
return Ok(PostTypeInfo::Simple(t));
}
if let Some(obj) = value.as_object() {
let r#type = obj
.get("type")
.ok_or_else(|| D::Error::missing_field("type"))
.and_then(|v| serde_json::from_value(v.clone()).map_err(D::Error::custom))?;
let name = obj
.get("name")
.ok_or_else(|| D::Error::missing_field("name"))
.and_then(|v| serde_json::from_value(v.clone()).map_err(D::Error::custom))?;
let properties = obj
.get("properties")
.map(|v| serde_json::from_value(v.clone()))
.transpose()
.map_err(D::Error::custom)?;
return Ok(PostTypeInfo::Extended {
r#type,
name,
properties,
});
}
Err(D::Error::custom(
"expected string or object for PostTypeInfo",
))
}
}
impl From<Type> for PostTypeInfo {
fn from(t: Type) -> Self {
PostTypeInfo::Simple(t)
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct SourceResponse {
#[serde(
default,
rename = "post-type",
with = "as_string_or_list",
skip_serializing_if = "Vec::is_empty"
)]
pub post_type: Vec<Type>,
#[serde(flatten)]
pub item: Item,
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct SourceListResponse {
pub items: Vec<SourceResponse>,
#[serde(flatten, skip_serializing_if = "paging::Fields::is_empty")]
pub paging: paging::Fields,
}
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct CategoryResponse {
pub categories: Vec<String>,
#[serde(flatten, skip_serializing_if = "paging::Fields::is_empty")]
pagination: paging::Fields,
}
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[cfg(feature = "experimental_syndication")]
pub struct SyndicationTargetsResponse {
pub syndicate_to: Vec<extension::syndication::Target>,
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
#[serde(untagged, rename_all = "kebab-case")]
pub enum Response {
Source(SourceResponse),
SourceList(SourceListResponse),
#[cfg(feature = "experimental_channels")]
Channel(extension::channel::QueryResponse),
Category(CategoryResponse),
#[cfg(feature = "experimental_syndication")]
SyndicateTo(SyndicationTargetsResponse),
Configuration(ConfigurationResponse),
Paginated(paging::Response),
Error(super::Error),
}
impl From<Response> for Result<Response, crate::Error> {
fn from(value: Response) -> Self {
if let Response::Error(e) = value {
Err(crate::Error::Micropub(e))
} else {
Ok(value)
}
}
}
#[cfg(test)]
mod test;