about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock3
-rw-r--r--Cargo.toml4
-rw-r--r--src/database/mod.rs2
-rw-r--r--src/database/postgres/mod.rs2
-rw-r--r--src/media/mod.rs2
-rw-r--r--src/micropub/mod.rs87
-rw-r--r--templates-neo/Cargo.toml2
-rw-r--r--templates/Cargo.toml2
-rw-r--r--templates/src/templates.rs4
-rw-r--r--util/Cargo.toml18
-rw-r--r--util/src/error.rs95
-rw-r--r--util/src/lib.rs16
-rw-r--r--util/src/micropub.rs173
13 files changed, 242 insertions, 168 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 3b4d347..be9a1b2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1733,7 +1733,7 @@ dependencies = [
 
 [[package]]
 name = "kittybox-util"
-version = "0.1.0"
+version = "0.2.0"
 dependencies = [
  "async-trait",
  "axum-core",
@@ -1744,6 +1744,7 @@ dependencies = [
  "serde_json",
  "sqlx",
  "tokio",
+ "url",
  "uuid",
 ]
 
diff --git a/Cargo.toml b/Cargo.toml
index 958f605..b5346b4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,9 +56,9 @@ required-features = ["sqlparser"]
 members = [".", "./util", "./templates", "./indieauth", "./templates-neo"]
 default-members = [".", "./util", "./templates", "./indieauth"]
 [dependencies.kittybox-util]
-version = "0.1.0"
+version = "0.2.0"
 path = "./util"
-features = ["fs"]
+features = ["fs", "axum"]
 [dependencies.kittybox-frontend-renderer]
 version = "0.1.0"
 path = "./templates"
diff --git a/src/database/mod.rs b/src/database/mod.rs
index ac8b43c..d60ac05 100644
--- a/src/database/mod.rs
+++ b/src/database/mod.rs
@@ -17,7 +17,7 @@ mod memory;
 #[cfg(test)]
 pub use crate::database::memory::MemoryStorage;
 
-pub use kittybox_util::MicropubChannel;
+pub use kittybox_util::micropub::Channel as MicropubChannel;
 
 use self::settings::Setting;
 
diff --git a/src/database/postgres/mod.rs b/src/database/postgres/mod.rs
index 7f788a8..3aef08e 100644
--- a/src/database/postgres/mod.rs
+++ b/src/database/postgres/mod.rs
@@ -1,6 +1,6 @@
 use std::borrow::Cow;
 
-use kittybox_util::{MicropubChannel, MentionType};
+use kittybox_util::{micropub::Channel as MicropubChannel, MentionType};
 use sqlx::{ConnectOptions, Executor, PgPool};
 use crate::micropub::{MicropubUpdate, MicropubPropertyDeletion};
 
diff --git a/src/media/mod.rs b/src/media/mod.rs
index 7884ef8..85b3b87 100644
--- a/src/media/mod.rs
+++ b/src/media/mod.rs
@@ -3,7 +3,7 @@ use axum::{
 };
 use axum_extra::headers::{HeaderMapExt, HeaderValue, IfNoneMatch};
 use axum_extra::TypedHeader;
-use kittybox_util::error::{MicropubError, ErrorType};
+use kittybox_util::micropub::{Error as MicropubError, ErrorKind as ErrorType};
 use kittybox_indieauth::Scope;
 use crate::indieauth::{backend::AuthBackend, User};
 
diff --git a/src/micropub/mod.rs b/src/micropub/mod.rs
index 63b81c5..65519e4 100644
--- a/src/micropub/mod.rs
+++ b/src/micropub/mod.rs
@@ -18,17 +18,7 @@ use tokio::sync::Mutex;
 use tokio::task::JoinSet;
 use tracing::{debug, error, info, warn};
 use kittybox_indieauth::{Scope, TokenData};
-use kittybox_util::{MicropubError, ErrorType};
-
-#[derive(Serialize, Deserialize, Debug, PartialEq)]
-#[serde(rename_all = "kebab-case")]
-enum QueryType {
-    Source,
-    Config,
-    Channel,
-    SyndicateTo,
-    Category
-}
+use kittybox_util::micropub::{Error as MicropubError, ErrorKind, QueryType};
 
 #[derive(Serialize, Deserialize, Debug)]
 pub struct MicropubQuery {
@@ -40,8 +30,8 @@ impl From<StorageError> for MicropubError {
     fn from(err: StorageError) -> Self {
         Self {
             error: match err.kind() {
-                crate::database::ErrorKind::NotFound => ErrorType::NotFound,
-                _ => ErrorType::InternalServerError,
+                crate::database::ErrorKind::NotFound => ErrorKind::NotFound,
+                _ => ErrorKind::InternalServerError,
             },
             error_description: format!("Backend error: {}", err),
         }
@@ -257,7 +247,7 @@ pub(crate) async fn _post<D: 'static + Storage>(
     // Security check! Do we have an OAuth2 scope to proceed?
     if !user.check_scope(&Scope::Create) {
         return Err(MicropubError {
-            error: ErrorType::InvalidScope,
+            error: ErrorKind::InvalidScope,
             error_description: "Not enough privileges - try acquiring the \"create\" scope."
                 .to_owned(),
         });
@@ -272,7 +262,7 @@ pub(crate) async fn _post<D: 'static + Storage>(
             .any(|url| !url.as_str().unwrap().starts_with(user.me.as_str()))
     {
         return Err(MicropubError {
-            error: ErrorType::Forbidden,
+            error: ErrorKind::Forbidden,
             error_description: "You're posting to a website that's not yours.".to_owned(),
         });
     }
@@ -280,7 +270,7 @@ pub(crate) async fn _post<D: 'static + Storage>(
     // Security check #3! Are we overwriting an existing document?
     if db.post_exists(&uid).await? {
         return Err(MicropubError {
-            error: ErrorType::AlreadyExists,
+            error: ErrorKind::AlreadyExists,
             error_description: "UID clash was detected, operation aborted.".to_owned(),
         });
     }
@@ -399,7 +389,7 @@ async fn post_action<D: Storage, A: AuthBackend>(
         uri
     } else {
         return Err(MicropubError {
-            error: ErrorType::InvalidRequest,
+            error: ErrorKind::InvalidRequest,
             error_description: "Your URL doesn't parse properly.".to_owned(),
         });
     };
@@ -414,7 +404,7 @@ async fn post_action<D: Storage, A: AuthBackend>(
             .unwrap()
     {
         return Err(MicropubError {
-            error: ErrorType::Forbidden,
+            error: ErrorKind::Forbidden,
             error_description: "Don't tamper with others' posts!".to_owned(),
         });
     }
@@ -423,7 +413,7 @@ async fn post_action<D: Storage, A: AuthBackend>(
         ActionType::Delete => {
             if !user.check_scope(&Scope::Delete) {
                 return Err(MicropubError {
-                    error: ErrorType::InvalidScope,
+                    error: ErrorKind::InvalidScope,
                     error_description: "You need a \"delete\" scope for this.".to_owned(),
                 });
             }
@@ -433,7 +423,7 @@ async fn post_action<D: Storage, A: AuthBackend>(
         ActionType::Update => {
             if !user.check_scope(&Scope::Update) {
                 return Err(MicropubError {
-                    error: ErrorType::InvalidScope,
+                    error: ErrorKind::InvalidScope,
                     error_description: "You need an \"update\" scope for this.".to_owned(),
                 });
             }
@@ -441,7 +431,7 @@ async fn post_action<D: Storage, A: AuthBackend>(
             db.update_post(
                 &action.url,
                 action.update.ok_or(MicropubError {
-                    error: ErrorType::InvalidRequest,
+                    error: ErrorKind::InvalidRequest,
                     error_description: "Update request is not set.".to_owned(),
                 })?
             )
@@ -483,7 +473,7 @@ async fn dispatch_body(
             // quick sanity check
             if !body.is_object() || !body["type"].is_array() {
                 return Err(MicropubError {
-                    error: ErrorType::InvalidRequest,
+                    error: ErrorKind::InvalidRequest,
                     error_description: "Invalid MF2-JSON detected: `.` should be an object, `.type` should be an array of MF2 types".to_owned()
                 });
             }
@@ -491,7 +481,7 @@ async fn dispatch_body(
             Ok(PostBody::MF2(body))
         } else {
             Err(MicropubError {
-                error: ErrorType::InvalidRequest,
+                error: ErrorKind::InvalidRequest,
                 error_description: "Invalid JSON object passed.".to_owned(),
             })
         }
@@ -502,14 +492,14 @@ async fn dispatch_body(
             Ok(PostBody::MF2(form_to_mf2_json(body)))
         } else {
             Err(MicropubError {
-                error: ErrorType::InvalidRequest,
+                error: ErrorKind::InvalidRequest,
                 error_description: "Invalid form-encoded data. Try h=entry&content=Hello!"
                     .to_owned(),
             })
         }
     } else {
         Err(MicropubError::new(
-            ErrorType::UnsupportedMediaType,
+            ErrorKind::UnsupportedMediaType,
             "This Content-Type is not recognized. Try application/json instead?",
         ))
     }
@@ -553,7 +543,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>(
         query
     } else {
         return MicropubError::new(
-            ErrorType::InvalidRequest,
+            ErrorKind::InvalidRequest,
             "Invalid query provided. Try ?q=config to see what you can do."
         ).into_response();
     };
@@ -565,7 +555,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>(
         != &host
     {
         return MicropubError::new(
-            ErrorType::NotAuthorized,
+            ErrorKind::NotAuthorized,
             "This website doesn't belong to you.",
         )
             .into_response();
@@ -585,26 +575,31 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>(
                 Ok(chans) => chans,
                 Err(err) => {
                     return MicropubError::new(
-                        ErrorType::InternalServerError,
+                        ErrorKind::InternalServerError,
                         &format!("Error fetching channels: {}", err),
                     )
                     .into_response()
                 }
             };
 
-            axum::response::Json(json!({
-                "q": [
+            axum::response::Json(kittybox_util::micropub::Config {
+                q: vec![
                     QueryType::Source,
                     QueryType::Config,
                     QueryType::Channel,
                     QueryType::SyndicateTo,
                     QueryType::Category
                 ],
-                "channels": channels,
-                "_kittybox_authority": user.me.as_str(),
-                "syndicate-to": [],
-                "media-endpoint": user.me.join("/.kittybox/media").unwrap().as_str()
-            }))
+                channels: Some(channels),
+                syndicate_to: None,
+                media_endpoint: Some(user.me.join("/.kittybox/media").unwrap()),
+                other: {
+                    let mut map = std::collections::HashMap::new();
+                    map.insert("kittybox_authority".to_string(), serde_json::Value::String(user.me.to_string()));
+
+                    map
+                }
+            })
             .into_response()
         }
         QueryType::Source => {
@@ -614,13 +609,13 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>(
                         Ok(some) => match some {
                             Some(post) => axum::response::Json(&post).into_response(),
                             None => MicropubError::new(
-                                ErrorType::NotFound,
+                                ErrorKind::NotFound,
                                 "The specified MF2 object was not found in database.",
                             )
                             .into_response(),
                         },
                         Err(err) => MicropubError::new(
-                            ErrorType::InternalServerError,
+                            ErrorKind::InternalServerError,
                             &format!("Backend error: {}", err),
                         )
                         .into_response(),
@@ -631,7 +626,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>(
                     // Using a pre-made query function can't be done because it does unneeded filtering
                     // Don't implement for now, this is optional
                     MicropubError::new(
-                        ErrorType::InvalidRequest,
+                        ErrorKind::InvalidRequest,
                         "Querying for post list is not implemented yet.",
                     )
                     .into_response()
@@ -641,7 +636,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>(
         QueryType::Channel => match db.get_channels(&user.me).await {
             Ok(chans) => axum::response::Json(json!({ "channels": chans })).into_response(),
             Err(err) => MicropubError::new(
-                ErrorType::InternalServerError,
+                ErrorKind::InternalServerError,
                 &format!("Error fetching channels: {}", err),
             )
             .into_response(),
@@ -654,13 +649,17 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>(
                 Ok(categories) => categories,
                 Err(err) => {
                     return MicropubError::new(
-                        ErrorType::InternalServerError,
+                        ErrorKind::InternalServerError,
                         &format!("Error fetching categories: {}", err)
                     ).into_response()
                 }
             };
             axum::response::Json(json!({ "categories": categories })).into_response()
-        }
+        },
+        QueryType::Unknown(q) => return MicropubError {
+            error: ErrorKind::InvalidRequest,
+            error_description: format!("Invalid query: {}", q)
+        }.into_response(),
     }
 }
 
@@ -776,7 +775,7 @@ mod tests {
             .await
             .unwrap_err();
 
-        assert_eq!(err.error, super::ErrorType::InvalidScope);
+        assert_eq!(err.error, super::ErrorKind::InvalidScope);
 
         let hashmap = db.mapping.read().await;
         assert!(hashmap.is_empty());
@@ -806,7 +805,7 @@ mod tests {
             .await
             .unwrap_err();
 
-        assert_eq!(err.error, super::ErrorType::Forbidden);
+        assert_eq!(err.error, super::ErrorKind::Forbidden);
 
         let hashmap = db.mapping.read().await;
         assert!(hashmap.is_empty());
@@ -873,6 +872,6 @@ mod tests {
             .by_ref()
             .fold(Vec::new(), |mut a, i| { a.extend(i); a});
         let json: MicropubError = serde_json::from_slice(&body as &[u8]).unwrap();
-        assert_eq!(json.error, super::ErrorType::NotAuthorized);
+        assert_eq!(json.error, super::ErrorKind::NotAuthorized);
     }
 }
diff --git a/templates-neo/Cargo.toml b/templates-neo/Cargo.toml
index ed88873..3f58b42 100644
--- a/templates-neo/Cargo.toml
+++ b/templates-neo/Cargo.toml
@@ -32,7 +32,7 @@ features = ["formatting"]
 version = "^0.4.19"
 features = ["serde"]
 [dependencies.kittybox-util]
-version = "0.1.0"
+version = "0.2.0"
 path = "../util"
 [dependencies.kittybox-indieauth]
 version = "0.2.0"
diff --git a/templates/Cargo.toml b/templates/Cargo.toml
index 6209e76..5281418 100644
--- a/templates/Cargo.toml
+++ b/templates/Cargo.toml
@@ -29,7 +29,7 @@ axum = "^0.7.5"
 version = "^0.4.19"
 features = ["serde"]
 [dependencies.kittybox-util]
-version = "0.1.0"
+version = "0.2.0"
 path = "../util"
 [dependencies.kittybox-indieauth]
 version = "0.2.0"
diff --git a/templates/src/templates.rs b/templates/src/templates.rs
index 3d22eac..0f55927 100644
--- a/templates/src/templates.rs
+++ b/templates/src/templates.rs
@@ -1,9 +1,9 @@
 use http::StatusCode;
-use kittybox_util::MicropubChannel;
+use kittybox_util::micropub::Channel;
 use crate::{Feed, VCard};
 
 markup::define! {
-    Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<MicropubChannel>, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) {
+    Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<Channel>, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) {
         @markup::doctype()
         html {
             head {
diff --git a/util/Cargo.toml b/util/Cargo.toml
index 3d8327c..9a6558b 100644
--- a/util/Cargo.toml
+++ b/util/Cargo.toml
@@ -1,21 +1,23 @@
 [package]
 name = "kittybox-util"
-version = "0.1.0"
+version = "0.2.0"
 edition = "2021"
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [features]
-fs = ["rand", "tokio", "tokio/fs"]
+fs = ["dep:rand", "dep:tokio", "tokio/fs"]
+sqlx = ["dep:sqlx"]
+axum = ["dep:axum-core", "http"]
+http = ["dep:http"]
 
 [dependencies]
 serde = { version = "^1.0.170", features = ["derive"] }
 serde_json = "^1.0.64"
-axum-core = "^0.4.3"
-http = "^1.0"
 async-trait = "^0.1.50"
 futures-util = "^0.3.14"
 uuid = "^1.3.3"
+url = "2.5.2"
 [dependencies.rand]
 version = "^0.8.5"
 optional = true
@@ -26,4 +28,10 @@ optional = true
 [dependencies.sqlx]
 version = "0.8"
 features = ["json"]
-optional = true
\ No newline at end of file
+optional = true
+[dependencies.axum-core]
+version = "^0.4.3"
+optional = true
+[dependencies.http]
+version = "^1.0"
+optional = true
diff --git a/util/src/error.rs b/util/src/error.rs
deleted file mode 100644
index 1c95020..0000000
--- a/util/src/error.rs
+++ /dev/null
@@ -1,95 +0,0 @@
-use serde::{Deserialize, Serialize};
-use http::StatusCode;
-use axum_core::response::{Response, IntoResponse};
-
-#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
-#[serde(rename_all = "snake_case")]
-/// Kinds of errors that can happen within a Micropub operation.
-pub enum ErrorType {
-    /// An erroneous attempt to create something that already exists.
-    AlreadyExists,
-    /// Current user is expressly forbidden from performing this action.
-    Forbidden,
-    /// The Micropub server experienced an internal error.
-    InternalServerError,
-    /// The request was invalid or malformed.
-    InvalidRequest,
-    /// The provided OAuth2 scopes were insufficient to allow performing this action.
-    InvalidScope,
-    /// There was no token or other means of authorization in the request.
-    NotAuthorized,
-    /// Whatever was requested was not found.
-    NotFound,
-    /// The request payload was of a type unsupported by the Micropub endpoint.
-    UnsupportedMediaType,
-}
-
-/// Representation of the Micropub API error.
-#[derive(Serialize, Deserialize, Debug)]
-pub struct MicropubError {
-    /// General kind of an error that occured.
-    pub error: ErrorType,
-    /// A human-readable error description intended for application developers.
-    // TODO use Cow<'static, str> to save on heap allocations
-    pub error_description: String,
-}
-
-impl std::error::Error for MicropubError {}
-
-impl std::fmt::Display for MicropubError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.write_str("Micropub error: ")?;
-        f.write_str(&self.error_description)
-    }
-}
-
-impl From<serde_json::Error> for MicropubError {
-    fn from(err: serde_json::Error) -> Self {
-        use ErrorType::*;
-        Self {
-            error: InvalidRequest,
-            error_description: err.to_string(),
-        }
-    }
-}
-
-impl MicropubError {
-    /// Create a new Micropub error.
-    pub fn new(error: ErrorType, error_description: &str) -> Self {
-        Self {
-            error,
-            error_description: error_description.to_owned(),
-        }
-    }
-}
-
-impl From<&MicropubError> for StatusCode {
-    fn from(err: &MicropubError) -> Self {
-        use ErrorType::*;
-        match err.error {
-            AlreadyExists => StatusCode::CONFLICT,
-            Forbidden => StatusCode::FORBIDDEN,
-            InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
-            InvalidRequest => StatusCode::BAD_REQUEST,
-            InvalidScope => StatusCode::UNAUTHORIZED,
-            NotAuthorized => StatusCode::UNAUTHORIZED,
-            NotFound => StatusCode::NOT_FOUND,
-            UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
-        }
-    }
-}
-impl From<MicropubError> for StatusCode {
-    fn from(err: MicropubError) -> Self {
-        (&err).into()
-    }
-}
-
-impl IntoResponse for MicropubError {
-    fn into_response(self) -> Response {
-        IntoResponse::into_response((
-            StatusCode::from(&self),
-            [("Content-Type", "application/json")],
-            serde_json::to_string(&self).unwrap(),
-        ))
-    }
-}
diff --git a/util/src/lib.rs b/util/src/lib.rs
index c840e59..a919fd8 100644
--- a/util/src/lib.rs
+++ b/util/src/lib.rs
@@ -14,16 +14,6 @@ pub struct IndiewebEndpoints {
     pub microsub: Option<String>,
 }
 
-/// Data structure representing a Micropub channel in the ?q=channels output.
-#[derive(Serialize, Deserialize, PartialEq, Debug)]
-#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
-pub struct MicropubChannel {
-    /// The channel's UID. It is usually also a publically accessible permalink URL.
-    pub uid: String,
-    /// The channel's user-friendly name used to recognize it in lists.
-    pub name: String,
-}
-
 #[derive(Debug, Default)]
 /// Common types of webmentions.
 pub enum MentionType {
@@ -40,10 +30,6 @@ pub enum MentionType {
     Mention
 }
 
-/// Common errors from the IndieWeb protocols that can be reused between modules.
-pub mod error;
-pub use error::{ErrorType, MicropubError};
-
 /// Common data-types useful in creating smart authentication systems.
 pub mod auth {
     #[derive(PartialEq, Eq, Hash, Clone, Copy)]
@@ -57,6 +43,8 @@ pub mod auth {
     }
 }
 
+pub mod micropub;
+
 /// A collection of traits for implementing a robust job queue.
 pub mod queue;
 
diff --git a/util/src/micropub.rs b/util/src/micropub.rs
new file mode 100644
index 0000000..1a3bcf3
--- /dev/null
+++ b/util/src/micropub.rs
@@ -0,0 +1,173 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+/// Query types supported by a Micropub server (as in ?q=<type>).
+pub enum QueryType {
+    /// Query source URL or recent posts (subject to extension implementation)
+    Source,
+    /// Query config (mandatory)
+    Config,
+    /// Query available channels
+    Channel,
+    /// Query available syndication destinations
+    SyndicateTo,
+    /// Query known categories/tags
+    Category,
+    /// Unsupported query type
+    // TODO: make this take a lifetime parameter for zero-copy deserialization if possible?
+    Unknown(std::borrow::Cow<'static, str>)
+}
+
+/// Data structure representing a Micropub channel in the ?q=channels output.
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
+pub struct Channel {
+    /// The channel's UID, opaque to the client.
+    pub uid: String,
+    /// A human-friendly name.
+    pub name: String,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+/// A destination place to syndicate a post to.
+///
+/// Commonly seen as part of [`?q=syndicate-to`][QueryType::SyndicateTo].
+pub struct SyndicationDestination {
+    /// The syndication destination's UID, opaque to the client.
+    pub uid: String,
+    /// A human-friendly name.
+    pub name: String
+}
+
+fn default_q_list() -> Vec<QueryType> {
+    vec![QueryType::Config]
+}
+/// ?q=config output of a Micropub endpoint.
+#[derive(serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Config {
+    /// Query types supported by this server.
+    #[serde(default = "default_q_list")]
+    pub q: Vec<QueryType>,
+    /// List of channels this server provides.
+    /// If [`None`][Option::None], you may want to consult [`?q=channel`][QueryType::Channel].
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub channels: Option<Vec<Channel>>,
+    /// List of available syndication destinations.
+    /// If [`None`][Option::None], you may want to consult [`?q=channel`][QueryType::Channel].
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub syndicate_to: Option<Vec<SyndicationDestination>>,
+    /// URL for a Media Endpoint, if any.
+    pub media_endpoint: Option<url::Url>,
+    /// Other unspecified keys, sometimes implementation-defined.
+    #[serde(flatten)]
+    pub other: HashMap<String, serde_json::Value>
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
+#[serde(rename_all = "snake_case")]
+/// Kinds of errors that can happen within a Micropub operation.
+pub enum ErrorKind {
+    /// An erroneous attempt to create something that already exists.
+    AlreadyExists,
+    /// Current user is expressly forbidden from performing this action.
+    Forbidden,
+    /// The Micropub server experienced an internal error.
+    InternalServerError,
+    /// The request was invalid or malformed.
+    InvalidRequest,
+    /// The provided OAuth2 scopes were insufficient to allow performing this action.
+    InvalidScope,
+    /// There was no token or other means of authorization in the request.
+    NotAuthorized,
+    /// Whatever was requested was not found.
+    NotFound,
+    /// The request payload was of a type unsupported by the Micropub endpoint.
+    UnsupportedMediaType,
+}
+
+/// Representation of the Micropub API error.
+#[derive(Serialize, Deserialize, Debug)]
+pub struct Error {
+    /// General kind of an error that occured.
+    pub error: ErrorKind,
+    /// A human-readable error description intended for application developers.
+    // TODO use Cow<'static, str> to save on heap allocations
+    pub error_description: String,
+}
+
+impl std::error::Error for Error {}
+
+impl std::fmt::Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let s = if let serde_json::Value::String(s) = serde_json::to_value(&self.error).unwrap() {
+            s
+        } else {
+            unreachable!()
+        };
+
+        f.write_str(&s)?;
+        f.write_str(" (")?;
+        f.write_str(&self.error_description)?;
+        f.write_str(")")
+    }
+}
+
+impl From<serde_json::Error> for Error {
+    fn from(err: serde_json::Error) -> Self {
+        use ErrorKind::*;
+        Self {
+            error: InvalidRequest,
+            error_description: err.to_string(),
+        }
+    }
+}
+
+impl Error {
+    /// Create a new Micropub error.
+    pub fn new(error: ErrorKind, error_description: &str) -> Self {
+        Self {
+            error,
+            error_description: error_description.to_owned(),
+        }
+    }
+}
+
+#[cfg(feature = "http")]
+impl From<&Error> for http::StatusCode {
+    fn from(err: &Error) -> Self {
+        use ErrorKind::*;
+        match err.error {
+            AlreadyExists => http::StatusCode::CONFLICT,
+            Forbidden => http::StatusCode::FORBIDDEN,
+            InternalServerError => http::StatusCode::INTERNAL_SERVER_ERROR,
+            InvalidRequest => http::StatusCode::BAD_REQUEST,
+            InvalidScope => http::StatusCode::UNAUTHORIZED,
+            NotAuthorized => http::StatusCode::UNAUTHORIZED,
+            NotFound => http::StatusCode::NOT_FOUND,
+            UnsupportedMediaType => http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
+        }
+    }
+}
+
+#[cfg(feature = "http")]
+impl From<Error> for http::StatusCode {
+    fn from(err: Error) -> Self {
+        (&err).into()
+    }
+}
+
+#[cfg(feature = "axum")]
+impl axum_core::response::IntoResponse for Error {
+    fn into_response(self) -> axum_core::response::Response {
+        axum_core::response::IntoResponse::into_response((
+            http::StatusCode::from(&self),
+            [("Content-Type", "application/json")],
+            serde_json::to_string(&self).unwrap(),
+        ))
+    }
+}
+