Publish shitty code
This commit is contained in:
commit
2e36ee2758
24 changed files with 2845 additions and 0 deletions
120
src/app/api/links.rs
Normal file
120
src/app/api/links.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{query, PgPool};
|
||||
use ulid::Ulid;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
pub fn router(db: PgPool) -> Router {
|
||||
Router::new()
|
||||
.route("/", post(create_link))
|
||||
.route(
|
||||
"/:id",
|
||||
get(get_link_info).put(update_link).delete(delete_link),
|
||||
)
|
||||
.with_state(db)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Link {
|
||||
id: Ulid,
|
||||
slug: String,
|
||||
destination: String,
|
||||
}
|
||||
|
||||
async fn get_link_info(
|
||||
State(db): State<PgPool>,
|
||||
Path(id): Path<Ulid>,
|
||||
) -> Result<Json<Link>, AppError> {
|
||||
let link = query!(
|
||||
"SELECT id, slug, destination FROM link WHERE id = $1",
|
||||
Uuid::from(id),
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(Link {
|
||||
id: Ulid::from(link.id),
|
||||
slug: link.slug,
|
||||
destination: link.destination,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateLinkRequestBody {
|
||||
slug: String,
|
||||
destination: Url,
|
||||
}
|
||||
|
||||
async fn create_link(
|
||||
State(db): State<PgPool>,
|
||||
Json(CreateLinkRequestBody { slug, destination }): Json<CreateLinkRequestBody>,
|
||||
) -> Result<Json<Link>, AppError> {
|
||||
let id = Ulid::new();
|
||||
|
||||
let result = query!(
|
||||
"INSERT INTO link (id, slug, destination) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
Uuid::from(id),
|
||||
slug,
|
||||
destination.to_string(),
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
match result.rows_affected() {
|
||||
1 => Ok(Json(Link {
|
||||
id,
|
||||
slug,
|
||||
destination: destination.to_string(),
|
||||
})),
|
||||
0 => Err(AppError::LinkExists(id)),
|
||||
rows => Err(AppError::ImpossibleAffectedRows(rows)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateLinkRequestBody {
|
||||
destination: Url,
|
||||
}
|
||||
|
||||
async fn update_link(
|
||||
State(db): State<PgPool>,
|
||||
Path(id): Path<Ulid>,
|
||||
Json(UpdateLinkRequestBody { destination }): Json<UpdateLinkRequestBody>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let result = query!(
|
||||
"UPDATE link SET destination = $2 WHERE id = $1",
|
||||
Uuid::from(id),
|
||||
destination.to_string(),
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
match result.rows_affected() {
|
||||
1 => Ok(StatusCode::NO_CONTENT),
|
||||
0 => Err(AppError::LinkNotFound(id)),
|
||||
rows => Err(AppError::ImpossibleAffectedRows(rows)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_link(
|
||||
State(db): State<PgPool>,
|
||||
Path(id): Path<Ulid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let result = query!("DELETE FROM link WHERE id = $1", Uuid::from(id))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
match result.rows_affected() {
|
||||
1 => Ok(StatusCode::NO_CONTENT),
|
||||
0 => Err(AppError::LinkNotFound(id)),
|
||||
rows => Err(AppError::ImpossibleAffectedRows(rows)),
|
||||
}
|
||||
}
|
8
src/app/api/mod.rs
Normal file
8
src/app/api/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
mod links;
|
||||
|
||||
use axum::Router;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn router(db: PgPool) -> Router {
|
||||
Router::new().nest("/links", links::router(db))
|
||||
}
|
36
src/app/mod.rs
Normal file
36
src/app/mod.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
mod api;
|
||||
mod root;
|
||||
|
||||
use axum::{body::Body, Router};
|
||||
use http::Request;
|
||||
use sqlx::{postgres::PgConnectOptions, PgPool};
|
||||
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||
use tracing::{field, span, Level};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn build_app(config: Config) -> eyre::Result<Router> {
|
||||
let db = PgPool::connect_with(
|
||||
PgConnectOptions::new()
|
||||
.application_name("ncpn")
|
||||
.socket(&config.db_socket)
|
||||
.username(&config.db_username)
|
||||
.database(&config.db_database),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(root::router(db.clone(), config.default_destination)
|
||||
.nest("/api", api::router(db))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<Body>| {
|
||||
span!(
|
||||
Level::INFO,
|
||||
"http-request",
|
||||
uri = field::display(request.uri()),
|
||||
)
|
||||
})
|
||||
.on_request(DefaultOnRequest::new().level(Level::DEBUG))
|
||||
.on_response(DefaultOnResponse::new().level(Level::INFO)),
|
||||
))
|
||||
}
|
69
src/app/root.rs
Normal file
69
src/app/root.rs
Normal file
|
@ -0,0 +1,69 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::Redirect,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use sqlx::{query, PgPool};
|
||||
use tracing::{error, field, instrument};
|
||||
use ulid::Ulid;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SharedState {
|
||||
db: PgPool,
|
||||
default_destination: Arc<Url>,
|
||||
}
|
||||
|
||||
pub fn router(db: PgPool, default_destination: Url) -> Router {
|
||||
Router::new()
|
||||
.route("/:slug", get(redirect))
|
||||
.with_state(SharedState {
|
||||
db,
|
||||
default_destination: Arc::new(default_destination),
|
||||
})
|
||||
}
|
||||
|
||||
async fn redirect(
|
||||
State(SharedState {
|
||||
db,
|
||||
default_destination,
|
||||
}): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let result = query!("SELECT id, destination FROM link WHERE slug = $1", slug)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| (Ulid::from(r.id), r.destination));
|
||||
|
||||
Ok(match result {
|
||||
Some((id, destination)) => {
|
||||
tokio::spawn(increase_visit_count(id, db));
|
||||
Redirect::temporary(&destination)
|
||||
}
|
||||
None => Redirect::temporary(default_destination.as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(db))]
|
||||
async fn increase_visit_count(id: Ulid, db: PgPool) {
|
||||
let result = query!(
|
||||
"UPDATE link SET visit_count = visit_count + 1 WHERE id = $1",
|
||||
Uuid::from(id),
|
||||
)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(result) if result.rows_affected() != 1 => {
|
||||
error!(err = field::display(AppError::ImpossibleAffectedRows(result.rows_affected())));
|
||||
}
|
||||
Err(err) => error!(err = field::display(err)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
40
src/config.rs
Normal file
40
src/config.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use std::{net::SocketAddr, path::PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_listen_addr")]
|
||||
pub listen_addr: SocketAddr,
|
||||
|
||||
#[serde(default = "default_db_socket")]
|
||||
pub db_socket: PathBuf,
|
||||
#[serde(default = "default_db_username")]
|
||||
pub db_username: String,
|
||||
#[serde(default = "default_db_database")]
|
||||
pub db_database: String,
|
||||
|
||||
#[serde(default = "default2_destination")]
|
||||
pub default_destination: Url,
|
||||
}
|
||||
|
||||
fn default_listen_addr() -> SocketAddr {
|
||||
([0, 0, 0, 0], 3000).into()
|
||||
}
|
||||
|
||||
fn default_db_socket() -> PathBuf {
|
||||
"/var/run/postgresql/".into()
|
||||
}
|
||||
|
||||
fn default_db_username() -> String {
|
||||
"ncpn".into()
|
||||
}
|
||||
|
||||
fn default_db_database() -> String {
|
||||
default_db_username()
|
||||
}
|
||||
|
||||
fn default2_destination() -> Url {
|
||||
"https://goob.cc/r".parse().expect("hardcoded URL is valid")
|
||||
}
|
43
src/error.rs
Normal file
43
src/error.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use axum::response::{IntoResponse, Response};
|
||||
use http::StatusCode;
|
||||
use tracing::{error, field};
|
||||
use ulid::Ulid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("link already exists ({0})")]
|
||||
LinkExists(Ulid),
|
||||
#[error("link not found ({0})")]
|
||||
LinkNotFound(Ulid),
|
||||
#[error("database returned an impossible number of affected rows ({0})")]
|
||||
ImpossibleAffectedRows(u64),
|
||||
#[error("database error")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
Other(#[from] eyre::Report),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
error!(err = field::display(&self));
|
||||
match self {
|
||||
Self::LinkExists(_) => (StatusCode::BAD_REQUEST, "Link already exists").into_response(),
|
||||
Self::LinkNotFound(_) => (StatusCode::NOT_FOUND, "Link not found").into_response(),
|
||||
Self::ImpossibleAffectedRows(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Database returned an impossible number of affected rows",
|
||||
)
|
||||
.into_response(),
|
||||
Self::Database(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"A database error has occured",
|
||||
)
|
||||
.into_response(),
|
||||
Self::Other(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("An error has occured:\n{err:?}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
59
src/main.rs
Normal file
59
src/main.rs
Normal file
|
@ -0,0 +1,59 @@
|
|||
mod app;
|
||||
mod config;
|
||||
mod error;
|
||||
|
||||
use eyre::Context;
|
||||
use figment::{
|
||||
providers::{Env, Format, Toml},
|
||||
Figment,
|
||||
};
|
||||
use tokio::runtime::Runtime;
|
||||
use tracing::{debug, field, Level};
|
||||
use tracing_subscriber::{filter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use self::app::build_app;
|
||||
use crate::config::Config;
|
||||
|
||||
fn main() -> eyre::Result<()> {
|
||||
let filter = filter::Targets::new()
|
||||
.with_target("honbra_api", Level::TRACE)
|
||||
.with_target("tower_http::trace::on_response", Level::TRACE)
|
||||
.with_target("tower_http::trace::on_request", Level::TRACE)
|
||||
.with_default(Level::INFO);
|
||||
|
||||
let tracing_layer = tracing_subscriber::fmt::layer();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_layer)
|
||||
.with(filter)
|
||||
.try_init()
|
||||
.map_err(eyre::Error::msg)
|
||||
.context("failed to initialize tracing subscriber")?;
|
||||
|
||||
let config: Config = Figment::new()
|
||||
.merge(Toml::file("config.toml"))
|
||||
.merge(Env::raw())
|
||||
.extract()
|
||||
.context("failed to parse config")?;
|
||||
|
||||
let rt = Runtime::new().context("failed to create tokio runtime")?;
|
||||
|
||||
rt.block_on(async move {
|
||||
let listen_addr = config.listen_addr;
|
||||
|
||||
let router = build_app(config)
|
||||
.await
|
||||
.context("failed to build app")?
|
||||
.into_make_service();
|
||||
|
||||
debug!(addr = field::display(&listen_addr), "binding");
|
||||
|
||||
axum::Server::try_bind(&listen_addr)
|
||||
.context("unable to bind to server address")?
|
||||
.serve(router)
|
||||
.await
|
||||
.context("server encountered a runtime error")?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue