From d6a8525274ccd96a5ce9540ea2ba1e463c84ff90 Mon Sep 17 00:00:00 2001 From: Vika Date: Tue, 20 Aug 2024 00:35:30 +0300 Subject: kittybox-util: 0.1.0 -> 0.2.0 Micropub types are now more coherent and gathered in one place. --- Cargo.lock | 3 +- Cargo.toml | 4 +- src/database/mod.rs | 2 +- src/database/postgres/mod.rs | 2 +- src/media/mod.rs | 2 +- src/micropub/mod.rs | 87 +++++++++++----------- templates-neo/Cargo.toml | 2 +- templates/Cargo.toml | 2 +- templates/src/templates.rs | 4 +- util/Cargo.toml | 18 +++-- util/src/error.rs | 95 ------------------------ util/src/lib.rs | 16 +--- util/src/micropub.rs | 173 +++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 242 insertions(+), 168 deletions(-) delete mode 100644 util/src/error.rs create mode 100644 util/src/micropub.rs 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 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( // 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( .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( // 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( 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( .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( 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( 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( 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( 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( != &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( 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( 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( // 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( 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( 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, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) { + Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec, 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 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 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, } -/// 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=). +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 { + 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, + /// 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>, + /// 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>, + /// URL for a Media Endpoint, if any. + pub media_endpoint: Option, + /// Other unspecified keys, sometimes implementation-defined. + #[serde(flatten)] + pub other: HashMap +} + +#[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 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 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(), + )) + } +} + -- cgit 1.4.1