use super::known_properties::is_known_property;
use secrecy::ExposeSecret;
use std::collections::HashMap;
use http::StatusCode;
use crate::standards::indieauth::AccessToken;
use serde::Deserialize;
use crate::{
algorithms::Properties,
http::{from_json_value, Client},
standards::micropub::Parameters,
};
#[cfg(feature = "experimental_batch")]
mod batch;
#[cfg(feature = "experimental_batch")]
pub use batch::{BatchActionResponse, BatchActionResult};
#[derive(serde::Serialize, Clone, Debug, PartialEq, Eq)]
pub struct CreationProperties {
pub r#type: microformats::types::Class,
#[serde(flatten)]
pub parameters: Parameters,
#[serde(flatten)]
pub extra_fields: Properties,
}
impl Default for CreationProperties {
fn default() -> Self {
Self {
r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry),
parameters: Default::default(),
extra_fields: Default::default(),
}
}
}
impl<'de> serde::Deserialize<'de> for CreationProperties {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
struct V {}
impl V {
fn extract_parameters<E>(properties: &mut Properties) -> Result<Parameters, E>
where
E: serde::de::Error,
{
let matching_parameters = properties
.0
.iter()
.filter(|(key, _)| {
is_known_property(key)
})
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect::<serde_json::Map<String, serde_json::Value>>();
matching_parameters.keys().for_each(|key| {
properties.remove_entry(key);
});
let props: Properties =
serde_json::from_value(serde_json::Value::Object(matching_parameters))
.map_err(serde::de::Error::custom)?;
props.try_into().map_err(serde::de::Error::custom)
}
fn parse<E>(mut root_properties: Properties) -> Result<CreationProperties, E>
where
E: serde::de::Error,
{
let (type_value, parameters, extra_fields) =
if let Some(class_value) = root_properties.remove("h") {
let parameters = Self::extract_parameters(&mut root_properties)?;
root_properties.values_mut().for_each(|value| {
if !value.is_array() {
*value = serde_json::Value::Array(vec![value.clone()]);
}
});
(class_value, parameters, root_properties)
} else if let Some(class_value) = root_properties.remove("type") {
let mut properties: Properties = root_properties
.remove(PROPERTY_PROPERTIES)
.ok_or_else(|| serde::de::Error::missing_field(".properties"))
.and_then(serde_json::from_value)
.map_err(serde::de::Error::custom)?;
let parameters = Self::extract_parameters(&mut properties)?;
(class_value, parameters, properties)
} else {
return Err(serde::de::Error::unknown_variant(
"encoding",
&["json (via 'type')", "form (via 'h')"],
));
};
let r#type = Self::extract_class_name(type_value)?;
Ok(CreationProperties {
r#type,
parameters,
extra_fields,
})
}
fn extract_class_name<E>(
value: serde_json::Value,
) -> Result<microformats::types::Class, E>
where
E: serde::de::Error,
{
let specific_value = if let serde_json::Value::Array(values) = value {
values
.into_iter()
.find(|v| v.is_string())
.unwrap_or_default()
} else {
value
};
serde_json::from_value(specific_value).map_err(serde::de::Error::custom)
}
}
impl<'de> serde::de::Visitor<'de> for V {
type Value = CreationProperties;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a map of values representing either a form-encoded or JSON-encoded Micropub creation payload")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
A::Error: serde::de::Error,
{
while let Some(v) = seq.next_element()? {
if let Ok(vz) = V::parse::<A::Error>(v) {
return Ok(vz);
}
}
Err(serde::de::Error::custom(
"Failed to parse the sequence of values into a valid CreationProperties",
))
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut root_properties = Properties::default();
while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
if let Some(original_value) = root_properties.get_mut(&key) {
if let serde_json::Value::Array(values) = original_value {
values.push(value);
} else {
*original_value =
serde_json::Value::Array(vec![original_value.clone(), value]);
}
} else {
root_properties.insert(key, value);
}
}
V::parse(root_properties)
}
fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let root_properties = Properties::deserialize(deserializer)?;
V::parse(root_properties)
}
}
deserializer.deserialize_map(V {})
}
}
#[test]
fn creation_properties_from_json() {
let creation_properties = CreationProperties {
parameters: Parameters {
category: vec!["foo".into(), "bar".into()],
status: Some(crate::standards::micropub::extension::PostStatus::Drafted),
..Default::default()
},
extra_fields: Properties::try_from(serde_json::json!({
"content": ["hello world"]
}))
.expect("failed to build extra fields of creation properties"),
r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry),
};
assert_eq!(
Ok(creation_properties),
serde_json::from_value(serde_json::json!({
"type": ["h-entry"],
"properties": {
"content": ["hello world"],
"category": ["foo", "bar"],
"post-status": "draft"
}
}))
.map_err(|e| e.to_string())
);
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(untagged, rename_all = "kebab-case")]
pub enum UpdateOperation {
Replace,
Add,
Delete,
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(untagged, rename_all = "kebab-case")]
pub enum OperationValue {
WholeProperties(Vec<String>),
SpecificValues(HashMap<String, Vec<microformats::types::PropertyValue>>),
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub enum Action {
Create {
properties: Box<CreationProperties>,
files: HashMap<String, Vec<u8>>,
},
Update {
url: url::Url,
operation: (UpdateOperation, OperationValue),
},
Delete(url::Url),
Undelete(url::Url),
#[cfg(feature = "experimental_batch")]
Batch {
actions: Vec<Action>,
},
}
fn parse_location_into_url(
header_value: Option<http::HeaderValue>,
) -> Result<Option<url::Url>, crate::Error> {
if let Some(location_value) = header_value {
String::from_utf8(location_value.as_bytes().to_vec())
.map_err(crate::Error::FromUTF8)?
.parse()
.map_err(crate::Error::Url)
.map(Option::Some)
} else {
Ok(None)
}
}
fn obtain_url_from_location(
header_value: Option<http::HeaderValue>,
) -> Result<url::Url, crate::Error> {
parse_location_into_url(header_value)?
.ok_or_else(|| super::Error::missing_header("location").into())
}
fn handle_manipulation_response(
response: http::Response<crate::http::Body>,
) -> Result<(Option<serde_json::Value>, Option<url::Url>), crate::Error> {
let expected_codes = [StatusCode::OK, StatusCode::CREATED, StatusCode::NO_CONTENT];
if !expected_codes.contains(&response.status()) {
return Err(
super::Error::unexpected_status_code(response.status(), &expected_codes).into(),
);
}
let location = if response.status() == StatusCode::CREATED {
Some(obtain_url_from_location(
response.headers().get(http::header::LOCATION).cloned(),
)?)
} else {
None
};
let changes = if response.status() == StatusCode::OK {
Some(serde_json::from_slice(response.body().as_bytes())?)
} else {
None
};
Ok((changes, location))
}
impl Action {
#[tracing::instrument]
fn prepare_request(
&self,
access_token: &AccessToken,
endpoint: &url::Url,
) -> Result<http::Request<crate::http::Body>, crate::Error> {
let (content_type, bytes) = if matches!(self, Self::Create { files, .. } if files.is_empty())
{
("application/json; charset=utf-8", serde_json::to_vec(self)?)
} else {
("multipart/form-data; charset=utf-8", Vec::default())
};
http::Request::post(endpoint.as_str())
.header(http::header::ACCEPT, "application/json; charset=utf-8")
.header(http::header::CONTENT_TYPE, content_type)
.header(
http::header::AUTHORIZATION,
format!("Bearer {}", access_token.expose_secret()),
)
.body(crate::http::Body::Bytes(bytes))
.map_err(|e| e.into())
}
#[tracing::instrument(skip(client))]
pub async fn send(
&self,
client: &impl Client,
endpoint: &url::Url,
access_token: &AccessToken,
) -> Result<ActionResponse, crate::Error> {
tracing::trace!(endpoint = endpoint.to_string(), "Sending request");
let req = self.prepare_request(access_token, endpoint)?;
client
.send_request(req)
.await
.map_err(super::convert_error)
.and_then(|response: http::Response<crate::http::Body>| {
match self {
Action::Create { .. } => {
let location = obtain_url_from_location(
response.headers().get(http::header::LOCATION).cloned(),
)?;
let sync = match response.status() {
StatusCode::CREATED => true,
StatusCode::ACCEPTED => false,
_ => {
return Err(super::Error::unexpected_status_code(
response.status(),
&[StatusCode::CREATED, StatusCode::ACCEPTED],
)
.into());
}
};
let rel = microformats::types::Relations::default();
Ok(ActionResponse::Created {
sync,
location,
rel,
})
}
Action::Delete(_) => {
let expected_codes = [StatusCode::OK, StatusCode::NO_CONTENT];
if !expected_codes.contains(&response.status()) {
return Err(super::Error::unexpected_status_code(
response.status(),
&expected_codes,
)
.into());
}
let changes = if response.status() == StatusCode::OK {
Some(from_json_value(response)?)
} else {
None
};
Ok(ActionResponse::Deleted(changes))
}
Action::Update { .. } => {
let (changes, location) = handle_manipulation_response(response)?;
Ok(ActionResponse::Updated { changes, location })
}
Action::Undelete(_) => {
let (changes, location) = handle_manipulation_response(response)?;
Ok(ActionResponse::Undeleted { changes, location })
}
#[cfg(feature = "experimental_batch")]
Action::Batch { .. } => {
let expected_codes = [StatusCode::OK, StatusCode::CREATED, StatusCode::ACCEPTED];
if !expected_codes.contains(&response.status()) {
return Err(super::Error::unexpected_status_code(
response.status(),
&expected_codes,
)
.into());
}
let batch_response: BatchActionResponse = from_json_value(response)?;
Ok(ActionResponse::Batch(batch_response))
}
}
})
}
pub fn into_json(&self) -> serde_json::Value {
match self {
Action::Create { properties, .. } => {
let mut all_properties: serde_json::Map<String, serde_json::Value> =
serde_json::to_value(properties.parameters.clone())
.map(|v| v.as_object().cloned().unwrap_or_default())
.unwrap_or_default();
all_properties.extend(properties.extra_fields.0.clone());
serde_json::json!({
"type": [properties.r#type],
"properties": all_properties
})
}
Action::Update {
url: updated_url,
operation,
} => {
let mut all_properties: serde_json::Map<String, serde_json::Value> =
Default::default();
all_properties.insert("action".to_string(), "update".into());
all_properties.insert("url".to_string(), updated_url.to_string().into());
let (operation, operation_values) = operation;
let operational_key = match *operation {
UpdateOperation::Replace => "replace".to_string(),
UpdateOperation::Add => "add".to_string(),
UpdateOperation::Delete => "delete".to_string(),
};
all_properties.insert(operational_key, serde_json::json!(operation_values));
serde_json::json!(all_properties)
}
Action::Delete(deleted_url) => serde_json::json!({
"action": "delete",
"url": deleted_url
}),
Action::Undelete(undeleted_url) => serde_json::json!({
"action": "undelete",
"url": undeleted_url
}),
#[cfg(feature = "experimental_batch")]
Action::Batch { actions } => {
let actions_json: Vec<serde_json::Value> = actions
.iter()
.map(|a| a.into_json())
.collect();
serde_json::json!({
"action": "batch",
"actions": actions_json
})
}
}
}
}
const PROPERTY_ACTION: &str = "action";
const PROPERTY_PROPERTIES: &str = "properties";
const PROPERTY_URL: &str = "url";
const PROPERTY_ADD: &str = "add";
const PROPERTY_DELETE: &str = "delete";
const PROPERTY_REPLACE: &str = "replace";
impl<'de> serde::Deserialize<'de> for Action {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let mut params: serde_json::Map<String, serde_json::Value> =
serde_json::Map::deserialize(deserializer)?;
let action_value = params.remove(PROPERTY_ACTION).unwrap_or_default();
if action_value == serde_json::Value::String("update".into()) {
let url_value = params
.remove(PROPERTY_URL)
.ok_or_else(|| serde::de::Error::missing_field("url"))
.and_then(|uv| serde_json::from_value(uv).map_err(serde::de::Error::custom))?;
let delete_value = params.remove(PROPERTY_DELETE);
let add_value = params.remove(PROPERTY_ADD);
let replace_value = params.remove(PROPERTY_REPLACE);
let operation = if let Some(value) = replace_value {
serde_json::from_value(value)
.map(|rv: OperationValue| (UpdateOperation::Replace, rv))
.map_err(serde::de::Error::custom)
} else if let Some(value) = add_value {
serde_json::from_value(value)
.map(|rv: OperationValue| (UpdateOperation::Add, rv))
.map_err(serde::de::Error::custom)
} else if let Some(value) = delete_value {
serde_json::from_value(value)
.map(|rv: OperationValue| (UpdateOperation::Delete, rv))
.map_err(serde::de::Error::custom)
} else {
serde_json::from_value(serde_json::Value::Object(params))
.map(|rv| (UpdateOperation::Replace, rv))
.map_err(serde::de::Error::custom)
};
operation.map(|operation| Self::Update {
url: url_value,
operation,
})
} else if action_value == serde_json::Value::String("delete".into()) {
params
.remove(PROPERTY_URL)
.ok_or_else(|| serde::de::Error::missing_field("url"))
.and_then(|uv| serde_json::from_value(uv).map_err(serde::de::Error::custom))
.map(Self::Delete)
} else if action_value == serde_json::Value::String("undelete".into()) {
params
.remove(PROPERTY_URL)
.ok_or_else(|| serde::de::Error::missing_field("url"))
.and_then(|uv| serde_json::from_value(uv).map_err(serde::de::Error::custom))
.map(Self::Undelete)
} else if action_value == serde_json::Value::String("batch".into()) {
#[cfg(feature = "experimental_batch")]
{
let actions_value = params
.remove("actions")
.ok_or_else(|| serde::de::Error::missing_field("actions"))?;
let actions: Vec<Action> = serde_json::from_value(actions_value)
.map_err(serde::de::Error::custom)?;
Ok(Self::Batch { actions })
}
#[cfg(not(feature = "experimental_batch"))]
{
Err(serde::de::Error::custom(
"batch actions require the 'experimental_batch' feature to be enabled",
))
}
} else {
let properties = serde_json::from_value(serde_json::Value::Object(params))
.map_err(serde::de::Error::custom)?;
Ok(Self::Create {
properties,
files: Default::default(),
})
}
}
}
#[test]
fn action_representation_into_json_create() {
let creation_properties = CreationProperties {
parameters: Parameters {
category: vec!["foo".into(), "bar".into()],
status: Some(crate::standards::micropub::extension::PostStatus::Drafted),
..Default::default()
},
extra_fields: Properties::try_from(serde_json::json!({
"content": ["hello world"]
}))
.expect("failed to build extra fields of creation properties"),
r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry),
};
assert_eq!(
Action::Create {
properties: Box::new(creation_properties),
files: Default::default(),
}
.into_json(),
serde_json::json!({
"type": ["h-entry"],
"properties": {
"category": ["foo", "bar"],
"content": ["hello world"],
"status": "draft"
}
}),
"converts object into payload JSON"
)
}
#[test]
fn action_representation_into_json_update_delete() {
let object = Action::Update {
url: "http://example.com".parse().unwrap(),
operation: (
UpdateOperation::Delete,
OperationValue::WholeProperties(vec!["content".to_string()]),
),
};
assert_eq!(
object.into_json(),
serde_json::json!({
"action": "update",
"url": "http://example.com/",
"delete": ["content"]
}),
"converts object into payload JSON for a update-delete operation"
)
}
#[test]
fn action_representation_into_json_delete() {
assert_eq!(
Action::Delete("http://example.com".parse().unwrap()).into_json(),
serde_json::json!({
"action": "delete",
"url": "http://example.com/",
}),
"converts object into payload JSON for a delete operation"
);
}
#[test]
fn action_representation_into_json_undelete() {
assert_eq!(
Action::Undelete("http://example.com".parse().unwrap()).into_json(),
serde_json::json!({
"action": "undelete",
"url": "http://example.com/",
}),
"converts object into payload JSON for a undelete operation"
);
}
#[test]
fn action_representation_create() {
crate::test::Client::default();
let creation_properties = CreationProperties {
parameters: Parameters {
category: vec!["foo".into(), "bar".into()],
..Default::default()
},
extra_fields: Properties::try_from(serde_json::json!({
"content": ["hello world"],
"photo": ["https://photos.example.com/592829482876343254.jpg"]
}))
.expect("failed to build extra fields of creation properties"),
r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry),
};
assert_eq!(
serde_json::from_value(serde_json::json!({
"type": ["h-entry"],
"properties": {
"content": ["hello world"],
"category": ["foo","bar"],
"photo": ["https://photos.example.com/592829482876343254.jpg"]
}
}))
.map_err(|e| e.to_string()),
Ok(Action::Create {
properties: Box::new(creation_properties.clone()),
files: Default::default()
}),
"converts create payload from JSON"
);
assert_eq!(
serde_qs::from_str("h=entry&content=hello+world&category[]=foo&category[]=bar&photo=https://photos.example.com/592829482876343254.jpg")
.map_err(|e| e.to_string()),
Ok(Action::Create {
properties: Box::new(creation_properties.clone()),
files: Default::default()
}),
"converts create payload from form encoded payload"
);
}
#[test]
fn action_representation_delete() {
crate::test::Client::default();
assert_eq!(
serde_json::from_value(
serde_json::json!({"action": "delete", "url": "http://example.com"})
)
.map_err(|e| e.to_string()),
Ok(Action::Delete("http://example.com".parse().unwrap())),
"converts delete payload from JSON"
);
assert_eq!(
serde_json::from_value(
serde_json::json!({"action": "undelete", "url": "http://example.com"})
)
.map_err(|e| e.to_string()),
Ok(Action::Undelete("http://example.com".parse().unwrap())),
"converts undelete payload from JSON"
)
}
#[test]
fn action_representation_update_replace() {
crate::test::Client::default();
assert_eq!(
serde_json::from_value(serde_json::json!({
"action": "update",
"url": "http://example.com",
"replace": {
"category": ["tag1", "tag2"]
}
}))
.map_err(|e| e.to_string()),
Ok(Action::Update {
url: "http://example.com".parse().unwrap(),
operation: (
UpdateOperation::Replace,
serde_json::from_value(serde_json::json!({
"category": ["tag1", "tag2"]
}))
.unwrap()
)
}),
"converts upload payload for replace from JSON"
);
}
#[test]
fn action_representation_update_delete() {
crate::test::Client::default();
assert_eq!(
serde_json::from_value(serde_json::json!({
"action": "update",
"url": "http://example.com",
"delete": {
"category": ["tag1", "tag2"]
}
}))
.map_err(|e| e.to_string()),
Ok(Action::Update {
url: "http://example.com".parse().unwrap(),
operation: (
UpdateOperation::Delete,
serde_json::from_value(serde_json::json!({
"category": ["tag1", "tag2"]
}))
.unwrap()
)
}),
"converts upload payload from delete from JSON"
);
}
#[test]
fn action_representation_update_replace_whole() {
crate::test::Client::default();
assert_eq!(
serde_json::from_value(serde_json::json!({
"action": "update",
"url": "http://example.com",
"add": ["category"]
}))
.map_err(|e| e.to_string()),
Ok(Action::Update {
url: "http://example.com".parse().unwrap(),
operation: (
UpdateOperation::Add,
serde_json::from_value(serde_json::json!(["category"])).unwrap()
)
}),
"converts upload payload from add from JSON"
);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ActionResponse {
Created {
sync: bool,
location: url::Url,
rel: microformats::types::Relations,
},
Updated {
changes: Option<serde_json::Value>,
location: Option<url::Url>,
},
Deleted(Option<serde_json::Value>),
Undeleted {
location: Option<url::Url>,
changes: Option<serde_json::Value>,
},
#[cfg(feature = "experimental_batch")]
Batch(BatchActionResponse),
}
mod test;