It was like that when I got here
This commit is contained in:
parent
331423d3f6
commit
18251d7f00
36 changed files with 1534 additions and 1000 deletions
|
@ -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,
|
||||
|
|
|
@ -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
144
src/app/api/pastes.rs
Normal 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)),
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
mod api;
|
||||
mod pages;
|
||||
mod resource;
|
||||
mod root;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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
140
src/app/resource.rs
Normal 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
|
||||
}
|
||||
}
|
32
src/error.rs
32
src/error.rs
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue