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:
6
thoughts-backend/.dockerignore
Normal file
6
thoughts-backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# Ignore build artifacts
|
||||
target/
|
||||
# Ignore git directory
|
||||
.git/
|
||||
# Ignore local environment files
|
||||
.env
|
6
thoughts-backend/.env
Normal file
6
thoughts-backend/.env
Normal file
@@ -0,0 +1,6 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
#DATABASE_URL="sqlite://dev.db"
|
||||
# DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
|
||||
DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
|
||||
PREFORK=1
|
5
thoughts-backend/.env.example
Normal file
5
thoughts-backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
DATABASE_URL="sqlite://dev.db"
|
||||
# DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
|
||||
PREFORK=1
|
1
thoughts-backend/.gitignore
vendored
Normal file
1
thoughts-backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
4467
thoughts-backend/Cargo.lock
generated
Normal file
4467
thoughts-backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
thoughts-backend/Cargo.toml
Normal file
59
thoughts-backend/Cargo.toml
Normal file
@@ -0,0 +1,59 @@
|
||||
[package]
|
||||
name = "thoughts-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
# docs
|
||||
authors = ["Gabriel Kaszewski <gabrielkaszewski@gmail.com>"]
|
||||
description = "Thoughts backend"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
|
||||
[workspace]
|
||||
members = ["api", "app", "doc", "models", "migration", "utils"]
|
||||
|
||||
[workspace.dependencies]
|
||||
axum = { version = "0.8.4", default-features = false }
|
||||
tower = { version = "0.5.2", default-features = false }
|
||||
sea-orm = { version = "1.1.12", default-features = false }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = { version = "1.0.140", default-features = false }
|
||||
tracing = "0.1.41"
|
||||
utoipa = { version = "5.4.0", default-features = false, features = ["macros"] }
|
||||
validator = { version = "0.20.0", default-features = false }
|
||||
|
||||
[dependencies]
|
||||
api = { path = "api" }
|
||||
utils = { path = "utils" }
|
||||
doc = { path = "doc" }
|
||||
|
||||
sea-orm = { workspace = true }
|
||||
|
||||
# logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
|
||||
# runtime
|
||||
axum = { workspace = true, features = ["tokio", "http1", "http2"] }
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
prefork = { version = "0.6.0", default-features = false, optional = true }
|
||||
|
||||
# shuttle runtime
|
||||
shuttle-axum = { version = "0.55.0", optional = true }
|
||||
shuttle-runtime = { version = "0.55.0", optional = true }
|
||||
shuttle-shared-db = { version = "0.55.0", features = [
|
||||
"postgres",
|
||||
], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
app = { path = "app" }
|
||||
models = { path = "models" }
|
||||
|
||||
http-body-util = "0.1.3"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["prefork"]
|
||||
prefork = ["prefork/tokio"]
|
||||
shuttle = ["shuttle-axum", "shuttle-runtime", "shuttle-shared-db"]
|
40
thoughts-backend/Dockerfile
Normal file
40
thoughts-backend/Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
FROM rust:1.89-slim AS builder
|
||||
RUN cargo install cargo-chef --locked
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY api/Cargo.toml ./api/
|
||||
COPY app/Cargo.toml ./app/
|
||||
COPY doc/Cargo.toml ./doc/
|
||||
COPY migration/Cargo.toml ./migration/
|
||||
COPY models/Cargo.toml ./models/
|
||||
COPY utils/Cargo.toml ./utils/
|
||||
|
||||
RUN mkdir -p src && echo "fn main() {}" > src/main.rs
|
||||
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release --bin thoughts-backend
|
||||
|
||||
FROM debian:13-slim AS runtime
|
||||
|
||||
RUN groupadd --system --gid 1001 appgroup && \
|
||||
useradd --system --uid 1001 --gid appgroup appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/target/release/thoughts-backend .
|
||||
|
||||
COPY .env.example .env
|
||||
|
||||
RUN chown -R appuser:appgroup /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["./thoughts-backend"]
|
21
thoughts-backend/LICENSE
Normal file
21
thoughts-backend/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Weiliang Li
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
129
thoughts-backend/README.md
Normal file
129
thoughts-backend/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# clean-axum
|
||||
|
||||
Axum scaffold with clean architecture.
|
||||
|
||||
You probably don't need [Rust on Rails](https://github.com/loco-rs/loco).
|
||||
|
||||
Refer to [this post](https://kigawas.me/posts/rustacean-clean-architecture-approach/) for rationale and background.
|
||||
|
||||
## Features
|
||||
|
||||
- [Axum](https://github.com/tokio-rs/axum) framework
|
||||
- [SeaORM](https://github.com/SeaQL/sea-orm) domain models
|
||||
- Completely separated API routers and DB-related logic (named "persistence" layer)
|
||||
- Completely separated input parameters, queries and output schemas
|
||||
- OpenAPI documentation ([Swagger UI](https://clean-axum.shuttleapp.rs/docs) and [Scalar](https://clean-axum.shuttleapp.rs/scalar)) powered by [Utoipa](https://github.com/juhaku/utoipa)
|
||||
- Error handling with [Anyhow](https://github.com/dtolnay/anyhow)
|
||||
- Custom parameter validation with [validator](https://github.com/Keats/validator)
|
||||
- Optional [Shuttle](https://www.shuttle.rs/) runtime
|
||||
- Optional [prefork](https://docs.rs/prefork/latest/prefork/) workers for maximizing performance on Linux
|
||||
|
||||
## Module hierarchy
|
||||
|
||||
### API logic
|
||||
|
||||
- `api::routers`: Axum endpoints
|
||||
- `api::error`: Models and traits for error handling
|
||||
- `api::extractor` Custom Axum extractors
|
||||
- `api::extractor::json`: `Json` for bodies and responses
|
||||
- `api::extractor::valid`: `Valid` for JSON body validation
|
||||
- `api::validation`: JSON validation model based on `validator`
|
||||
- `api::models`: Non domain model API models
|
||||
- `api::models::response`: JSON error response
|
||||
|
||||
### OpenAPI documentation
|
||||
|
||||
- `doc`: Utoipa doc declaration
|
||||
|
||||
### API-agonistic application logic
|
||||
|
||||
Main concept: Web framework is replaceable.
|
||||
|
||||
All modules here should not include any specific API web framework logic.
|
||||
|
||||
- `app::persistence`: DB manipulation (CRUD) functions
|
||||
- `app::config`: DB or API server configuration
|
||||
- `app::state`: APP state, e.g. DB connection
|
||||
- `app::error`: APP errors used by `api::error`. e.g. "User not found"
|
||||
|
||||
### DB/API-agnostic domain models
|
||||
|
||||
Main concept: Database (Sqlite/MySQL/PostgreSQL) is replaceable.
|
||||
|
||||
Except `models::domains` and `migration`, all modules are ORM library agnostic.
|
||||
|
||||
- `models::domains`: SeaORM domain models
|
||||
- `models::params`: Serde input parameters for creating/updating domain models in DB
|
||||
- `models::schemas`: Serde output schemas for combining different domain models
|
||||
- `models::queries`: Serde queries for filtering domain models
|
||||
- `migration`: SeaORM migration files
|
||||
|
||||
### Unit and integration tests
|
||||
|
||||
- `tests::api`: API integration tests. Hierarchy is the same as `api::routers`
|
||||
- `tests::app::persistence`: DB/ORM-related unit tests. Hierarchy is the same as `app::persistence`
|
||||
|
||||
### Others
|
||||
|
||||
- `utils`: Utility functions
|
||||
- `main`: Tokio and Shuttle conditional entry point
|
||||
|
||||
## Run
|
||||
|
||||
### Start server
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# touch dev.db
|
||||
# cargo install sea-orm-cli
|
||||
# sea-orm-cli migrate up
|
||||
cargo run
|
||||
|
||||
# or for production
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
### Call API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username":"aaa"}'
|
||||
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username":"abc"}'
|
||||
curl http://localhost:3000/users\?username\=a
|
||||
```
|
||||
|
||||
### OpenAPI doc (Swagger UI/Scalar)
|
||||
|
||||
```bash
|
||||
open http://localhost:3000/docs
|
||||
open http://localhost:3000/scalar
|
||||
```
|
||||
|
||||
## Start Shuttle local server
|
||||
|
||||
```bash
|
||||
# cargo install cargo-shuttle
|
||||
cargo shuttle run
|
||||
```
|
||||
|
||||
Make sure docker engine is running, otherwise:
|
||||
|
||||
```bash
|
||||
brew install colima docker
|
||||
colima start
|
||||
sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock
|
||||
```
|
||||
|
||||
## Shuttle deployment
|
||||
|
||||
```bash
|
||||
cargo shuttle login
|
||||
cargo shuttle deploy
|
||||
```
|
||||
|
||||
## Benchmark
|
||||
|
||||
```bash
|
||||
# edit .env to use Postgres
|
||||
cargo run --release
|
||||
wrk --latency -t20 -c50 -d10s http://localhost:3000/users\?username\=
|
||||
```
|
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)
|
||||
}
|
||||
}
|
14
thoughts-backend/app/Cargo.toml
Normal file
14
thoughts-backend/app/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "app"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
models = { path = "../models" }
|
||||
|
||||
sea-orm = { workspace = true }
|
3
thoughts-backend/app/README.md
Normal file
3
thoughts-backend/app/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# app
|
||||
|
||||
No axum or api dependencies should be introduced into this folder.
|
24
thoughts-backend/app/src/config.rs
Normal file
24
thoughts-backend/app/src/config.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
pub struct Config {
|
||||
pub db_url: String,
|
||||
pub host: String,
|
||||
pub port: u32,
|
||||
pub prefork: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Config {
|
||||
Config {
|
||||
db_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"),
|
||||
host: std::env::var("HOST").expect("HOST is not set in .env file"),
|
||||
port: std::env::var("PORT")
|
||||
.expect("PORT is not set in .env file")
|
||||
.parse()
|
||||
.expect("PORT is not a number"),
|
||||
prefork: std::env::var("PREFORK").is_ok_and(|v| v == "1"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_server_url(&self) -> String {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
3
thoughts-backend/app/src/error/mod.rs
Normal file
3
thoughts-backend/app/src/error/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod user;
|
||||
|
||||
pub use user::UserError;
|
14
thoughts-backend/app/src/error/user.rs
Normal file
14
thoughts-backend/app/src/error/user.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#[derive(Debug)]
|
||||
pub enum UserError {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
UserError::NotFound => write!(f, "User not found"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UserError {}
|
4
thoughts-backend/app/src/lib.rs
Normal file
4
thoughts-backend/app/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod persistence;
|
||||
pub mod state;
|
24
thoughts-backend/app/src/persistence/blog.rs
Normal file
24
thoughts-backend/app/src/persistence/blog.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
||||
|
||||
use models::{domains::blog, params::blog::CreateBlogParams, queries::blog::BlogQuery};
|
||||
|
||||
pub async fn search_blogs(db: &DbConn, query: BlogQuery) -> Result<Vec<blog::Model>, DbErr> {
|
||||
blog::Entity::find()
|
||||
.filter(blog::Column::Title.contains(query.title.unwrap_or_default()))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_blog(
|
||||
db: &DbConn,
|
||||
params: CreateBlogParams,
|
||||
) -> Result<blog::ActiveModel, DbErr> {
|
||||
blog::ActiveModel {
|
||||
author_id: Set(params.author_id as i32),
|
||||
title: Set(params.title),
|
||||
content: Set(params.content),
|
||||
..Default::default()
|
||||
}
|
||||
.save(db)
|
||||
.await
|
||||
}
|
2
thoughts-backend/app/src/persistence/mod.rs
Normal file
2
thoughts-backend/app/src/persistence/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod blog;
|
||||
pub mod user;
|
28
thoughts-backend/app/src/persistence/user.rs
Normal file
28
thoughts-backend/app/src/persistence/user.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
||||
|
||||
use models::domains::user;
|
||||
use models::params::user::CreateUserParams;
|
||||
use models::queries::user::UserQuery;
|
||||
|
||||
pub async fn create_user(
|
||||
db: &DbConn,
|
||||
params: CreateUserParams,
|
||||
) -> Result<user::ActiveModel, DbErr> {
|
||||
user::ActiveModel {
|
||||
username: Set(params.username),
|
||||
..Default::default()
|
||||
}
|
||||
.save(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_users(db: &DbConn, query: UserQuery) -> Result<Vec<user::Model>, DbErr> {
|
||||
user::Entity::find()
|
||||
.filter(user::Column::Username.contains(query.username.unwrap_or_default()))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user(db: &DbConn, id: i32) -> Result<Option<user::Model>, DbErr> {
|
||||
user::Entity::find_by_id(id).one(db).await
|
||||
}
|
6
thoughts-backend/app/src/state.rs
Normal file
6
thoughts-backend/app/src/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub conn: DatabaseConnection,
|
||||
}
|
23
thoughts-backend/doc/Cargo.toml
Normal file
23
thoughts-backend/doc/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "doc"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "doc"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true }
|
||||
utoipa = { workspace = true, features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = [
|
||||
"axum",
|
||||
"vendored",
|
||||
], default-features = false }
|
||||
utoipa-scalar = { version = "0.3.0", features = [
|
||||
"axum",
|
||||
], default-features = false }
|
||||
|
||||
api = { path = "../api" }
|
||||
models = { path = "../models" }
|
20
thoughts-backend/doc/src/blog.rs
Normal file
20
thoughts-backend/doc/src/blog.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use models::params::blog::CreateBlogParams;
|
||||
use models::schemas::blog::{BlogListSchema, BlogSchema};
|
||||
|
||||
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||
use api::routers::blog::*;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(blogs_get, blogs_post),
|
||||
components(schemas(
|
||||
CreateBlogParams,
|
||||
BlogListSchema,
|
||||
BlogSchema,
|
||||
ApiErrorResponse,
|
||||
ParamsErrorResponse,
|
||||
))
|
||||
)]
|
||||
pub(super) struct BlogApi;
|
35
thoughts-backend/doc/src/lib.rs
Normal file
35
thoughts-backend/doc/src/lib.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use axum::Router;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
mod blog;
|
||||
mod root;
|
||||
mod user;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
nest(
|
||||
(path = "/", api = root::RootApi),
|
||||
(path = "/users", api = user::UserApi),
|
||||
(path = "/blogs", api = blog::BlogApi),
|
||||
),
|
||||
tags(
|
||||
(name = "root", description = "Root API"),
|
||||
(name = "user", description = "User API"),
|
||||
(name = "blog", description = "Blog API"),
|
||||
|
||||
)
|
||||
)]
|
||||
struct _ApiDoc;
|
||||
|
||||
pub trait ApiDoc {
|
||||
fn attach_doc(self) -> Self;
|
||||
}
|
||||
|
||||
impl ApiDoc for Router {
|
||||
fn attach_doc(self) -> Self {
|
||||
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
||||
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
||||
}
|
||||
}
|
7
thoughts-backend/doc/src/root.rs
Normal file
7
thoughts-backend/doc/src/root.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use api::routers::root::*;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(paths(root_get))]
|
||||
pub(super) struct RootApi;
|
20
thoughts-backend/doc/src/user.rs
Normal file
20
thoughts-backend/doc/src/user.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use models::params::user::CreateUserParams;
|
||||
use models::schemas::user::{UserListSchema, UserSchema};
|
||||
|
||||
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||
use api::routers::user::*;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(users_get, users_id_get, users_post),
|
||||
components(schemas(
|
||||
CreateUserParams,
|
||||
UserListSchema,
|
||||
UserSchema,
|
||||
ApiErrorResponse,
|
||||
ParamsErrorResponse,
|
||||
))
|
||||
)]
|
||||
pub(super) struct UserApi;
|
18
thoughts-backend/migration/Cargo.toml
Normal file
18
thoughts-backend/migration/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
models = { path = "../models" }
|
||||
|
||||
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
||||
sea-orm-migration = { version = "1.1.12", features = [
|
||||
"sqlx-sqlite",
|
||||
"sqlx-postgres",
|
||||
] }
|
59
thoughts-backend/migration/README.md
Normal file
59
thoughts-backend/migration/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Running Migrator CLI
|
||||
|
||||
- Generate a new migration file
|
||||
|
||||
```sh
|
||||
cargo run -- generate MIGRATION_NAME
|
||||
```
|
||||
|
||||
- Apply all pending migrations
|
||||
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
|
||||
```sh
|
||||
cargo run -- up
|
||||
```
|
||||
|
||||
- Apply first 10 pending migrations
|
||||
|
||||
```sh
|
||||
cargo run -- up -n 10
|
||||
```
|
||||
|
||||
- Rollback last applied migrations
|
||||
|
||||
```sh
|
||||
cargo run -- down
|
||||
```
|
||||
|
||||
- Rollback last 10 applied migrations
|
||||
|
||||
```sh
|
||||
cargo run -- down -n 10
|
||||
```
|
||||
|
||||
- Drop all tables from the database, then reapply all migrations
|
||||
|
||||
```sh
|
||||
cargo run -- fresh
|
||||
```
|
||||
|
||||
- Rollback all applied migrations, then reapply all migrations
|
||||
|
||||
```sh
|
||||
cargo run -- refresh
|
||||
```
|
||||
|
||||
- Rollback all applied migrations
|
||||
|
||||
```sh
|
||||
cargo run -- reset
|
||||
```
|
||||
|
||||
- Check the status of all migrations
|
||||
|
||||
```sh
|
||||
cargo run -- status
|
||||
```
|
16
thoughts-backend/migration/src/lib.rs
Normal file
16
thoughts-backend/migration/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20240101_000001_init;
|
||||
mod m20240816_160144_blog;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20240101_000001_init::Migration),
|
||||
Box::new(m20240816_160144_blog::Migration),
|
||||
]
|
||||
}
|
||||
}
|
44
thoughts-backend/migration/src/m20240101_000001_init.rs
Normal file
44
thoughts-backend/migration/src/m20240101_000001_init.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(User::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(User::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(User::Username)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
pub(super) enum User {
|
||||
Table,
|
||||
Id,
|
||||
Username,
|
||||
}
|
47
thoughts-backend/migration/src/m20240816_160144_blog.rs
Normal file
47
thoughts-backend/migration/src/m20240816_160144_blog.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
use super::m20240101_000001_init::User;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Blog::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_auto(Blog::Id))
|
||||
.col(integer(Blog::AuthorId).not_null())
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_blog_author_id")
|
||||
.from(Blog::Table, Blog::AuthorId)
|
||||
.to(User::Table, User::Id)
|
||||
.on_update(ForeignKeyAction::NoAction)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.col(ColumnDef::new(Blog::Title).string().not_null())
|
||||
.col(ColumnDef::new(Blog::Content).string().not_null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Blog::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Blog {
|
||||
Table,
|
||||
Id,
|
||||
AuthorId,
|
||||
Title,
|
||||
Content,
|
||||
}
|
6
thoughts-backend/migration/src/main.rs
Normal file
6
thoughts-backend/migration/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
}
|
21
thoughts-backend/models/Cargo.toml
Normal file
21
thoughts-backend/models/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "models"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "models"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sea-orm = { workspace = true, features = [
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
"runtime-tokio-rustls",
|
||||
"macros",
|
||||
] }
|
||||
validator = { workspace = true, features = ["derive"] }
|
||||
utoipa = { workspace = true }
|
13
thoughts-backend/models/README.md
Normal file
13
thoughts-backend/models/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# models
|
||||
|
||||
No axum or api dependencies should be introduced into this folder.
|
||||
Only dependencies for modelling are allowed:
|
||||
|
||||
- serde (JSON serialization/deserialization)
|
||||
- SeaORM (domain models and database)
|
||||
- validator (parameter validation)
|
||||
- utoipa (openapi)
|
||||
|
||||
## SeaORM
|
||||
|
||||
Write migration files first, then generate models.
|
33
thoughts-backend/models/src/domains/blog.rs
Normal file
33
thoughts-backend/models/src/domains/blog.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "blog")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub author_id: i32,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::AuthorId",
|
||||
to = "super::user::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
6
thoughts-backend/models/src/domains/mod.rs
Normal file
6
thoughts-backend/models/src/domains/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod blog;
|
||||
pub mod user;
|
4
thoughts-backend/models/src/domains/prelude.rs
Normal file
4
thoughts-backend/models/src/domains/prelude.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
pub use super::blog::Entity as Blog;
|
||||
pub use super::user::Entity as User;
|
26
thoughts-backend/models/src/domains/user.rs
Normal file
26
thoughts-backend/models/src/domains/user.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "user")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(unique)]
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::blog::Entity")]
|
||||
Blog,
|
||||
}
|
||||
|
||||
impl Related<super::blog::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Blog.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
4
thoughts-backend/models/src/lib.rs
Normal file
4
thoughts-backend/models/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod domains;
|
||||
pub mod params;
|
||||
pub mod queries;
|
||||
pub mod schemas;
|
14
thoughts-backend/models/src/params/blog.rs
Normal file
14
thoughts-backend/models/src/params/blog.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateBlogParams {
|
||||
pub author_id: u32,
|
||||
|
||||
#[validate(length(min = 2))]
|
||||
pub title: String,
|
||||
|
||||
#[validate(length(min = 2))]
|
||||
pub content: String,
|
||||
}
|
2
thoughts-backend/models/src/params/mod.rs
Normal file
2
thoughts-backend/models/src/params/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod blog;
|
||||
pub mod user;
|
9
thoughts-backend/models/src/params/user.rs
Normal file
9
thoughts-backend/models/src/params/user.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateUserParams {
|
||||
#[validate(length(min = 2))]
|
||||
pub username: String,
|
||||
}
|
9
thoughts-backend/models/src/queries/blog.rs
Normal file
9
thoughts-backend/models/src/queries/blog.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
#[derive(Deserialize, Default, IntoParams)]
|
||||
#[into_params(style = Form, parameter_in = Query)]
|
||||
pub struct BlogQuery {
|
||||
#[param(nullable = true)]
|
||||
pub title: Option<String>,
|
||||
}
|
2
thoughts-backend/models/src/queries/mod.rs
Normal file
2
thoughts-backend/models/src/queries/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod blog;
|
||||
pub mod user;
|
9
thoughts-backend/models/src/queries/user.rs
Normal file
9
thoughts-backend/models/src/queries/user.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
#[derive(Deserialize, Default, IntoParams)]
|
||||
#[into_params(style = Form, parameter_in = Query)]
|
||||
pub struct UserQuery {
|
||||
#[param(nullable = true)]
|
||||
pub username: Option<String>,
|
||||
}
|
36
thoughts-backend/models/src/schemas/blog.rs
Normal file
36
thoughts-backend/models/src/schemas/blog.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::domains::blog;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BlogSchema {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub author_id: u32,
|
||||
}
|
||||
|
||||
impl From<blog::Model> for BlogSchema {
|
||||
fn from(blog: blog::Model) -> Self {
|
||||
Self {
|
||||
id: blog.id as u32,
|
||||
title: blog.title,
|
||||
content: blog.content,
|
||||
author_id: blog.author_id as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BlogListSchema {
|
||||
pub blogs: Vec<BlogSchema>,
|
||||
}
|
||||
|
||||
impl From<Vec<blog::Model>> for BlogListSchema {
|
||||
fn from(blogs: Vec<blog::Model>) -> Self {
|
||||
Self {
|
||||
blogs: blogs.into_iter().map(BlogSchema::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
2
thoughts-backend/models/src/schemas/mod.rs
Normal file
2
thoughts-backend/models/src/schemas/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod blog;
|
||||
pub mod user;
|
32
thoughts-backend/models/src/schemas/user.rs
Normal file
32
thoughts-backend/models/src/schemas/user.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::domains::user;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserSchema {
|
||||
pub id: u32,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl From<user::Model> for UserSchema {
|
||||
fn from(user: user::Model) -> Self {
|
||||
Self {
|
||||
id: user.id as u32,
|
||||
username: user.username,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserListSchema {
|
||||
pub users: Vec<UserSchema>,
|
||||
}
|
||||
|
||||
impl From<Vec<user::Model>> for UserListSchema {
|
||||
fn from(users: Vec<user::Model>) -> Self {
|
||||
Self {
|
||||
users: users.into_iter().map(UserSchema::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
27
thoughts-backend/src/main.rs
Normal file
27
thoughts-backend/src/main.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
#[cfg(not(feature = "shuttle"))]
|
||||
mod tokio;
|
||||
|
||||
#[cfg(not(feature = "shuttle"))]
|
||||
fn main() {
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "api=debug,clean_axum=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting with tokio");
|
||||
tokio::run();
|
||||
}
|
||||
|
||||
#[cfg(feature = "shuttle")]
|
||||
mod shuttle;
|
||||
|
||||
#[cfg(feature = "shuttle")]
|
||||
#[shuttle_runtime::main]
|
||||
async fn main(#[shuttle_shared_db::Postgres] db_url: String) -> shuttle_axum::ShuttleAxum {
|
||||
shuttle::run(&db_url).await
|
||||
}
|
13
thoughts-backend/src/shuttle.rs
Normal file
13
thoughts-backend/src/shuttle.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use api::{setup_db, setup_router};
|
||||
use doc::ApiDoc;
|
||||
use utils::migrate;
|
||||
|
||||
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
||||
tracing::info!("Starting with shuttle");
|
||||
|
||||
let conn = setup_db(&db_url, false).await;
|
||||
migrate(&conn).await.expect("Migration failed!");
|
||||
|
||||
let router = setup_router(conn).attach_doc();
|
||||
Ok(router.into())
|
||||
}
|
58
thoughts-backend/src/tokio.rs
Normal file
58
thoughts-backend/src/tokio.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use api::{setup_config, setup_db, setup_router};
|
||||
use doc::ApiDoc;
|
||||
use utils::{create_dev_db, migrate};
|
||||
|
||||
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
||||
tracing::info!("Worker {} started", child_num);
|
||||
|
||||
let conn = setup_db(db_url, prefork).await;
|
||||
|
||||
if child_num == 0 {
|
||||
migrate(&conn).await.expect("Migration failed!");
|
||||
}
|
||||
|
||||
let router = setup_router(conn).attach_doc();
|
||||
|
||||
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
||||
axum::serve(listener, router).await.expect("start server");
|
||||
}
|
||||
|
||||
#[cfg(feature = "prefork")]
|
||||
fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
||||
let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str());
|
||||
create_dev_db(db_url);
|
||||
|
||||
let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32;
|
||||
let is_parent = prefork::Prefork::from_resource(listener)
|
||||
.with_num_processes(num_of_cores)
|
||||
.with_tokio(move |child_num, listener| worker(child_num, db_url, true, listener))
|
||||
.fork()
|
||||
.expect("prefork failed");
|
||||
|
||||
if is_parent {
|
||||
tracing::info!("All workers stopped");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
|
||||
create_dev_db(db_url);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(worker(0, db_url, false, listener));
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let config = setup_config();
|
||||
|
||||
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
||||
listener.set_nonblocking(true).expect("non blocking failed");
|
||||
tracing::debug!("listening on http://{}", listener.local_addr().unwrap());
|
||||
|
||||
#[cfg(feature = "prefork")]
|
||||
if config.prefork {
|
||||
run_prefork(&config.db_url, listener);
|
||||
return;
|
||||
}
|
||||
|
||||
run_non_prefork(&config.db_url, listener);
|
||||
}
|
30
thoughts-backend/tests/api/blog.rs
Normal file
30
thoughts-backend/tests/api/blog.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use axum::{http::StatusCode, Router};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
|
||||
use utils::testing::{make_get_request, make_post_request};
|
||||
|
||||
pub(super) async fn test_post_blogs(app: Router) {
|
||||
let response = make_post_request(
|
||||
app,
|
||||
"/blogs",
|
||||
r#"{"author_id": 1, "title": "title", "content": "test"}"#.to_owned(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
pub(super) async fn test_get_blogs(app: Router) {
|
||||
let response = make_get_request(app, "/blogs").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let result: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(result["blogs"].as_array().unwrap().len(), 1);
|
||||
|
||||
let blog = &result["blogs"][0];
|
||||
assert_eq!(blog["author_id"], 1);
|
||||
assert_eq!(blog["title"], "title");
|
||||
assert_eq!(blog["content"], "test");
|
||||
}
|
44
thoughts-backend/tests/api/mod.rs
Normal file
44
thoughts-backend/tests/api/mod.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use api::setup_router;
|
||||
use utils::testing::setup_test_db;
|
||||
|
||||
mod blog;
|
||||
mod root;
|
||||
mod user;
|
||||
|
||||
use blog::*;
|
||||
use root::*;
|
||||
use user::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_main() {
|
||||
let db = setup_test_db("sqlite::root?mode=memory&cache=shared")
|
||||
.await
|
||||
.expect("Set up db failed!");
|
||||
|
||||
let app = setup_router(db);
|
||||
test_root(app).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_main() {
|
||||
let db = setup_test_db("sqlite::user?mode=memory&cache=shared")
|
||||
.await
|
||||
.expect("Set up db failed!");
|
||||
|
||||
let app = setup_router(db);
|
||||
test_post_users(app.clone()).await;
|
||||
test_post_users_error(app.clone()).await;
|
||||
test_get_users(app).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blog_main() {
|
||||
let db = setup_test_db("sqlite::blog?mode=memory&cache=shared")
|
||||
.await
|
||||
.expect("Set up db failed!");
|
||||
|
||||
let app = setup_router(db);
|
||||
test_post_users(app.clone()).await;
|
||||
test_post_blogs(app.clone()).await;
|
||||
test_get_blogs(app).await;
|
||||
}
|
12
thoughts-backend/tests/api/root.rs
Normal file
12
thoughts-backend/tests/api/root.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use axum::{http::StatusCode, Router};
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
use utils::testing::make_get_request;
|
||||
|
||||
pub(super) async fn test_root(app: Router) {
|
||||
let response = make_get_request(app, "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(&body[..], b"Hello, World from DB!");
|
||||
}
|
36
thoughts-backend/tests/api/user.rs
Normal file
36
thoughts-backend/tests/api/user.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use axum::{http::StatusCode, Router};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
|
||||
use utils::testing::{make_get_request, make_post_request};
|
||||
|
||||
pub(super) async fn test_post_users(app: Router) {
|
||||
let response = make_post_request(app, "/users", r#"{"username": "test"}"#.to_owned()).await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(&body[..], br#"{"id":1,"username":"test"}"#);
|
||||
}
|
||||
|
||||
pub(super) async fn test_post_users_error(app: Router) {
|
||||
let response = make_post_request(app, "/users", r#"{"username": "1"}"#.to_owned()).await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let result: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(result["message"], "Validation error");
|
||||
assert_eq!(result["details"]["username"][0]["code"], "length");
|
||||
assert_eq!(result["details"]["username"][0]["message"], Value::Null);
|
||||
assert_eq!(
|
||||
result["details"]["username"][0]["params"]["min"],
|
||||
Value::Number(2.into())
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn test_get_users(app: Router) {
|
||||
let response = make_get_request(app, "/users").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(&body[..], br#"{"users":[{"id":1,"username":"test"}]}"#);
|
||||
}
|
1
thoughts-backend/tests/app/mod.rs
Normal file
1
thoughts-backend/tests/app/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
mod persistence;
|
22
thoughts-backend/tests/app/persistence/blog.rs
Normal file
22
thoughts-backend/tests/app/persistence/blog.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use app::persistence::blog::create_blog;
|
||||
use models::domains::blog;
|
||||
use models::params::blog::CreateBlogParams;
|
||||
|
||||
pub(super) async fn test_blog(db: &DatabaseConnection) {
|
||||
let params = CreateBlogParams {
|
||||
author_id: 1,
|
||||
title: "title".to_string(),
|
||||
content: "test".to_string(),
|
||||
};
|
||||
|
||||
let blog = create_blog(db, params).await.expect("Create blog failed!");
|
||||
let expected = blog::ActiveModel {
|
||||
id: Unchanged(1),
|
||||
author_id: Unchanged(1),
|
||||
title: Unchanged("title".to_owned()),
|
||||
content: Unchanged("test".to_owned()),
|
||||
};
|
||||
assert_eq!(blog, expected);
|
||||
}
|
26
thoughts-backend/tests/app/persistence/mod.rs
Normal file
26
thoughts-backend/tests/app/persistence/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use utils::testing::setup_test_db;
|
||||
|
||||
mod blog;
|
||||
mod user;
|
||||
|
||||
use blog::test_blog;
|
||||
use user::test_user;
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_main() {
|
||||
let db = setup_test_db("sqlite::memory:")
|
||||
.await
|
||||
.expect("Set up db failed!");
|
||||
|
||||
test_user(&db).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blog_main() {
|
||||
let db = setup_test_db("sqlite::memory:")
|
||||
.await
|
||||
.expect("Set up db failed!");
|
||||
|
||||
test_user(&db).await;
|
||||
test_blog(&db).await;
|
||||
}
|
18
thoughts-backend/tests/app/persistence/user.rs
Normal file
18
thoughts-backend/tests/app/persistence/user.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use sea_orm::{DatabaseConnection, Unchanged};
|
||||
|
||||
use app::persistence::user::create_user;
|
||||
use models::domains::user;
|
||||
use models::params::user::CreateUserParams;
|
||||
|
||||
pub(super) async fn test_user(db: &DatabaseConnection) {
|
||||
let params = CreateUserParams {
|
||||
username: "test".to_string(),
|
||||
};
|
||||
|
||||
let user = create_user(db, params).await.expect("Create user failed!");
|
||||
let expected = user::ActiveModel {
|
||||
id: Unchanged(1),
|
||||
username: Unchanged("test".to_owned()),
|
||||
};
|
||||
assert_eq!(user, expected);
|
||||
}
|
2
thoughts-backend/tests/mod.rs
Normal file
2
thoughts-backend/tests/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod api;
|
||||
mod app;
|
16
thoughts-backend/utils/Cargo.toml
Normal file
16
thoughts-backend/utils/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "utils"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "utils"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
migration = { path = "../migration" }
|
||||
|
||||
axum = { workspace = true }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
sea-orm = { workspace = true, features = ["sqlx-sqlite", "sqlx-postgres"] }
|
3
thoughts-backend/utils/README.md
Normal file
3
thoughts-backend/utils/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# utils
|
||||
|
||||
Some utility functions to facilitate development.
|
8
thoughts-backend/utils/src/db.rs
Normal file
8
thoughts-backend/utils/src/db.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use migration::{sea_orm::DatabaseConnection, DbErr, Migrator, MigratorTrait, SchemaManager};
|
||||
|
||||
pub async fn migrate(conn: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let schema_manager = SchemaManager::new(conn);
|
||||
Migrator::up(conn, None).await?;
|
||||
assert!(schema_manager.has_table("user").await?);
|
||||
Ok(())
|
||||
}
|
22
thoughts-backend/utils/src/file.rs
Normal file
22
thoughts-backend/utils/src/file.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn touch(file_name: &str) {
|
||||
if cfg!(target_os = "windows") {
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("type nul >> {}", file_name)])
|
||||
.output()
|
||||
.expect("failed to execute touch");
|
||||
} else {
|
||||
Command::new("touch")
|
||||
.arg(file_name)
|
||||
.output()
|
||||
.expect("failed to execute touch");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_dev_db(db_url: &str) {
|
||||
let prefix = "sqlite://";
|
||||
if let Some(file_name) = db_url.strip_prefix(prefix) {
|
||||
touch(file_name);
|
||||
}
|
||||
}
|
6
thoughts-backend/utils/src/lib.rs
Normal file
6
thoughts-backend/utils/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod db;
|
||||
mod file;
|
||||
pub mod testing;
|
||||
|
||||
pub use db::migrate;
|
||||
pub use file::create_dev_db;
|
21
thoughts-backend/utils/src/testing/api/mod.rs
Normal file
21
thoughts-backend/utils/src/testing/api/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use axum::{body::Body, http::Request, response::Response, Router};
|
||||
use tower::ServiceExt;
|
||||
|
||||
pub async fn make_get_request(app: Router, url: &str) -> Response {
|
||||
app.oneshot(Request::builder().uri(url).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn make_post_request(app: Router, url: &str, body: String) -> Response {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
9
thoughts-backend/utils/src/testing/db/mod.rs
Normal file
9
thoughts-backend/utils/src/testing/db/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use sea_orm::{Database, DatabaseConnection, DbErr};
|
||||
|
||||
use crate::migrate;
|
||||
|
||||
pub async fn setup_test_db(db_url: &str) -> Result<DatabaseConnection, DbErr> {
|
||||
let db = Database::connect(db_url).await?;
|
||||
migrate(&db).await?;
|
||||
Ok(db)
|
||||
}
|
5
thoughts-backend/utils/src/testing/mod.rs
Normal file
5
thoughts-backend/utils/src/testing/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod api;
|
||||
mod db;
|
||||
|
||||
pub use api::{make_get_request, make_post_request};
|
||||
pub use db::setup_test_db;
|
Reference in New Issue
Block a user