1
0
Fork 0

It was like that when I got here

This commit is contained in:
Honbra 2024-11-17 22:52:17 +01:00
parent 331423d3f6
commit 18251d7f00
Signed by: honbra
GPG key ID: B61CC9ADABE2D952
36 changed files with 1534 additions and 1000 deletions

View file

@ -15,38 +15,11 @@ use crate::{app::SharedState, error::AppError};
pub fn resource() -> Resource<SharedState> {
Resource::named("links")
.create(create_link)
.show(get_link_info)
.show(show_link)
.update(update_link)
.destroy(delete_link)
}
#[derive(Serialize)]
struct Link {
id: Ulid,
slug: String,
destination: String,
}
async fn get_link_info(
State(SharedState { db, .. }): State<SharedState>,
Path(id): Path<Ulid>,
) -> Result<Json<Link>, AppError> {
match query!(
"SELECT id, slug, destination FROM link WHERE id = $1",
Uuid::from(id),
)
.fetch_optional(&db)
.await?
{
Some(r) => Ok(Json(Link {
id: Ulid::from(r.id),
slug: r.slug,
destination: r.destination,
})),
None => Err(AppError::LinkNotFoundId(id)),
}
}
#[derive(Deserialize)]
struct CreateLinkRequestBody {
slug: String,
@ -79,6 +52,33 @@ async fn create_link(
}
}
#[derive(Serialize)]
struct Link {
id: Ulid,
slug: String,
destination: String,
}
async fn show_link(
State(SharedState { db, .. }): State<SharedState>,
Path(id): Path<Ulid>,
) -> Result<Json<Link>, AppError> {
match query!(
"SELECT id, slug, destination FROM link WHERE id = $1",
Uuid::from(id),
)
.fetch_optional(&db)
.await?
{
Some(r) => Ok(Json(Link {
id: Ulid::from(r.id),
slug: r.slug,
destination: r.destination,
})),
None => Err(AppError::LinkNotFoundId(id)),
}
}
#[derive(Deserialize)]
struct UpdateLinkRequestBody {
destination: Url,

View file

@ -1,9 +1,21 @@
mod links;
mod pastes;
use axum::Router;
use serde::Deserialize;
use super::SharedState;
pub fn router() -> Router<SharedState> {
Router::new().merge(links::resource())
Router::new()
.merge(links::resource())
.merge(pastes::resource())
}
#[derive(Deserialize)]
struct Pagination {
#[serde(default)]
limit: Option<i64>,
#[serde(default)]
offset: i64,
}

144
src/app/api/pastes.rs Normal file
View file

@ -0,0 +1,144 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use axum_codec::Codec;
use http::StatusCode;
use serde::Deserialize;
use sqlx::query;
use ulid::Ulid;
use uuid::Uuid;
use super::Pagination;
use crate::{
app::{resource::Resource, SharedState},
error::AppError,
};
pub fn resource() -> Resource<SharedState> {
Resource::named("pastes")
.index(list_pastes)
.create(create_paste)
.show(show_paste)
.update(update_paste)
.destroy(delete_paste)
}
#[axum_codec::apply(encode)]
struct Paste {
id: Ulid,
title: String,
content: String,
}
async fn list_pastes(
State(SharedState { db, .. }): State<SharedState>,
Query(Pagination { limit, offset }): Query<Pagination>,
) -> Result<Codec<Vec<Paste>>, AppError> {
Ok(Codec(
query!(
"SELECT id, title, content from paste LIMIT $1 OFFSET $2",
limit,
offset,
)
.fetch_all(&db)
.await?
.into_iter()
.map(|r| Paste {
id: Ulid::from(r.id),
title: r.title,
content: r.content,
})
.collect(),
))
}
#[axum_codec::apply(decode)]
struct CreatePasteRequestBody {
title: String,
content: String,
}
async fn create_paste(
State(SharedState { db, .. }): State<SharedState>,
Codec(CreatePasteRequestBody { title, content }): Codec<CreatePasteRequestBody>,
) -> Result<Codec<Paste>, AppError> {
let id = Ulid::new();
match query!(
"INSERT INTO paste (id, title, content) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
Uuid::from(id),
title,
content,
)
.execute(&db)
.await?
.rows_affected()
{
1 => Ok(Codec(Paste { id, title, content })),
0 => Err(AppError::UlidConflict(id)),
rows => Err(AppError::ImpossibleAffectedRows(rows)),
}
}
async fn show_paste(
State(SharedState { db, .. }): State<SharedState>,
Path(id): Path<Ulid>,
) -> Result<Codec<Paste>, AppError> {
match query!(
"SELECT title, content FROM paste WHERE id = $1",
Uuid::from(id),
)
.fetch_optional(&db)
.await?
{
Some(r) => Ok(Codec(Paste {
id,
title: r.title,
content: r.content,
})),
None => Err(AppError::PasteNotFound(id)),
}
}
#[derive(Deserialize)]
struct UpdatePasteRequestBody {
title: String,
content: String,
}
async fn update_paste(
State(SharedState { db, .. }): State<SharedState>,
Path(id): Path<Ulid>,
Json(UpdatePasteRequestBody { title, content }): Json<UpdatePasteRequestBody>,
) -> Result<StatusCode, AppError> {
match query!(
"UPDATE paste SET title = $2, content = $3 WHERE id = $1",
Uuid::from(id),
title,
content,
)
.execute(&db)
.await?
.rows_affected()
{
1 => Ok(StatusCode::NO_CONTENT),
0 => Err(AppError::PasteNotFound(id)),
rows => Err(AppError::ImpossibleAffectedRows(rows)),
}
}
async fn delete_paste(
State(SharedState { db, .. }): State<SharedState>,
Path(id): Path<Ulid>,
) -> Result<StatusCode, AppError> {
match query!("DELETE FROM paste WHERE id = $1", Uuid::from(id))
.execute(&db)
.await?
.rows_affected()
{
1 => Ok(StatusCode::NO_CONTENT),
0 => Err(AppError::PasteNotFound(id)),
rows => Err(AppError::ImpossibleAffectedRows(rows)),
}
}

View file

@ -1,5 +1,6 @@
mod api;
mod pages;
mod resource;
mod root;
use std::sync::Arc;

View file

@ -1,3 +1,4 @@
use askama::Template;
use axum::{
extract::{Query, State},
response::Redirect,
@ -61,6 +62,13 @@ async fn show_pastes(
))
}
#[derive(Template)]
#[template(path = "admin/paste-new.html")]
struct CreatePasteTemplate {
paste_title: String,
paste_content: String,
}
#[derive(Deserialize)]
struct CreatePasteFieldsOptional {
title: Option<String>,
@ -69,21 +77,11 @@ struct CreatePasteFieldsOptional {
async fn show_create_paste(
Query(CreatePasteFieldsOptional { title, content }): Query<CreatePasteFieldsOptional>,
) -> Markup {
page(
"Create Paste",
&[BC_INDEX, BC_ADMIN, BC_PASTES],
html! {
form method="post" action="/admin/pastes" {
fieldset {
legend { "Paste" }
input class="w-full" type="text" name="title" placeholder="Paste title" required value=(title.unwrap_or_default());
textarea class="w-full min-h-32" name="content" placeholder="Paste content" required { (content.unwrap_or_default()) }
input type="submit" value="Create";
}
}
},
)
) -> CreatePasteTemplate {
CreatePasteTemplate {
paste_title: title.unwrap_or_default(),
paste_content: content.unwrap_or_default(),
}
}
#[derive(Deserialize)]

View file

@ -1,42 +1,60 @@
// 01HZT59RTH4R6P1TYE6NMAFYEP 018FF454-E351-260D-60EB-CE3568A7F9D6
use askama_axum::Template;
use axum::{
extract::{Path, State},
routing::get,
Router,
};
use maud::{html, Markup};
use sqlx::query;
use ulid::Ulid;
use uuid::Uuid;
use super::{page, Breadcrumb, BC_INDEX};
use crate::{app::SharedState, error::AppError};
pub(super) fn router() -> Router<SharedState> {
Router::new().route("/:id", get(show_paste))
Router::new()
.route("/:id", get(show_paste))
.route("/:id/raw", get(show_paste_raw))
}
const BC_PASTES_PUBLIC: Breadcrumb = Breadcrumb::new_static("Pastes", "/p");
#[derive(Template)]
#[template(path = "paste.html")]
struct PasteTemplate {
id: Ulid,
title: String,
content: String,
}
async fn show_paste(
Path(id): Path<Ulid>,
State(SharedState { db, .. }): State<SharedState>,
) -> Result<Markup, AppError> {
) -> Result<PasteTemplate, AppError> {
match query!(
"SELECT title, content FROM paste WHERE id = $1",
Uuid::from(id)
Uuid::from(id),
)
.fetch_optional(&db)
.await?
{
Some(r) => Ok(page(
&r.title,
&[BC_INDEX, BC_PASTES_PUBLIC],
html! {
pre class="p-4 border border-bd-base rounded-xl" { (r.content) }
},
)),
Some(r) => Ok(PasteTemplate {
id,
title: r.title,
content: r.content,
}),
None => Err(AppError::PasteNotFound(id)),
}
}
async fn show_paste_raw(
Path(id): Path<Ulid>,
State(SharedState { db, .. }): State<SharedState>,
) -> Result<String, AppError> {
match query!("SELECT content FROM paste WHERE id = $1", Uuid::from(id))
.fetch_optional(&db)
.await?
{
Some(r) => Ok(r.content),
None => Err(AppError::PasteNotFound(id)),
}
}

140
src/app/resource.rs Normal file
View file

@ -0,0 +1,140 @@
use axum::{routing::MethodRouter, Router};
use axum_codec::{
handler::Input,
routing::{delete, get, post, put},
CodecHandler, IntoCodecResponse,
};
/// A resource which defines a set of conventional CRUD routes.
#[derive(Debug)]
#[must_use]
pub struct Resource<S = ()> {
pub(crate) name: String,
pub(crate) router: Router<S>,
}
impl<S> Resource<S>
where
S: Clone + Send + Sync + 'static,
{
/// Create a `Resource` with the given name.
///
/// All routes will be nested at `/{resource_name}`.
pub fn named(resource_name: &str) -> Self {
Self {
name: resource_name.to_owned(),
router: Router::new(),
}
}
/// Add a handler at `GET /{resource_name}`.
pub fn index<H, I, D, T>(self, handler: H) -> Self
where
H: CodecHandler<T, I, D, S> + Clone + Send + Sync + 'static,
I: Input + Send + 'static,
D: IntoCodecResponse + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
T: 'static,
{
let path = self.index_create_path();
self.route(&path, get(handler).into())
}
/// Add a handler at `POST /{resource_name}`.
pub fn create<H, I, D, T>(self, handler: H) -> Self
where
H: CodecHandler<T, I, D, S> + Clone + Send + Sync + 'static,
I: Input + Send + 'static,
D: IntoCodecResponse + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
T: 'static,
{
let path = self.index_create_path();
self.route(&path, post(handler).into())
}
/// Add a handler at `GET /{resource_name}/new`.
pub fn new<H, I, D, T>(self, handler: H) -> Self
where
H: CodecHandler<T, I, D, S> + Clone + Send + Sync + 'static,
I: Input + Send + 'static,
D: IntoCodecResponse + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
T: 'static,
{
let path = format!("/{}/new", self.name);
self.route(&path, get(handler).into())
}
/// Add a handler at `GET /{resource_name}/:{resource_name}_id`.
pub fn show<H, I, D, T>(self, handler: H) -> Self
where
H: CodecHandler<T, I, D, S> + Clone + Send + Sync + 'static,
I: Input + Send + 'static,
D: IntoCodecResponse + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
T: 'static,
{
let path = self.show_update_destroy_path();
self.route(&path, get(handler).into())
}
/// Add a handler at `GET /{resource_name}/:{resource_name}_id/edit`.
pub fn edit<H, I, D, T>(self, handler: H) -> Self
where
H: CodecHandler<T, I, D, S> + Clone + Send + Sync + 'static,
I: Input + Send + 'static,
D: IntoCodecResponse + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
T: 'static,
{
let path = format!("/{0}/:{0}_id/edit", self.name);
self.route(&path, get(handler).into())
}
/// Add a handler at `PUT or PATCH /resource_name/:{resource_name}_id`.
pub fn update<H, I, D, T>(self, handler: H) -> Self
where
H: CodecHandler<T, I, D, S> + Clone + Send + Sync + 'static,
I: Input + Send + 'static,
D: IntoCodecResponse + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
T: 'static,
{
let path = self.show_update_destroy_path();
// it's 12 AM and I don't know how to get `MethodFilter` to work
self.route(&path, put(handler.clone()).patch(handler).into())
}
/// Add a handler at `DELETE /{resource_name}/:{resource_name}_id`.
pub fn destroy<H, I, D, T>(self, handler: H) -> Self
where
H: CodecHandler<T, I, D, S> + Clone + Send + Sync + 'static,
I: Input + Send + 'static,
D: IntoCodecResponse + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
T: 'static,
{
let path = self.show_update_destroy_path();
self.route(&path, delete(handler).into())
}
fn index_create_path(&self) -> String {
format!("/{}", self.name)
}
fn show_update_destroy_path(&self) -> String {
format!("/{0}/:{0}_id", self.name)
}
fn route(mut self, path: &str, method_router: MethodRouter<S>) -> Self {
self.router = self.router.route(path, method_router);
self
}
}
impl<S> From<Resource<S>> for Router<S> {
fn from(resource: Resource<S>) -> Self {
resource.router
}
}

View file

@ -1,4 +1,5 @@
use axum::{body::Body, response::IntoResponse};
use axum_codec::{ContentType, IntoCodecResponse};
use http::StatusCode;
use tracing::{error, field};
use ulid::Ulid;
@ -55,3 +56,34 @@ impl IntoResponse for AppError {
.into_response()
}
}
impl IntoCodecResponse for AppError {
fn into_codec_response(self, content_type: ContentType) -> axum_core::response::Response<Body> {
error!(err = field::display(&self));
match self {
Self::LinkExists(_) => (StatusCode::BAD_REQUEST, "Link already exists"),
Self::LinkNotFoundId(_) | Self::LinkNotFoundSlug(_) => {
(StatusCode::NOT_FOUND, "Link not found")
}
Self::PasteNotFound(_) => (StatusCode::NOT_FOUND, "Paste not found"),
Self::ImpossibleAffectedRows(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Database returned an impossible number of affected rows",
),
Self::UlidConflict(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ULID conflict real???"),
Self::Database(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"A database error has occured",
),
Self::Io(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"An I/O error has occured",
),
Self::Other(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"An unknown error has occured",
),
}
.into_codec_response(content_type)
}
}