big error refactor, also almost working post /sites
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Hamcha 2023-07-06 12:34:45 +02:00
parent cce6272373
commit dd45fd26f3
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
13 changed files with 157 additions and 75 deletions

1
Cargo.lock generated
View file

@ -769,6 +769,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",

View file

@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde", "clock"] }
anyhow = "1.0"
argon2 = { version = "0.5", features = ["std", "alloc"] }
url = "2.4"
thiserror = "1.0"
[profile.dev.package.sqlx-macros]
opt-level = 3

View file

@ -16,11 +16,11 @@ use cookie::Cookie;
use std::sync::Arc;
use uuid::Uuid;
use crate::{error::AppError, state::AppState};
use crate::{http::error::ApiError, state::AppState};
use super::{session::Session, user::User};
pub const INVALID_SESSION: AppError = AppError::ClientError {
pub const INVALID_SESSION: ApiError = ApiError::ClientError {
status: StatusCode::UNAUTHORIZED,
code: "authentication-required",
message: "Please log-in and submit a valid session as a cookie",
@ -39,7 +39,7 @@ impl<S> FromRequestParts<S> for RequireUser
where
S: Send + Sync,
{
type Rejection = AppError<'static>;
type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match Extension::<User>::from_request_parts(parts, state).await {
@ -56,7 +56,7 @@ impl<S> FromRequestParts<S> for RequireSession
where
S: Send + Sync,
{
type Rejection = AppError<'static>;
type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match Extension::<Session>::from_request_parts(parts, state).await {

View file

@ -6,11 +6,11 @@ use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Pool, Postgres};
use uuid::Uuid;
use crate::error::AppError;
use crate::http::error::ApiError;
use super::{hash::random, user::User};
pub const USER_NOT_FOUND: AppError = AppError::ClientError {
pub const USER_NOT_FOUND: ApiError<'static> = ApiError::ClientError {
status: StatusCode::UNAUTHORIZED,
code: "user-not-found",
message: "The logged-in user was not found",
@ -137,7 +137,7 @@ impl Session {
))
}
pub async fn user(self: &Self, pool: &Pool<Postgres>) -> Result<User, AppError<'static>> {
pub async fn user(self: &Self, pool: &Pool<Postgres>) -> Result<User, ApiError<'static>> {
match User::get_id(pool, self.actor).await? {
Some(user) => Ok(user),
None => Err(USER_NOT_FOUND),

View file

@ -1,41 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub enum AppError<'a> {
ServerError(anyhow::Error),
ClientError {
status: StatusCode,
code: &'a str,
message: &'a str,
},
}
impl IntoResponse for AppError<'_> {
fn into_response(self) -> Response {
match self {
AppError::ServerError(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"code":"server-error", "message": err.to_string()})),
)
.into_response(),
AppError::ClientError {
status,
code,
message,
} => (status, Json(json!({"code":code, "message": message}))).into_response(),
}
}
}
impl<E> From<E> for AppError<'_>
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
AppError::ServerError(err.into())
}
}

79
src/http/error.rs Normal file
View file

@ -0,0 +1,79 @@
use std::fmt::Display;
use axum::{
extract::rejection::JsonRejection,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
// Generic errors
pub const ERR_NOT_FOUND: ApiError<'static> = ApiError::ClientError {
status: StatusCode::NOT_FOUND,
code: "not-found",
message: "resource not found",
};
#[derive(Error, Debug)]
pub enum ApiError<'a> {
ClientError {
status: StatusCode,
code: &'a str,
message: &'a str,
},
InternalError(#[from] anyhow::Error),
DatabaseError(#[from] sqlx::Error),
JSONFormatError(#[from] JsonRejection),
}
impl Display for ApiError<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::ClientError {
status,
code,
message,
} => write!(f, "Client error: {} {}: {}", status, code, message),
ApiError::InternalError(err) => write!(f, "Unexpected error: {}", err),
ApiError::DatabaseError(err) => write!(f, "Database error: {}", err),
ApiError::JSONFormatError(err) => write!(f, "JSON format error: {}", err),
}
}
}
impl IntoResponse for ApiError<'_> {
fn into_response(self) -> Response {
match self {
ApiError::InternalError(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"code":"server-error", "message": err.to_string()})),
)
.into_response(),
ApiError::DatabaseError(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"code":"server-error", "message": err.to_string()})),
)
.into_response(),
ApiError::ClientError {
status,
code,
message,
} => (status, Json(json!({"code":code, "message": message}))).into_response(),
ApiError::JSONFormatError(rejection) => {
let status = match rejection {
JsonRejection::JsonDataError(_) => StatusCode::UNPROCESSABLE_ENTITY,
JsonRejection::JsonSyntaxError(_) => StatusCode::BAD_REQUEST,
JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
Json(json!({"code":"invalid-body", "message": rejection.body_text()})),
)
.into_response()
}
}
}
}

7
src/http/extract.rs Normal file
View file

@ -0,0 +1,7 @@
use axum_macros::FromRequest;
use super::error::ApiError;
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError<'static>))]
pub struct JsonBody<T>(pub T);

2
src/http/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod error;
pub mod extract;

View file

@ -1,6 +1,6 @@
mod auth;
mod content;
mod error;
mod http;
mod roles;
mod routes;
mod state;

View file

@ -8,7 +8,7 @@ use std::sync::Arc;
use crate::{
auth::{hash::random, user::User},
error::AppError,
http::error::ApiError,
roles::ROLE_SUPERADMIN,
state::AppState,
};
@ -21,10 +21,10 @@ async fn bootstrap(State(state): State<Arc<AppState>>) -> impl IntoResponse {
.map(|row| row.empty.unwrap_or(true))
.fetch_one(&state.database)
.await
.map_err(AppError::from)?;
.map_err(anyhow::Error::from)?;
if !empty {
return Err(AppError::ClientError {
return Err(ApiError::ClientError {
status: StatusCode::BAD_REQUEST,
code: "already-setup".into(),
message: "The instance was already bootstrapped".into(),
@ -41,7 +41,7 @@ async fn bootstrap(State(state): State<Arc<AppState>>) -> impl IntoResponse {
&[ROLE_SUPERADMIN].to_vec(),
)
.await
.map_err(AppError::from)?;
.map_err(ApiError::from)?;
Ok(Json(json!({"username": username, "password": password})))
}

View file

@ -17,7 +17,7 @@ use crate::{
session::Session,
user::User,
},
error::AppError,
http::{error::ApiError, extract::JsonBody},
state::AppState,
};
@ -29,14 +29,14 @@ struct LoginRequest {
async fn login(
State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
JsonBody(payload): JsonBody<LoginRequest>,
) -> impl IntoResponse {
let user = User::find(&state.database, payload.username.as_str())
.await
.map_err(AppError::from)?;
.map_err(ApiError::from)?;
let invalid = || -> AppError {
AppError::ClientError {
let invalid = || -> ApiError {
ApiError::ClientError {
status: StatusCode::UNAUTHORIZED,
code: "invalid-login".into(),
message: "No matching user was found".into(),
@ -46,7 +46,7 @@ async fn login(
let user = user.ok_or_else(invalid)?;
let plaintext = user.password.ok_or_else(invalid)?;
if !verify(&payload.password, &plaintext).map_err(AppError::from)? {
if !verify(&payload.password, &plaintext).map_err(ApiError::from)? {
return Err(invalid());
}
@ -56,7 +56,7 @@ async fn login(
Duration::seconds(state.config.session_duration.into()),
)
.await
.map_err(AppError::from)?;
.map_err(ApiError::from)?;
let token = session.token();
let mut response: Response =
@ -66,27 +66,29 @@ async fn login(
SET_COOKIE,
session
.cookie(state.config.domain().as_str(), state.config.secure())
.parse()?,
.parse()
.map_err(anyhow::Error::from)?,
);
Ok(response)
}
async fn me(RequireUser(user): RequireUser) -> Result<String, AppError<'static>> {
async fn me(RequireUser(user): RequireUser) -> Result<String, ApiError<'static>> {
Ok(user.name)
}
async fn logout(
State(state): State<Arc<AppState>>,
RequireSession(session): RequireSession,
) -> Result<impl IntoResponse, AppError<'static>> {
) -> Result<impl IntoResponse, ApiError<'static>> {
session.destroy(&state.database).await?;
let mut response: Response = Json(json!({ "ok": true })).into_response();
response.headers_mut().insert(
SET_COOKIE,
Session::cookie_for_delete(state.config.domain().as_str(), state.config.secure())
.parse()?,
.parse()
.map_err(anyhow::Error::from)?,
);
Ok(response)
}

View file

@ -6,18 +6,28 @@ use axum::{
};
use std::sync::Arc;
use crate::{content::Page, error::AppError, state::AppState};
use crate::{
content::Page,
http::error::{ApiError, ERR_NOT_FOUND},
state::AppState,
};
async fn get_page(
State(state): State<Arc<AppState>>,
Path((site, slug)): Path<(String, String)>,
) -> Result<impl IntoResponse, AppError<'static>> {
) -> Result<impl IntoResponse, ApiError<'static>> {
let page_query = sqlx::query_as(
"SELECT p.* FROM pages p JOIN sites s ON p.site = s.id WHERE p.slug = $1 AND s.name = $2",
)
.bind(slug)
.bind(site);
let page: Page = page_query.fetch_one(&state.database).await?;
let page: Page = page_query
.fetch_one(&state.database)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => ERR_NOT_FOUND,
_ => e.into(),
})?;
Ok(Json(page))
}

View file

@ -8,27 +8,48 @@ use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use crate::{content::Site, error::AppError, state::AppState};
use crate::{
auth::http::RequireUser,
content::Site,
http::{
error::{ApiError, ERR_NOT_FOUND},
extract::JsonBody,
},
state::AppState,
};
async fn get_site(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<impl IntoResponse, AppError<'static>> {
) -> Result<impl IntoResponse, ApiError<'static>> {
let site_query = sqlx::query_as("SELECT * FROM sites WHERE name = $1").bind(name);
let site: Site = site_query.fetch_one(&state.database).await?;
let site: Site = site_query
.fetch_one(&state.database)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => ERR_NOT_FOUND,
_ => e.into(),
})?;
Ok(Json(site))
}
#[derive(Deserialize)]
struct CreateSiteRequest {
name: String,
title: String,
}
async fn create_site(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateSiteRequest>,
) -> Result<impl IntoResponse, AppError<'static>> {
sqlx::query!("INSERT INTO sites (name) VALUES ($1)", payload.name)
RequireUser(user): RequireUser,
JsonBody(payload): JsonBody<CreateSiteRequest>,
) -> Result<impl IntoResponse, ApiError<'static>> {
sqlx::query!(
"INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)",
payload.name,
user.id,
payload.title,
)
.execute(&state.database)
.await?;
Ok(Json(json!({"ok": true, "url": "test"})))