feat: initialize thoughts-frontend with Next.js, TypeScript, and ESLint
- Add ESLint configuration for Next.js and TypeScript support. - Create Next.js configuration file with standalone output option. - Initialize package.json with scripts for development, build, and linting. - Set up PostCSS configuration for Tailwind CSS. - Add SVG assets for UI components. - Create TypeScript configuration for strict type checking and module resolution.
This commit is contained in:
33
thoughts-backend/api/Cargo.toml
Normal file
33
thoughts-backend/api/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "api"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true, features = ["macros", "query"] }
|
||||
serde = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
validator = { workspace = true, features = ["derive"] }
|
||||
|
||||
tower-http = { version = "0.6.6", features = ["fs"] }
|
||||
tower-cookies = "0.11.0"
|
||||
anyhow = "1.0.98"
|
||||
dotenvy = "0.15.7"
|
||||
|
||||
# db
|
||||
sea-orm = { workspace = true }
|
||||
|
||||
# doc
|
||||
utoipa = { workspace = true }
|
||||
|
||||
# local dependencies
|
||||
app = { path = "../app" }
|
||||
models = { path = "../models" }
|
||||
|
||||
[dev-dependencies]
|
32
thoughts-backend/api/src/error/adapter.rs
Normal file
32
thoughts-backend/api/src/error/adapter.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use axum::{extract::rejection::JsonRejection, http::StatusCode};
|
||||
use sea_orm::DbErr;
|
||||
|
||||
use app::error::UserError;
|
||||
|
||||
use super::traits::HTTPError;
|
||||
|
||||
impl HTTPError for JsonRejection {
|
||||
fn to_status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
JsonRejection::JsonSyntaxError(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HTTPError for DbErr {
|
||||
fn to_status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
DbErr::ConnectionAcquire(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR, // TODO:: more granularity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HTTPError for UserError {
|
||||
fn to_status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
UserError::NotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
}
|
10
thoughts-backend/api/src/error/core.rs
Normal file
10
thoughts-backend/api/src/error/core.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub struct ApiError(pub(super) anyhow::Error);
|
||||
|
||||
impl<E> From<E> for ApiError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
Self(err.into())
|
||||
}
|
||||
}
|
36
thoughts-backend/api/src/error/handler.rs
Normal file
36
thoughts-backend/api/src/error/handler.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use axum::{
|
||||
extract::rejection::JsonRejection,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use sea_orm::DbErr;
|
||||
|
||||
use app::error::UserError;
|
||||
|
||||
use super::{ApiError, HTTPError};
|
||||
use crate::models::ApiErrorResponse;
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let err = self.0;
|
||||
|
||||
let (status, message) = if let Some(err) = err.downcast_ref::<DbErr>() {
|
||||
tracing::error!(%err, "error from db:");
|
||||
(err.to_status_code(), "DB error".to_string()) // hide the detail
|
||||
} else if let Some(err) = err.downcast_ref::<UserError>() {
|
||||
(err.to_status_code(), err.to_string())
|
||||
} else if let Some(err) = err.downcast_ref::<JsonRejection>() {
|
||||
tracing::error!(%err, "error from extractor:");
|
||||
(err.to_status_code(), err.to_string())
|
||||
} else {
|
||||
tracing::error!(%err, "error from other source:");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Unknown error".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
(status, Json(ApiErrorResponse { message })).into_response()
|
||||
}
|
||||
}
|
7
thoughts-backend/api/src/error/mod.rs
Normal file
7
thoughts-backend/api/src/error/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod adapter;
|
||||
mod core;
|
||||
mod handler;
|
||||
mod traits;
|
||||
|
||||
pub use core::ApiError;
|
||||
pub use traits::HTTPError;
|
5
thoughts-backend/api/src/error/traits.rs
Normal file
5
thoughts-backend/api/src/error/traits.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use axum::http::StatusCode;
|
||||
|
||||
pub trait HTTPError {
|
||||
fn to_status_code(&self) -> StatusCode;
|
||||
}
|
26
thoughts-backend/api/src/extractor/json.rs
Normal file
26
thoughts-backend/api/src/extractor/json.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use axum::{
|
||||
extract::FromRequest,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
#[derive(FromRequest)]
|
||||
#[from_request(via(axum::Json), rejection(ApiError))]
|
||||
pub struct Json<T>(pub T);
|
||||
|
||||
impl<T> IntoResponse for Json<T>
|
||||
where
|
||||
axum::Json<T>: IntoResponse,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
axum::Json(self.0).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Validate> Validate for Json<T> {
|
||||
fn validate(&self) -> Result<(), validator::ValidationErrors> {
|
||||
self.0.validate()
|
||||
}
|
||||
}
|
5
thoughts-backend/api/src/extractor/mod.rs
Normal file
5
thoughts-backend/api/src/extractor/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod json;
|
||||
mod valid;
|
||||
|
||||
pub use json::Json;
|
||||
pub use valid::Valid;
|
23
thoughts-backend/api/src/extractor/valid.rs
Normal file
23
thoughts-backend/api/src/extractor/valid.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use axum::extract::{FromRequest, Request};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::validation::ValidRejection;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Valid<T>(pub T);
|
||||
|
||||
impl<State, Extractor> FromRequest<State> for Valid<Extractor>
|
||||
where
|
||||
State: Send + Sync,
|
||||
Extractor: Validate + FromRequest<State>,
|
||||
{
|
||||
type Rejection = ValidRejection<<Extractor as FromRequest<State>>::Rejection>;
|
||||
|
||||
async fn from_request(req: Request, state: &State) -> Result<Self, Self::Rejection> {
|
||||
let inner = Extractor::from_request(req, state)
|
||||
.await
|
||||
.map_err(ValidRejection::Extractor)?;
|
||||
inner.validate()?;
|
||||
Ok(Valid(inner))
|
||||
}
|
||||
}
|
32
thoughts-backend/api/src/init.rs
Normal file
32
thoughts-backend/api/src/init.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
|
||||
use app::config::Config;
|
||||
use app::state::AppState;
|
||||
|
||||
use crate::routers::create_router;
|
||||
|
||||
// TODO: middleware, logging, authentication
|
||||
pub fn setup_router(conn: DatabaseConnection) -> Router {
|
||||
create_router(AppState { conn })
|
||||
}
|
||||
|
||||
pub fn setup_config() -> Config {
|
||||
dotenvy::dotenv().ok();
|
||||
Config::from_env()
|
||||
}
|
||||
|
||||
pub async fn setup_db(db_url: &str, prefork: bool) -> DatabaseConnection {
|
||||
let mut opt = ConnectOptions::new(db_url);
|
||||
opt.max_lifetime(Duration::from_secs(60));
|
||||
|
||||
if !prefork {
|
||||
opt.min_connections(10).max_connections(100);
|
||||
}
|
||||
|
||||
Database::connect(opt)
|
||||
.await
|
||||
.expect("Database connection failed")
|
||||
}
|
9
thoughts-backend/api/src/lib.rs
Normal file
9
thoughts-backend/api/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod error;
|
||||
mod extractor;
|
||||
mod init;
|
||||
mod validation;
|
||||
|
||||
pub mod models;
|
||||
pub mod routers;
|
||||
|
||||
pub use init::{setup_config, setup_db, setup_router};
|
3
thoughts-backend/api/src/models/mod.rs
Normal file
3
thoughts-backend/api/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod response;
|
||||
|
||||
pub use response::{ApiErrorResponse, ParamsErrorResponse, ValidationErrorResponse};
|
27
thoughts-backend/api/src/models/response.rs
Normal file
27
thoughts-backend/api/src/models/response.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ApiErrorResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ValidationErrorResponse<T> {
|
||||
pub message: String,
|
||||
pub details: T,
|
||||
}
|
||||
|
||||
pub type ParamsErrorResponse =
|
||||
ValidationErrorResponse<HashMap<String, Vec<HashMap<String, String>>>>;
|
||||
|
||||
impl<T> From<T> for ValidationErrorResponse<T> {
|
||||
fn from(t: T) -> Self {
|
||||
Self {
|
||||
message: "Validation error".to_string(),
|
||||
details: t,
|
||||
}
|
||||
}
|
||||
}
|
68
thoughts-backend/api/src/routers/blog.rs
Normal file
68
thoughts-backend/api/src/routers/blog.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use sea_orm::TryIntoModel;
|
||||
|
||||
use app::persistence::blog::{create_blog, search_blogs};
|
||||
use app::state::AppState;
|
||||
use models::params::blog::CreateBlogParams;
|
||||
use models::queries::blog::BlogQuery;
|
||||
use models::schemas::blog::{BlogListSchema, BlogSchema};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractor::{Json, Valid};
|
||||
use crate::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "",
|
||||
request_body = CreateBlogParams,
|
||||
responses(
|
||||
(status = 201, description = "Blog created", body = BlogSchema),
|
||||
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn blogs_post(
|
||||
state: State<AppState>,
|
||||
Valid(Json(params)): Valid<Json<CreateBlogParams>>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let blog = create_blog(&state.conn, params)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let blog = blog.try_into_model().unwrap();
|
||||
Ok((StatusCode::CREATED, Json(BlogSchema::from(blog))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "",
|
||||
params(
|
||||
BlogQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List blogs", body = BlogListSchema),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn blogs_get(
|
||||
state: State<AppState>,
|
||||
query: Query<BlogQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let Query(query) = query;
|
||||
|
||||
let blogs = search_blogs(&state.conn, query)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(Json(BlogListSchema::from(blogs)))
|
||||
}
|
||||
|
||||
pub fn create_blog_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(blogs_get).post(blogs_post))
|
||||
}
|
18
thoughts-backend/api/src/routers/mod.rs
Normal file
18
thoughts-backend/api/src/routers/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use axum::Router;
|
||||
|
||||
pub mod blog;
|
||||
pub mod root;
|
||||
pub mod user;
|
||||
|
||||
use app::state::AppState;
|
||||
use blog::create_blog_router;
|
||||
use root::create_root_router;
|
||||
use user::create_user_router;
|
||||
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.merge(create_root_router())
|
||||
.nest("/users", create_user_router())
|
||||
.nest("/blogs", create_blog_router())
|
||||
.with_state(state)
|
||||
}
|
30
thoughts-backend/api/src/routers/root.rs
Normal file
30
thoughts-backend/api/src/routers/root.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use axum::{extract::State, routing::get, Router};
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
|
||||
use app::state::AppState;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "",
|
||||
responses(
|
||||
(status = 200, description = "Hello world", body = String)
|
||||
)
|
||||
)]
|
||||
async fn root_get(state: State<AppState>) -> Result<String, ApiError> {
|
||||
let result = state
|
||||
.conn
|
||||
.query_one(Statement::from_string(
|
||||
state.conn.get_database_backend(),
|
||||
"SELECT 'Hello, World from DB!'",
|
||||
))
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
result.unwrap().try_get_by(0).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub fn create_root_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(root_get))
|
||||
}
|
93
thoughts-backend/api/src/routers/user.rs
Normal file
93
thoughts-backend/api/src/routers/user.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use sea_orm::TryIntoModel;
|
||||
|
||||
use app::error::UserError;
|
||||
use app::persistence::user::{create_user, get_user, search_users};
|
||||
use app::state::AppState;
|
||||
use models::params::user::CreateUserParams;
|
||||
use models::queries::user::UserQuery;
|
||||
use models::schemas::user::{UserListSchema, UserSchema};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractor::{Json, Valid};
|
||||
use crate::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "",
|
||||
request_body = CreateUserParams,
|
||||
responses(
|
||||
(status = 201, description = "User created", body = UserSchema),
|
||||
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn users_post(
|
||||
state: State<AppState>,
|
||||
Valid(Json(params)): Valid<Json<CreateUserParams>>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = create_user(&state.conn, params)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let user = user.try_into_model().unwrap();
|
||||
Ok((StatusCode::CREATED, Json(UserSchema::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "",
|
||||
params(
|
||||
UserQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List users", body = UserListSchema),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn users_get(
|
||||
state: State<AppState>,
|
||||
query: Query<UserQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let Query(query) = query;
|
||||
|
||||
let users = search_users(&state.conn, query)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(Json(UserListSchema::from(users)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/{id}",
|
||||
params(
|
||||
("id" = i32, Path, description = "User id")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Get user", body = UserSchema),
|
||||
(status = 404, description = "Not found", body = ApiErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn users_id_get(
|
||||
state: State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = get_user(&state.conn, id).await.map_err(ApiError::from)?;
|
||||
|
||||
user.map(|user| Json(UserSchema::from(user)))
|
||||
.ok_or_else(|| UserError::NotFound.into())
|
||||
}
|
||||
|
||||
pub fn create_user_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", post(users_post).get(users_get))
|
||||
.route("/{id}", get(users_id_get))
|
||||
}
|
3
thoughts-backend/api/src/validation/mod.rs
Normal file
3
thoughts-backend/api/src/validation/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod rejection;
|
||||
|
||||
pub use rejection::ValidRejection;
|
58
thoughts-backend/api/src/validation/rejection.rs
Normal file
58
thoughts-backend/api/src/validation/rejection.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use validator::ValidationErrors;
|
||||
|
||||
use crate::models::ValidationErrorResponse;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ValidationRejection<V, E> {
|
||||
Validator(V), // Validation errors
|
||||
Extractor(E), // Extraction errors, e.g. axum's JsonRejection
|
||||
}
|
||||
|
||||
impl<V: std::fmt::Display, E: std::fmt::Display> std::fmt::Display for ValidationRejection<V, E> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ValidationRejection::Validator(v) => write!(f, "{v}"),
|
||||
ValidationRejection::Extractor(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: std::error::Error + 'static, E: std::error::Error + 'static> std::error::Error
|
||||
for ValidationRejection<V, E>
|
||||
{
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ValidationRejection::Validator(v) => Some(v),
|
||||
ValidationRejection::Extractor(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: serde::Serialize + std::error::Error, E: IntoResponse> IntoResponse
|
||||
for ValidationRejection<V, E>
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
ValidationRejection::Validator(v) => {
|
||||
tracing::error!("Validation error: {v}");
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(ValidationErrorResponse::from(v)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
// logged by ApiError
|
||||
ValidationRejection::Extractor(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ValidRejection<E> = ValidationRejection<ValidationErrors, E>;
|
||||
|
||||
impl<E> From<ValidationErrors> for ValidRejection<E> {
|
||||
fn from(v: ValidationErrors) -> Self {
|
||||
Self::Validator(v)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user