diff --git a/Cargo.lock b/Cargo.lock index 090682f..98f36d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "aho-corasick" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -171,7 +180,7 @@ dependencies = [ "num-integer", "num-traits", "serde", - "time", + "time 0.1.45", "wasm-bindgen", "winapi", ] @@ -221,12 +230,15 @@ dependencies = [ "dotenvy", "reqwest", "serde", + "serde_with", "sqlx", + "thiserror", "tokio", "tower", "tower-http", "tracing", "tracing-subscriber", + "validator", ] [[package]] @@ -317,6 +329,41 @@ dependencies = [ "syn 2.0.15", ] +[[package]] +name = "darling" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0558d22a7b463ed0241e993f76f09f30b126687447751a8638587b864e4b3944" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8bfa2e259f8ee1ce5e97824a3c55ec4404a0d772ca7fa96bf19f0752a046eb" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.15", +] + +[[package]] +name = "darling_macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a358ff9f12ec09c3e61fef9b5a9902623a695a46a917b07f269bff1445611a" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.15", +] + [[package]] name = "digest" version = "0.10.6" @@ -694,6 +741,23 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.3.0" @@ -704,6 +768,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + [[package]] name = "indexmap" version = "1.9.3" @@ -712,6 +782,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown", + "serde", ] [[package]] @@ -810,6 +881,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "matchit" version = "0.7.0" @@ -1082,6 +1159,30 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.56" @@ -1159,6 +1260,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" + [[package]] name = "reqwest" version = "0.11.17" @@ -1318,6 +1436,34 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513" +dependencies = [ + "base64 0.21.0", + "chrono", + "hex", + "indexmap", + "serde", + "serde_json", + "serde_with_macros", + "time 0.3.21", +] + +[[package]] +name = "serde_with_macros" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc7d5d3932fb12ce722ee5e64dd38c504efba37567f0c402f6ca728c3b8b070" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "sha1" version = "0.10.5" @@ -1496,6 +1642,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.4.1" @@ -1593,6 +1745,33 @@ dependencies = [ "winapi", ] +[[package]] +name = "time" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3403384eaacbca9923fa06940178ac13e4edb725486d70e8e15881d0c836cc" +dependencies = [ + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1837,10 +2016,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" dependencies = [ "form_urlencoded", - "idna", + "idna 0.3.0", "percent-encoding", ] +[[package]] +name = "validator" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ad5bf234c7d3ad1042e5252b7eddb2c4669ee23f32c7dd0e9b7705f07ef591" +dependencies = [ + "idna 0.2.3", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5e92dcb..a3c25be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,12 @@ chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" reqwest = { version = "0.11", features = ["json"] } serde = { version = "1", features = ["derive"] } +serde_with = "3" sqlx = { version = "0.6", features = ["runtime-tokio-native-tls", "postgres", "macros", "migrate", "chrono"] } +thiserror = "1" tokio = { version = "1", features = ["full"] } tower = "0.4" tower-http = { version = "0.4", features = ["trace"] } tracing = "0.1" tracing-subscriber = "0.3" +validator = { version = "0.16", features = ["derive"] } diff --git a/src/error.rs b/src/error.rs index 3b1593c..68a6947 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,26 +1,67 @@ -use axum::{http::StatusCode, response::{IntoResponse, Response}}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use tracing::error; +use serde_with::DisplayFromStr; +use validator::ValidationErrors; -// Make our own error that wraps `anyhow::Error`. -pub struct AppError(anyhow::Error); +/// An API-friendly error type. +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// A SQLx call returned an error. + /// + /// The exact error contents are not reported to the user in order to avoid leaking + /// information about database internals. + #[error("an internal database error occurred")] + Sqlx(#[from] sqlx::Error), -// Tell axum how to convert `AppError` into a response. -impl IntoResponse for AppError { + /// Similarly, we don't want to report random `anyhow` errors to the user. + #[error("an internal server error occurred")] + Anyhow(#[from] anyhow::Error), + + #[error("validation error in request body")] + InvalidEntity(#[from] ValidationErrors), +} + +impl IntoResponse for Error { fn into_response(self) -> Response { + #[serde_with::serde_as] + #[serde_with::skip_serializing_none] + #[derive(serde::Serialize)] + struct ErrorResponse<'a> { + // Serialize the `Display` output as the error message + #[serde_as(as = "DisplayFromStr")] + message: &'a Error, + + errors: Option<&'a ValidationErrors>, + } + + let errors = match &self { + Error::InvalidEntity(errors) => Some(errors), + _ => None, + }; + + error!("API error: {:?}", self); + ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong: {}", self.0), + self.status_code(), + Json(ErrorResponse { + message: &self, + errors, + }), ) .into_response() } } -// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into -// `Result<_, AppError>`. That way you don't need to do that manually. -impl From for AppError -where - E: Into, -{ - fn from(err: E) -> Self { - Self(err.into()) +impl Error { + fn status_code(&self) -> StatusCode { + use Error::*; + + match self { + Sqlx(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND, + Sqlx(_) | Anyhow(_) => StatusCode::INTERNAL_SERVER_ERROR, + InvalidEntity(_) => StatusCode::UNPROCESSABLE_ENTITY, + } } } diff --git a/src/handlers/item.rs b/src/handlers/item.rs index 118515a..c451d18 100644 --- a/src/handlers/item.rs +++ b/src/handlers/item.rs @@ -1,19 +1,19 @@ use axum::{extract::{State, Path}, Json}; use sqlx::PgPool; -use crate::error::AppError; +use crate::error::Error; use crate::models::item::{create_item, get_item, CreateItem, Item}; pub async fn get( State(pool): State, Path(id): Path, -) -> Result, AppError> { +) -> Result, Error> { Ok(Json(get_item(pool, id).await?)) } pub async fn post( State(pool): State, Json(payload): Json, -) -> Result, AppError> { +) -> Result, Error> { Ok(Json(create_item(pool, payload).await?)) } diff --git a/src/handlers/items.rs b/src/handlers/items.rs index b578395..b6e2948 100644 --- a/src/handlers/items.rs +++ b/src/handlers/items.rs @@ -1,9 +1,9 @@ use axum::{extract::State, Json}; use sqlx::PgPool; -use crate::error::AppError; +use crate::error::Error; use crate::models::item::{get_items, Item}; -pub async fn get(State(pool): State) -> Result>, AppError> { +pub async fn get(State(pool): State) -> Result>, Error> { Ok(Json(get_items(pool).await?)) } diff --git a/src/main.rs b/src/main.rs index 6ee4c91..b423581 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,9 +27,9 @@ async fn main() -> anyhow::Result<()> { sqlx::migrate!().run(&pool).await?; let app = Router::new() - .route("/items", get(handlers::items::get)) - .route("/item", post(handlers::item::post)) - .route("/item/:id", get(handlers::item::get)) + .route("/v1/items", get(handlers::items::get)) + .route("/v1/item", post(handlers::item::post)) + .route("/v1/item/:id", get(handlers::item::get)) .with_state(pool) .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http())); diff --git a/src/models/item.rs b/src/models/item.rs index f9338f6..4973720 100644 --- a/src/models/item.rs +++ b/src/models/item.rs @@ -1,4 +1,3 @@ -use anyhow::Context; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -21,21 +20,19 @@ pub struct CreateItem { description: Option, } -pub async fn get_item(pool: PgPool, id: i32) -> anyhow::Result { +pub async fn get_item(pool: PgPool, id: i32) -> sqlx::Result { sqlx::query_as!(Item, "SELECT * FROM items WHERE id = $1", id) .fetch_one(&pool) .await - .context("Failed to fetch item") } -pub async fn get_items(pool: PgPool) -> anyhow::Result> { +pub async fn get_items(pool: PgPool) -> sqlx::Result> { sqlx::query_as!(Item, "SELECT * FROM items") .fetch_all(&pool) .await - .context("Failed to fetch items") } -pub async fn create_item(pool: PgPool, payload: CreateItem) -> anyhow::Result { +pub async fn create_item(pool: PgPool, payload: CreateItem) -> sqlx::Result { sqlx::query_as!( Item, "INSERT INTO items ( @@ -49,5 +46,4 @@ pub async fn create_item(pool: PgPool, payload: CreateItem) -> anyhow::Result