Compare commits
10 Commits
6bd06ff2c8
...
6e63dca513
Author | SHA1 | Date | |
---|---|---|---|
6e63dca513 | |||
3dd6c0f64b | |||
e9c4088e68 | |||
c7c573f3f4 | |||
3d73c7f198 | |||
d70015c887 | |||
0e6c072387 | |||
decf81e535 | |||
912259ef54 | |||
e5747eaaf3 |
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
POSTGRES_USER=thoughts_user
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_DB=thoughts_db
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
backend-codebase.txt
|
backend-codebase.txt
|
||||||
frontend-codebase.txt
|
frontend-codebase.txt
|
||||||
|
.env
|
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
|
7
thoughts-backend/.env
Normal file
7
thoughts-backend/.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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=0
|
||||||
|
AUTH_SECRET=your_secret_key_here
|
7
thoughts-backend/.env.example
Normal file
7
thoughts-backend/.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL="sqlite://dev.db"
|
||||||
|
# DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
|
||||||
|
PREFORK=1
|
||||||
|
AUTH_SECRET=your_secret_key_here
|
||||||
|
BASE_URL=http://localhost:3000
|
2
thoughts-backend/.gitignore
vendored
Normal file
2
thoughts-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.env
|
5719
thoughts-backend/Cargo.lock
generated
Normal file
5719
thoughts-backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
thoughts-backend/Cargo.toml
Normal file
61
thoughts-backend/Cargo.toml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
[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]
|
||||||
|
tower = { version = "0.5.2", default-features = false }
|
||||||
|
axum = { version = "0.8.4", default-features = false }
|
||||||
|
sea-orm = { version = "1.1.12" }
|
||||||
|
sea-query = { version = "0.32.6" } # Added sea-query dependency
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
serde_json = { version = "1.0.140", features = ["raw_value"] }
|
||||||
|
tracing = "0.1.41"
|
||||||
|
utoipa = { version = "5.4.0", features = ["macros", "chrono"] }
|
||||||
|
validator = { version = "0.20.0", default-features = false }
|
||||||
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
|
|
||||||
|
[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\=
|
||||||
|
```
|
43
thoughts-backend/api/Cargo.toml
Normal file
43
thoughts-backend/api/Cargo.toml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
[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"] }
|
||||||
|
bcrypt = "0.17.1"
|
||||||
|
jsonwebtoken = "9.3.1"
|
||||||
|
once_cell = "1.21.3"
|
||||||
|
|
||||||
|
tokio = "1.45.1"
|
||||||
|
|
||||||
|
# db
|
||||||
|
sea-orm = { workspace = true }
|
||||||
|
|
||||||
|
# doc
|
||||||
|
utoipa = { workspace = true }
|
||||||
|
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
# local dependencies
|
||||||
|
app = { path = "../app" }
|
||||||
|
models = { path = "../models" }
|
||||||
|
reqwest = { version = "0.12.23", features = ["json"] }
|
||||||
|
|
||||||
|
|
||||||
|
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
|
||||||
|
tower-cookies = "0.11.0"
|
||||||
|
anyhow = "1.0.98"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
activitypub_federation = "0.6.5"
|
||||||
|
url = "2.5.7"
|
||||||
|
[dev-dependencies]
|
41
thoughts-backend/api/src/error/adapter.rs
Normal file
41
thoughts-backend/api/src/error/adapter.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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,
|
||||||
|
DbErr::UnpackInsertId => StatusCode::CONFLICT,
|
||||||
|
DbErr::RecordNotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
DbErr::Custom(s) if s == "Users cannot follow themselves" => StatusCode::BAD_REQUEST,
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR, // TODO:: more granularity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HTTPError for UserError {
|
||||||
|
fn to_status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
UserError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
UserError::NotFollowing => StatusCode::NOT_FOUND,
|
||||||
|
UserError::Forbidden => StatusCode::FORBIDDEN,
|
||||||
|
UserError::UsernameTaken => StatusCode::BAD_REQUEST,
|
||||||
|
UserError::AlreadyFollowing => StatusCode::BAD_REQUEST,
|
||||||
|
UserError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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;
|
||||||
|
}
|
57
thoughts-backend/api/src/extractor/auth.rs
Normal file
57
thoughts-backend/api/src/extractor/auth.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{request::Parts, HeaderMap, StatusCode},
|
||||||
|
};
|
||||||
|
|
||||||
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use app::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: i32,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
static JWT_SECRET: Lazy<String> =
|
||||||
|
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
||||||
|
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for AuthUser {
|
||||||
|
type Rejection = (StatusCode, &'static str);
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
_state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
||||||
|
let user_id_str = user_id_header.to_str().unwrap_or("0");
|
||||||
|
let user_id = user_id_str.parse::<i32>().unwrap_or(0);
|
||||||
|
return Ok(AuthUser { id: user_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = get_token_from_header(&parts.headers)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
|
||||||
|
|
||||||
|
let decoding_key = DecodingKey::from_secret(JWT_SECRET.as_ref());
|
||||||
|
|
||||||
|
let claims = decode::<Claims>(&token, &decoding_key, &Validation::default())
|
||||||
|
.map(|data| data.claims)
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
|
||||||
|
|
||||||
|
Ok(AuthUser { id: claims.sub })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|header| header.to_str().ok())
|
||||||
|
.and_then(|header| header.strip_prefix("Bearer "))
|
||||||
|
.map(|token| token.to_owned())
|
||||||
|
}
|
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()
|
||||||
|
}
|
||||||
|
}
|
8
thoughts-backend/api/src/extractor/mod.rs
Normal file
8
thoughts-backend/api/src/extractor/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
mod auth;
|
||||||
|
mod json;
|
||||||
|
mod valid;
|
||||||
|
|
||||||
|
pub use auth::AuthUser;
|
||||||
|
pub use auth::Claims;
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
70
thoughts-backend/api/src/federation.rs
Normal file
70
thoughts-backend/api/src/federation.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use app::{
|
||||||
|
persistence::{follow, user},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use models::domains::thought;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// This function handles pushing a new thought to all followers.
|
||||||
|
pub async fn federate_thought(
|
||||||
|
state: AppState,
|
||||||
|
thought: thought::Model,
|
||||||
|
author: models::domains::user::Model,
|
||||||
|
) {
|
||||||
|
// Find all followers of the author
|
||||||
|
let follower_ids = match follow::get_follower_ids(&state.conn, author.id).await {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get followers for federation: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if follower_ids.is_empty() {
|
||||||
|
tracing::debug!("No followers to federate to for user {}", author.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let thought_url = format!("{}/thoughts/{}", &state.base_url, thought.id);
|
||||||
|
let author_url = format!("{}/users/{}", &state.base_url, author.username);
|
||||||
|
|
||||||
|
// Construct the "Create" activity containing the "Note" object
|
||||||
|
let activity = json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": format!("{}/activity", thought_url),
|
||||||
|
"type": "Create",
|
||||||
|
"actor": author_url,
|
||||||
|
"object": {
|
||||||
|
"id": thought_url,
|
||||||
|
"type": "Note",
|
||||||
|
"attributedTo": author_url,
|
||||||
|
"content": thought.content,
|
||||||
|
"published": thought.created_at.to_rfc3339(),
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"cc": [format!("{}/followers", author_url)]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the inbox URLs for all followers
|
||||||
|
// In a real federated app, you would store remote users' full inbox URLs.
|
||||||
|
// For now, we assume followers are local and construct their inbox URLs.
|
||||||
|
let followers = match user::get_users_by_ids(&state.conn, follower_ids).await {
|
||||||
|
Ok(users) => users,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get follower user objects: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
for follower in followers {
|
||||||
|
let inbox_url = format!("{}/users/{}/inbox", &state.base_url, follower.username);
|
||||||
|
tracing::info!("Federating post {} to {}", thought.id, inbox_url);
|
||||||
|
|
||||||
|
let res = client.post(&inbox_url).json(&activity).send().await;
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::error!("Failed to federate to {}: {}", inbox_url, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
thoughts-backend/api/src/init.rs
Normal file
35
thoughts-backend/api/src/init.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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, config: &Config) -> Router {
|
||||||
|
create_router(AppState {
|
||||||
|
conn,
|
||||||
|
base_url: config.base_url.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
10
thoughts-backend/api/src/lib.rs
Normal file
10
thoughts-backend/api/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mod error;
|
||||||
|
mod extractor;
|
||||||
|
mod federation;
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
93
thoughts-backend/api/src/routers/auth.rs
Normal file
93
thoughts-backend/api/src/routers/auth.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use axum::{
|
||||||
|
debug_handler, extract::State, http::StatusCode, response::IntoResponse, routing::post, Router,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::ApiError,
|
||||||
|
extractor::{Claims, Json, Valid},
|
||||||
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
|
};
|
||||||
|
use app::{persistence::auth, state::AppState};
|
||||||
|
use models::{
|
||||||
|
params::auth::{LoginParams, RegisterParams},
|
||||||
|
schemas::user::UserSchema,
|
||||||
|
};
|
||||||
|
|
||||||
|
static JWT_SECRET: Lazy<String> =
|
||||||
|
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/register",
|
||||||
|
request_body = RegisterParams,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "User registered", body = UserSchema),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 409, description = "Username already exists", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Valid(Json(params)): Valid<Json<RegisterParams>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = auth::register_user(&state.conn, params).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(UserSchema::from(user))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/login",
|
||||||
|
request_body = LoginParams,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "User logged in", body = TokenResponse),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "Invalid credentials", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[debug_handler]
|
||||||
|
async fn login(
|
||||||
|
state: State<AppState>,
|
||||||
|
Valid(Json(params)): Valid<Json<LoginParams>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = auth::authenticate_user(&state.conn, params).await?;
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user.id,
|
||||||
|
exp: (now + 3600 * 24) as usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(JWT_SECRET.as_ref()),
|
||||||
|
)
|
||||||
|
.map_err(|e| ApiError::from(app::error::UserError::Internal(e.to_string())))?;
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(TokenResponse { token })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_auth_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/register", post(register))
|
||||||
|
.route("/login", post(login))
|
||||||
|
}
|
42
thoughts-backend/api/src/routers/feed.rs
Normal file
42
thoughts-backend/api/src/routers/feed.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||||
|
|
||||||
|
use app::{
|
||||||
|
persistence::{follow::get_followed_ids, thought::get_feed_for_user},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||||
|
|
||||||
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = []),
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn feed_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?;
|
||||||
|
let mut thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?;
|
||||||
|
|
||||||
|
let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?;
|
||||||
|
thoughts_with_authors.extend(own_thoughts);
|
||||||
|
|
||||||
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
|
.into_iter()
|
||||||
|
.map(ThoughtSchema::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_feed_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/", get(feed_get))
|
||||||
|
}
|
30
thoughts-backend/api/src/routers/mod.rs
Normal file
30
thoughts-backend/api/src/routers/mod.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod feed;
|
||||||
|
pub mod root;
|
||||||
|
pub mod thought;
|
||||||
|
pub mod user;
|
||||||
|
pub mod well_known;
|
||||||
|
|
||||||
|
use crate::routers::{auth::create_auth_router, well_known::create_well_known_router};
|
||||||
|
use app::state::AppState;
|
||||||
|
use root::create_root_router;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use user::create_user_router;
|
||||||
|
|
||||||
|
use crate::routers::{feed::create_feed_router, thought::create_thought_router};
|
||||||
|
|
||||||
|
pub fn create_router(state: AppState) -> Router {
|
||||||
|
let cors = CorsLayer::permissive();
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.merge(create_root_router())
|
||||||
|
.nest("/.well-known", create_well_known_router())
|
||||||
|
.nest("/auth", create_auth_router())
|
||||||
|
.nest("/users", create_user_router())
|
||||||
|
.nest("/thoughts", create_thought_router())
|
||||||
|
.nest("/feed", create_feed_router())
|
||||||
|
.with_state(state)
|
||||||
|
.layer(cors)
|
||||||
|
}
|
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))
|
||||||
|
}
|
95
thoughts-backend/api/src/routers/thought.rs
Normal file
95
thoughts-backend/api/src/routers/thought.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{delete, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use app::{
|
||||||
|
error::UserError,
|
||||||
|
persistence::thought::{create_thought, delete_thought, get_thought},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::ApiError,
|
||||||
|
extractor::{AuthUser, Json, Valid},
|
||||||
|
federation,
|
||||||
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "",
|
||||||
|
request_body = CreateThoughtParams,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Thought created", body = ThoughtSchema),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ParamsErrorResponse)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = []),
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn thoughts_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Valid(Json(params)): Valid<Json<CreateThoughtParams>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let thought = create_thought(&state.conn, auth_user.id, params).await?;
|
||||||
|
let author = app::persistence::user::get_user(&state.conn, auth_user.id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
|
||||||
|
|
||||||
|
// Spawn a background task to handle federation without blocking the response
|
||||||
|
tokio::spawn(federation::federate_thought(
|
||||||
|
state.clone(),
|
||||||
|
thought.clone(),
|
||||||
|
author.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let schema = ThoughtSchema::from_models(&thought, &author);
|
||||||
|
Ok((StatusCode::CREATED, Json(schema)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/{id}",
|
||||||
|
params(
|
||||||
|
("id" = i32, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "Thought deleted"),
|
||||||
|
(status = 403, description = "Forbidden", body = ApiErrorResponse),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = []),
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn thoughts_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let thought = get_thought(&state.conn, id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
if thought.author_id != auth_user.id {
|
||||||
|
return Err(UserError::Forbidden.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_thought(&state.conn, id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_thought_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", post(thoughts_post))
|
||||||
|
.route("/{id}", delete(thoughts_delete))
|
||||||
|
}
|
325
thoughts-backend/api/src/routers/user.rs
Normal file
325
thoughts-backend/api/src/routers/user.rs
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use app::persistence::{
|
||||||
|
follow,
|
||||||
|
thought::get_thoughts_by_user,
|
||||||
|
user::{get_user, search_users},
|
||||||
|
};
|
||||||
|
use app::state::AppState;
|
||||||
|
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||||
|
use models::schemas::thought::ThoughtListSchema;
|
||||||
|
use models::schemas::user::{UserListSchema, UserSchema};
|
||||||
|
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
|
use crate::extractor::Json;
|
||||||
|
use crate::models::ApiErrorResponse;
|
||||||
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
|
||||||
|
#[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 = "/{username}/thoughts",
|
||||||
|
params(
|
||||||
|
("username" = String, Path, description = "Username")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of user's thoughts", body = ThoughtListSchema),
|
||||||
|
(status = 404, description = "User not found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn user_thoughts_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let thoughts_with_authors = get_thoughts_by_user(&state.conn, user.id).await?;
|
||||||
|
|
||||||
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
|
.into_iter()
|
||||||
|
.map(ThoughtSchema::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/{username}/follow",
|
||||||
|
params(
|
||||||
|
("username" = String, Path, description = "Username to follow")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "User followed successfully"),
|
||||||
|
(status = 404, description = "User not found", body = ApiErrorResponse),
|
||||||
|
(status = 409, description = "Already following", body = ApiErrorResponse)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = []),
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn user_follow_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user_to_follow = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let result = follow::follow_user(&state.conn, auth_user.id, user_to_follow.id).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||||
|
Err(e)
|
||||||
|
if matches!(
|
||||||
|
e.sql_err(),
|
||||||
|
Some(sea_orm::SqlErr::UniqueConstraintViolation { .. })
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Err(UserError::AlreadyFollowing.into())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/{username}/follow",
|
||||||
|
params(
|
||||||
|
("username" = String, Path, description = "Username to unfollow")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "User unfollowed successfully"),
|
||||||
|
(status = 404, description = "User not found or not being followed", body = ApiErrorResponse)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = []),
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn user_follow_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user_to_unfollow = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
follow::unfollow_user(&state.conn, auth_user.id, user_to_unfollow.id).await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/{username}/inbox",
|
||||||
|
request_body = Object,
|
||||||
|
description = "The ActivityPub inbox for receiving activities.",
|
||||||
|
responses(
|
||||||
|
(status = 202, description = "Activity accepted"),
|
||||||
|
(status = 400, description = "Bad Request"),
|
||||||
|
(status = 404, description = "User not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn user_inbox_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
Json(activity): Json<Value>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let activity_type = activity["type"].as_str().unwrap_or_default();
|
||||||
|
let actor_id = activity["actor"].as_str().unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::debug!(target: "activitypub", "Received activity '{}' from actor '{}' in {}'s inbox", activity_type, actor_id, username);
|
||||||
|
|
||||||
|
// For now, we only handle the "Follow" activity
|
||||||
|
if activity_type == "Follow" {
|
||||||
|
follow::add_follower(&state.conn, user.id, actor_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per the ActivityPub spec, we should return a 202 Accepted status
|
||||||
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{param}",
|
||||||
|
params(
|
||||||
|
("param" = String, Path, description = "User ID or username")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "User profile or ActivityPub actor", body = UserSchema, content_type = "application/json"),
|
||||||
|
(status = 200, description = "ActivityPub actor", body = Object, content_type = "application/activity+json"),
|
||||||
|
(status = 404, description = "User not found", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = []),
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_user_by_param(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(param): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
// First, try to handle it as a numeric ID.
|
||||||
|
if let Ok(id) = param.parse::<i32>() {
|
||||||
|
return match get_user(&state.conn, id).await {
|
||||||
|
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
||||||
|
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||||
|
Err(db_err) => ApiError::from(db_err).into_response(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not a number, treat it as a username and perform content negotiation.
|
||||||
|
let username = param;
|
||||||
|
let is_activitypub_request = headers
|
||||||
|
.get(axum::http::header::ACCEPT)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map_or(false, |s| s.contains("application/activity+json"));
|
||||||
|
|
||||||
|
if is_activitypub_request {
|
||||||
|
// This is the logic from `user_actor_get`.
|
||||||
|
match get_user_by_username(&state.conn, &username).await {
|
||||||
|
Ok(Some(user)) => {
|
||||||
|
let user_url = format!("{}/users/{}", &state.base_url, user.username);
|
||||||
|
let actor = json!({
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1"
|
||||||
|
],
|
||||||
|
"id": user_url,
|
||||||
|
"type": "Person",
|
||||||
|
"preferredUsername": user.username,
|
||||||
|
"inbox": format!("{}/inbox", user_url),
|
||||||
|
"outbox": format!("{}/outbox", user_url),
|
||||||
|
});
|
||||||
|
let mut headers = axum::http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
"application/activity+json".parse().unwrap(),
|
||||||
|
);
|
||||||
|
(headers, Json(actor)).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||||
|
Err(e) => ApiError::from(e).into_response(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match get_user_by_username(&state.conn, &username).await {
|
||||||
|
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
||||||
|
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||||
|
Err(e) => ApiError::from(e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{username}/outbox",
|
||||||
|
description = "The ActivityPub outbox for sending activities.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Activity collection", body = Object),
|
||||||
|
(status = 404, description = "User not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn user_outbox_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let thoughts = get_thoughts_by_user(&state.conn, user.id).await?;
|
||||||
|
|
||||||
|
// Format the outbox as an ActivityPub OrderedCollection
|
||||||
|
let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username);
|
||||||
|
let items: Vec<Value> = thoughts
|
||||||
|
.into_iter()
|
||||||
|
.map(|thought| {
|
||||||
|
let thought_url = format!("{}/thoughts/{}", &state.base_url, thought.id);
|
||||||
|
let author_url = format!("{}/users/{}", &state.base_url, thought.author_username);
|
||||||
|
json!({
|
||||||
|
"id": format!("{}/activity", thought_url),
|
||||||
|
"type": "Create",
|
||||||
|
"actor": author_url,
|
||||||
|
"published": thought.created_at,
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"object": {
|
||||||
|
"id": thought_url,
|
||||||
|
"type": "Note",
|
||||||
|
"attributedTo": author_url,
|
||||||
|
"content": thought.content,
|
||||||
|
"published": thought.created_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let outbox = json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": outbox_url,
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"totalItems": items.len(),
|
||||||
|
"orderedItems": items,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut headers = axum::http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
"application/activity+json".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((headers, Json(outbox)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_user_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(users_get))
|
||||||
|
.route("/{param}", get(get_user_by_param))
|
||||||
|
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||||
|
.route(
|
||||||
|
"/{username}/follow",
|
||||||
|
post(user_follow_post).delete(user_follow_delete),
|
||||||
|
)
|
||||||
|
.route("/{username}/inbox", post(user_inbox_post))
|
||||||
|
.route("/{username}/outbox", get(user_outbox_get))
|
||||||
|
}
|
70
thoughts-backend/api/src/routers/well_known.rs
Normal file
70
thoughts-backend/api/src/routers/well_known.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use app::state::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::{IntoResponse, Json},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WebFingerQuery {
|
||||||
|
resource: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WebFingerLink {
|
||||||
|
rel: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
type_: String,
|
||||||
|
href: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WebFingerResponse {
|
||||||
|
subject: String,
|
||||||
|
links: Vec<WebFingerLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn webfinger(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<WebFingerQuery>,
|
||||||
|
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||||
|
if let Some((scheme, account_info)) = query.resource.split_once(':') {
|
||||||
|
if scheme != "acct" {
|
||||||
|
return Err((
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid resource scheme",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let account_parts: Vec<&str> = account_info.split('@').collect();
|
||||||
|
let username = account_parts[0];
|
||||||
|
|
||||||
|
let user = match app::persistence::user::get_user_by_username(&state.conn, username).await {
|
||||||
|
Ok(Some(user)) => user,
|
||||||
|
_ => return Err((axum::http::StatusCode::NOT_FOUND, "User not found")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_url = Url::parse(&format!("{}/users/{}", &state.base_url, user.username)).unwrap();
|
||||||
|
|
||||||
|
let response = WebFingerResponse {
|
||||||
|
subject: query.resource,
|
||||||
|
links: vec![WebFingerLink {
|
||||||
|
rel: "self".to_string(),
|
||||||
|
type_: "application/activity+json".to_string(),
|
||||||
|
href: user_url,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
} else {
|
||||||
|
Err((
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid resource format",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_well_known_router() -> axum::Router<AppState> {
|
||||||
|
axum::Router::new().route("/webfinger", axum::routing::get(webfinger))
|
||||||
|
}
|
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)
|
||||||
|
}
|
||||||
|
}
|
16
thoughts-backend/app/Cargo.toml
Normal file
16
thoughts-backend/app/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bcrypt = "0.17.1"
|
||||||
|
models = { path = "../models" }
|
||||||
|
validator = "0.20"
|
||||||
|
|
||||||
|
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.
|
28
thoughts-backend/app/src/config.rs
Normal file
28
thoughts-backend/app/src/config.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
pub struct Config {
|
||||||
|
pub db_url: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u32,
|
||||||
|
pub prefork: bool,
|
||||||
|
pub auth_secret: String,
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
auth_secret: std::env::var("AUTH_SECRET").expect("AUTH_SECRET is not set in .env file"),
|
||||||
|
base_url: std::env::var("BASE_URL").expect("BASE_URL is not set in .env file"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
26
thoughts-backend/app/src/error/user.rs
Normal file
26
thoughts-backend/app/src/error/user.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#[derive(Debug)]
|
||||||
|
pub enum UserError {
|
||||||
|
NotFound,
|
||||||
|
NotFollowing,
|
||||||
|
Forbidden,
|
||||||
|
UsernameTaken,
|
||||||
|
AlreadyFollowing,
|
||||||
|
Validation(String), // Added Validation variant
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
UserError::NotFollowing => write!(f, "You are not following this user"),
|
||||||
|
UserError::Forbidden => write!(f, "You do not have permission to perform this action"),
|
||||||
|
UserError::UsernameTaken => write!(f, "Username is already taken"),
|
||||||
|
UserError::AlreadyFollowing => write!(f, "You are already following this user"),
|
||||||
|
UserError::Validation(msg) => write!(f, "Validation error: {}", msg),
|
||||||
|
UserError::Internal(msg) => write!(f, "Internal server error: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
54
thoughts-backend/app/src/persistence/auth.rs
Normal file
54
thoughts-backend/app/src/persistence/auth.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use bcrypt::{hash, verify, BcryptError, DEFAULT_COST};
|
||||||
|
use models::{
|
||||||
|
domains::user,
|
||||||
|
params::auth::{LoginParams, RegisterParams},
|
||||||
|
};
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, QueryFilter, Set};
|
||||||
|
use validator::Validate; // Import the Validate trait
|
||||||
|
|
||||||
|
use crate::error::UserError;
|
||||||
|
|
||||||
|
fn hash_password(password: &str) -> Result<String, BcryptError> {
|
||||||
|
hash(password, DEFAULT_COST)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
|
||||||
|
// Validate the parameters
|
||||||
|
params
|
||||||
|
.validate()
|
||||||
|
.map_err(|e| UserError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let hashed_password =
|
||||||
|
hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let new_user = user::ActiveModel {
|
||||||
|
username: Set(params.username),
|
||||||
|
password_hash: Set(Some(hashed_password)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
new_user.insert(db).await.map_err(|e| {
|
||||||
|
if let Some(sea_orm::SqlErr::UniqueConstraintViolation { .. }) = e.sql_err() {
|
||||||
|
UserError::UsernameTaken
|
||||||
|
} else {
|
||||||
|
UserError::Internal(e.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate_user(db: &DbConn, params: LoginParams) -> Result<user::Model, UserError> {
|
||||||
|
let user = user::Entity::find()
|
||||||
|
.filter(user::Column::Username.eq(params.username))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let password_hash = user.password_hash.as_ref().ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
if verify(params.password, password_hash).map_err(|e| UserError::Internal(e.to_string()))? {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
Err(UserError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
76
thoughts-backend/app/src/persistence/follow.rs
Normal file
76
thoughts-backend/app/src/persistence/follow.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::{error::UserError, persistence::user::get_user_by_username};
|
||||||
|
use models::domains::follow;
|
||||||
|
|
||||||
|
pub async fn add_follower(
|
||||||
|
db: &DbConn,
|
||||||
|
followed_id: i32,
|
||||||
|
follower_actor_id: &str,
|
||||||
|
) -> Result<(), UserError> {
|
||||||
|
let follower_username = follower_actor_id
|
||||||
|
.split('/')
|
||||||
|
.last()
|
||||||
|
.ok_or_else(|| UserError::Internal("Invalid follower actor ID".to_string()))?;
|
||||||
|
|
||||||
|
let follower = get_user_by_username(db, follower_username)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
follow_user(db, follower.id, followed_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Result<(), DbErr> {
|
||||||
|
if follower_id == followed_id {
|
||||||
|
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let follow = follow::ActiveModel {
|
||||||
|
follower_id: Set(follower_id),
|
||||||
|
followed_id: Set(followed_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
follow.insert(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfollow_user(
|
||||||
|
db: &DbConn,
|
||||||
|
follower_id: i32,
|
||||||
|
followed_id: i32,
|
||||||
|
) -> Result<(), UserError> {
|
||||||
|
let deleted_result = follow::Entity::delete_many()
|
||||||
|
.filter(follow::Column::FollowerId.eq(follower_id))
|
||||||
|
.filter(follow::Column::FollowedId.eq(followed_id))
|
||||||
|
.exec(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if deleted_result.rows_affected == 0 {
|
||||||
|
return Err(UserError::NotFollowing);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
|
||||||
|
let followed_users = follow::Entity::find()
|
||||||
|
.filter(follow::Column::FollowerId.eq(user_id))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_follower_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
|
||||||
|
let followers = follow::Entity::find()
|
||||||
|
.filter(follow::Column::FollowedId.eq(user_id))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
||||||
|
}
|
4
thoughts-backend/app/src/persistence/mod.rs
Normal file
4
thoughts-backend/app/src/persistence/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod follow;
|
||||||
|
pub mod thought;
|
||||||
|
pub mod user;
|
78
thoughts-backend/app/src/persistence/thought.rs
Normal file
78
thoughts-backend/app/src/persistence/thought.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
||||||
|
QuerySelect, RelationTrait, Set,
|
||||||
|
};
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
domains::{thought, user},
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::ThoughtWithAuthor,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::UserError;
|
||||||
|
|
||||||
|
pub async fn create_thought(
|
||||||
|
db: &DbConn,
|
||||||
|
author_id: i32,
|
||||||
|
params: CreateThoughtParams,
|
||||||
|
) -> Result<thought::Model, DbErr> {
|
||||||
|
thought::ActiveModel {
|
||||||
|
author_id: Set(author_id),
|
||||||
|
content: Set(params.content),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought(db: &DbConn, thought_id: i32) -> Result<Option<thought::Model>, DbErr> {
|
||||||
|
thought::Entity::find_by_id(thought_id).one(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_thought(db: &DbConn, thought_id: i32) -> Result<(), DbErr> {
|
||||||
|
thought::Entity::delete_by_id(thought_id).exec(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thoughts_by_user(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
thought::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(thought::Column::Id)
|
||||||
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::AuthorId)
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(thought::Column::AuthorId.eq(user_id))
|
||||||
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
|
.into_model::<ThoughtWithAuthor>()
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_feed_for_user(
|
||||||
|
db: &DbConn,
|
||||||
|
followed_ids: Vec<i32>,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
||||||
|
if followed_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
thought::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(thought::Column::Id)
|
||||||
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::AuthorId)
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(thought::Column::AuthorId.is_in(followed_ids))
|
||||||
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
|
.into_model::<ThoughtWithAuthor>()
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))
|
||||||
|
}
|
45
thoughts-backend/app/src/persistence/user.rs
Normal file
45
thoughts-backend/app/src/persistence/user.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_username(
|
||||||
|
db: &DbConn,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<user::Model>, DbErr> {
|
||||||
|
user::Entity::find()
|
||||||
|
.filter(user::Column::Username.eq(username))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<i32>) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
user::Entity::find()
|
||||||
|
.filter(user::Column::Id.is_in(ids))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
7
thoughts-backend/app/src/state.rs
Normal file
7
thoughts-backend/app/src/state.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub conn: DatabaseConnection,
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
10
thoughts-backend/common/Cargo.toml
Normal file
10
thoughts-backend/common/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "common"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
utoipa = { workspace = true }
|
||||||
|
sea-orm = { workspace = true }
|
||||||
|
sea-query = { workspace = true }
|
53
thoughts-backend/common/src/lib.rs
Normal file
53
thoughts-backend/common/src/lib.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use sea_orm::prelude::DateTimeWithTimeZone;
|
||||||
|
use sea_orm::TryGetError;
|
||||||
|
use sea_orm::{sea_query::ColumnType, sea_query::Value, sea_query::ValueType, TryGetable};
|
||||||
|
use sea_query::ValueTypeErr;
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema, Debug)]
|
||||||
|
#[schema(example = "2025-09-05T12:34:56Z")]
|
||||||
|
pub struct DateTimeWithTimeZoneWrapper(String);
|
||||||
|
|
||||||
|
impl From<DateTimeWithTimeZone> for DateTimeWithTimeZoneWrapper {
|
||||||
|
fn from(value: DateTimeWithTimeZone) -> Self {
|
||||||
|
DateTimeWithTimeZoneWrapper(value.to_rfc3339())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryGetable for DateTimeWithTimeZoneWrapper {
|
||||||
|
fn try_get_by<I: sea_orm::ColIdx>(
|
||||||
|
res: &sea_orm::QueryResult,
|
||||||
|
index: I,
|
||||||
|
) -> Result<Self, TryGetError> {
|
||||||
|
let value: String = res.try_get_by(index)?;
|
||||||
|
Ok(DateTimeWithTimeZoneWrapper(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_get(res: &sea_orm::QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
|
||||||
|
let value: String = res.try_get(pre, col)?;
|
||||||
|
Ok(DateTimeWithTimeZoneWrapper(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValueType for DateTimeWithTimeZoneWrapper {
|
||||||
|
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
|
||||||
|
if let Value::String(Some(string)) = v {
|
||||||
|
Ok(DateTimeWithTimeZoneWrapper(*string))
|
||||||
|
} else {
|
||||||
|
Err(ValueTypeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn array_type() -> sea_query::ArrayType {
|
||||||
|
sea_query::ArrayType::String
|
||||||
|
}
|
||||||
|
|
||||||
|
fn column_type() -> ColumnType {
|
||||||
|
ColumnType::String(sea_query::StringLen::Max)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_name() -> String {
|
||||||
|
"DateTimeWithTimeZoneWrapper".to_string()
|
||||||
|
}
|
||||||
|
}
|
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" }
|
23
thoughts-backend/doc/src/auth.rs
Normal file
23
thoughts-backend/doc/src/auth.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use api::{
|
||||||
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
|
routers::auth::*,
|
||||||
|
};
|
||||||
|
use models::{
|
||||||
|
params::auth::{LoginParams, RegisterParams},
|
||||||
|
schemas::user::UserSchema,
|
||||||
|
};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(register, login),
|
||||||
|
components(schemas(
|
||||||
|
RegisterParams,
|
||||||
|
LoginParams,
|
||||||
|
UserSchema,
|
||||||
|
TokenResponse,
|
||||||
|
ApiErrorResponse,
|
||||||
|
ParamsErrorResponse,
|
||||||
|
))
|
||||||
|
)]
|
||||||
|
pub(super) struct AuthApi;
|
10
thoughts-backend/doc/src/feed.rs
Normal file
10
thoughts-backend/doc/src/feed.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use api::{models::ApiErrorResponse, routers::feed::*};
|
||||||
|
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(feed_get),
|
||||||
|
components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse))
|
||||||
|
)]
|
||||||
|
pub(super) struct FeedApi;
|
59
thoughts-backend/doc/src/lib.rs
Normal file
59
thoughts-backend/doc/src/lib.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use axum::Router;
|
||||||
|
use utoipa::{
|
||||||
|
openapi::security::{ApiKey, ApiKeyValue, Http, SecurityScheme},
|
||||||
|
Modify, OpenApi,
|
||||||
|
};
|
||||||
|
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||||
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod feed;
|
||||||
|
mod root;
|
||||||
|
mod thought;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
nest(
|
||||||
|
(path = "/", api = root::RootApi),
|
||||||
|
(path = "/auth", api = auth::AuthApi),
|
||||||
|
(path = "/users", api = user::UserApi),
|
||||||
|
(path = "/thoughts", api = thought::ThoughtApi),
|
||||||
|
(path = "/feed", api = feed::FeedApi),
|
||||||
|
),
|
||||||
|
tags(
|
||||||
|
(name = "root", description = "Root API"),
|
||||||
|
(name = "auth", description = "Authentication API"),
|
||||||
|
(name = "user", description = "User & Social API"),
|
||||||
|
(name = "thought", description = "Thoughts API"),
|
||||||
|
(name = "feed", description = "Feed API"),
|
||||||
|
),
|
||||||
|
modifiers(&SecurityAddon),
|
||||||
|
)]
|
||||||
|
struct _ApiDoc;
|
||||||
|
|
||||||
|
struct SecurityAddon;
|
||||||
|
impl Modify for SecurityAddon {
|
||||||
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||||
|
let components = openapi.components.get_or_insert_with(Default::default);
|
||||||
|
components.add_security_scheme(
|
||||||
|
"bearer_auth",
|
||||||
|
SecurityScheme::Http(Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer)),
|
||||||
|
);
|
||||||
|
components.add_security_scheme(
|
||||||
|
"api_key",
|
||||||
|
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
18
thoughts-backend/doc/src/thought.rs
Normal file
18
thoughts-backend/doc/src/thought.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use api::{
|
||||||
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
|
routers::thought::*,
|
||||||
|
};
|
||||||
|
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(thoughts_post, thoughts_delete),
|
||||||
|
components(schemas(
|
||||||
|
CreateThoughtParams,
|
||||||
|
ThoughtSchema,
|
||||||
|
ApiErrorResponse,
|
||||||
|
ParamsErrorResponse
|
||||||
|
))
|
||||||
|
)]
|
||||||
|
pub(super) struct ThoughtApi;
|
32
thoughts-backend/doc/src/user.rs
Normal file
32
thoughts-backend/doc/src/user.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||||
|
use api::routers::user::*;
|
||||||
|
use models::params::user::CreateUserParams;
|
||||||
|
use models::schemas::{
|
||||||
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
|
user::{UserListSchema, UserSchema},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(
|
||||||
|
users_get,
|
||||||
|
get_user_by_param,
|
||||||
|
user_thoughts_get,
|
||||||
|
user_follow_post,
|
||||||
|
user_follow_delete,
|
||||||
|
user_inbox_post,
|
||||||
|
user_outbox_get,
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
CreateUserParams,
|
||||||
|
UserListSchema,
|
||||||
|
UserSchema,
|
||||||
|
ThoughtSchema,
|
||||||
|
ThoughtListSchema,
|
||||||
|
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 m20250905_000001_init;
|
||||||
|
|
||||||
|
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(m20250905_000001_init::Migration),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
47
thoughts-backend/migration/src/m20240101_000001_init.rs
Normal file
47
thoughts-backend/migration/src/m20240101_000001_init.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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()
|
||||||
|
.col(ColumnDef::new(User::PasswordHash).string())
|
||||||
|
.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,
|
||||||
|
PasswordHash,
|
||||||
|
}
|
95
thoughts-backend/migration/src/m20250905_000001_init.rs
Normal file
95
thoughts-backend/migration/src/m20250905_000001_init.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use super::m20240101_000001_init::User;
|
||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// --- Create Thought Table ---
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Thought::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(pk_auto(Thought::Id))
|
||||||
|
.col(integer(Thought::AuthorId).not_null())
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_thought_author_id")
|
||||||
|
.from(Thought::Table, Thought::AuthorId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_update(ForeignKeyAction::NoAction)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.col(string(Thought::Content).not_null())
|
||||||
|
.col(
|
||||||
|
timestamp_with_time_zone(Thought::CreatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// --- Create Follow Table ---
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Follow::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(integer(Follow::FollowerId).not_null())
|
||||||
|
.col(integer(Follow::FollowedId).not_null())
|
||||||
|
// Composite Primary Key to ensure a user can only follow another once
|
||||||
|
.primary_key(
|
||||||
|
Index::create()
|
||||||
|
.col(Follow::FollowerId)
|
||||||
|
.col(Follow::FollowedId),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_follow_follower_id")
|
||||||
|
.from(Follow::Table, Follow::FollowerId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_follow_followed_id")
|
||||||
|
.from(Follow::Table, Follow::FollowedId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Follow::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Thought::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Thought {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
AuthorId,
|
||||||
|
Content,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Follow {
|
||||||
|
Table,
|
||||||
|
// The user who is initiating the follow
|
||||||
|
FollowerId,
|
||||||
|
// The user who is being followed
|
||||||
|
FollowedId,
|
||||||
|
}
|
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;
|
||||||
|
}
|
23
thoughts-backend/models/Cargo.toml
Normal file
23
thoughts-backend/models/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[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 }
|
||||||
|
|
||||||
|
common = { path = "../common" }
|
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.
|
32
thoughts-backend/models/src/domains/follow.rs
Normal file
32
thoughts-backend/models/src/domains/follow.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "follow")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub follower_id: i32,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub followed_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::FollowerId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Follower,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::FollowedId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Followed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
7
thoughts-backend/models/src/domains/mod.rs
Normal file
7
thoughts-backend/models/src/domains/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||||
|
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod follow;
|
||||||
|
pub mod thought;
|
||||||
|
pub mod user;
|
5
thoughts-backend/models/src/domains/prelude.rs
Normal file
5
thoughts-backend/models/src/domains/prelude.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||||
|
|
||||||
|
pub use super::follow::Entity as Follow;
|
||||||
|
pub use super::thought::Entity as Thought;
|
||||||
|
pub use super::user::Entity as User;
|
31
thoughts-backend/models/src/domains/thought.rs
Normal file
31
thoughts-backend/models/src/domains/thought.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "thought")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub author_id: i32,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {}
|
18
thoughts-backend/models/src/domains/user.rs
Normal file
18
thoughts-backend/models/src/domains/user.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//! `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,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
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;
|
19
thoughts-backend/models/src/params/auth.rs
Normal file
19
thoughts-backend/models/src/params/auth.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct RegisterParams {
|
||||||
|
#[validate(length(min = 3))]
|
||||||
|
pub username: String,
|
||||||
|
#[validate(length(min = 6))]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct LoginParams {
|
||||||
|
#[validate(length(min = 3))]
|
||||||
|
pub username: String,
|
||||||
|
#[validate(length(min = 6))]
|
||||||
|
pub password: String,
|
||||||
|
}
|
3
thoughts-backend/models/src/params/mod.rs
Normal file
3
thoughts-backend/models/src/params/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod thought;
|
||||||
|
pub mod user;
|
13
thoughts-backend/models/src/params/thought.rs
Normal file
13
thoughts-backend/models/src/params/thought.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateThoughtParams {
|
||||||
|
#[validate(length(
|
||||||
|
min = 1,
|
||||||
|
max = 128,
|
||||||
|
message = "Content must be between 1 and 128 characters"
|
||||||
|
))]
|
||||||
|
pub content: String,
|
||||||
|
}
|
11
thoughts-backend/models/src/params/user.rs
Normal file
11
thoughts-backend/models/src/params/user.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateUserParams {
|
||||||
|
#[validate(length(min = 2))]
|
||||||
|
pub username: String,
|
||||||
|
#[validate(length(min = 6))]
|
||||||
|
pub password: String,
|
||||||
|
}
|
1
thoughts-backend/models/src/queries/mod.rs
Normal file
1
thoughts-backend/models/src/queries/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
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>,
|
||||||
|
}
|
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 thought;
|
||||||
|
pub mod user;
|
57
thoughts-backend/models/src/schemas/thought.rs
Normal file
57
thoughts-backend/models/src/schemas/thought.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use crate::domains::{thought, user};
|
||||||
|
use common::DateTimeWithTimeZoneWrapper;
|
||||||
|
use sea_orm::FromQueryResult;
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
|
||||||
|
pub struct ThoughtSchema {
|
||||||
|
pub id: i32,
|
||||||
|
#[schema(example = "frutiger")]
|
||||||
|
pub author_username: String,
|
||||||
|
#[schema(example = "This is my first thought! #welcome")]
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThoughtSchema {
|
||||||
|
pub fn from_models(thought: &thought::Model, author: &user::Model) -> Self {
|
||||||
|
Self {
|
||||||
|
id: thought.id,
|
||||||
|
author_username: author.username.clone(),
|
||||||
|
content: thought.content.clone(),
|
||||||
|
created_at: thought.created_at.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ThoughtListSchema {
|
||||||
|
pub thoughts: Vec<ThoughtSchema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<ThoughtSchema>> for ThoughtListSchema {
|
||||||
|
fn from(thoughts: Vec<ThoughtSchema>) -> Self {
|
||||||
|
Self { thoughts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
pub struct ThoughtWithAuthor {
|
||||||
|
pub id: i32,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
||||||
|
pub author_id: i32,
|
||||||
|
pub author_username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ThoughtWithAuthor> for ThoughtSchema {
|
||||||
|
fn from(model: ThoughtWithAuthor) -> Self {
|
||||||
|
Self {
|
||||||
|
id: model.id,
|
||||||
|
author_username: model.author_username,
|
||||||
|
content: model.content,
|
||||||
|
created_at: model.created_at.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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: i32,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<user::Model> for UserSchema {
|
||||||
|
fn from(user: user::Model) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user.id,
|
||||||
|
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())
|
||||||
|
}
|
60
thoughts-backend/src/tokio.rs
Normal file
60
thoughts-backend/src/tokio.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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 config = setup_config();
|
||||||
|
|
||||||
|
let router = setup_router(conn, &config).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);
|
||||||
|
}
|
146
thoughts-backend/tests/api/activitypub.rs
Normal file
146
thoughts-backend/tests/api/activitypub.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use crate::api::main::{create_user_with_password, setup};
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{
|
||||||
|
make_get_request, make_jwt_request, make_post_request, make_request_with_headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webfinger_discovery() {
|
||||||
|
let app = setup().await;
|
||||||
|
create_user_with_password(&app.db, "testuser", "password123").await;
|
||||||
|
|
||||||
|
// 1. Valid WebFinger lookup for existing user
|
||||||
|
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
|
||||||
|
let response = make_get_request(app.router.clone(), url, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["subject"], "acct:testuser@localhost:3000");
|
||||||
|
assert_eq!(
|
||||||
|
v["links"][0]["href"],
|
||||||
|
"http://localhost:3000/users/testuser"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. WebFinger lookup for a non-existent user
|
||||||
|
let response = make_get_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/.well-known/webfinger?resource=acct:nobody@localhost:3000",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_actor_endpoint() {
|
||||||
|
let app = setup().await;
|
||||||
|
create_user_with_password(&app.db, "testuser", "password123").await;
|
||||||
|
|
||||||
|
let response = make_request_with_headers(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/testuser",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
vec![(
|
||||||
|
header::ACCEPT,
|
||||||
|
"application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
|
||||||
|
)],
|
||||||
|
).await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let content_type = response.headers().get(header::CONTENT_TYPE).unwrap();
|
||||||
|
assert_eq!(content_type, "application/activity+json");
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["type"], "Person");
|
||||||
|
assert_eq!(v["preferredUsername"], "testuser");
|
||||||
|
assert_eq!(v["id"], "http://localhost:3000/users/testuser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_inbox_follow() {
|
||||||
|
let app = setup().await;
|
||||||
|
// user1 will be followed
|
||||||
|
create_user_with_password(&app.db, "user1", "password123").await;
|
||||||
|
// user2 will be the follower
|
||||||
|
create_user_with_password(&app.db, "user2", "password123").await;
|
||||||
|
|
||||||
|
// Construct a follow activity from user2, targeting user1
|
||||||
|
let follow_activity = json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": "http://localhost:3000/some-unique-id",
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": "http://localhost:3000/users/user2", // The actor is user2
|
||||||
|
"object": "http://localhost:3000/users/user1"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// POST the activity to user1's inbox
|
||||||
|
let response = make_post_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user1/inbox",
|
||||||
|
follow_activity,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
|
||||||
|
// Verify that user2 is now following user1 in the database
|
||||||
|
let followers = app::persistence::follow::get_followed_ids(&app.db, 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(followers.contains(&1), "User2 should be following user1");
|
||||||
|
|
||||||
|
let following = app::persistence::follow::get_followed_ids(&app.db, 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!following.contains(&2),
|
||||||
|
"User1 should now be followed by user2"
|
||||||
|
);
|
||||||
|
assert!(following.is_empty(), "User1 should not be following anyone");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_outbox_get() {
|
||||||
|
let app = setup().await;
|
||||||
|
create_user_with_password(&app.db, "testuser", "password123").await;
|
||||||
|
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
|
||||||
|
|
||||||
|
// Create a thought first
|
||||||
|
let thought_body = json!({ "content": "This is a federated thought!" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Now, fetch the outbox
|
||||||
|
let response = make_request_with_headers(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/testuser/outbox",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
vec![(header::ACCEPT, "application/activity+json")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["type"], "OrderedCollection");
|
||||||
|
assert_eq!(v["totalItems"], 1);
|
||||||
|
assert_eq!(v["orderedItems"][0]["type"], "Create");
|
||||||
|
assert_eq!(
|
||||||
|
v["orderedItems"][0]["object"]["content"],
|
||||||
|
"This is a federated thought!"
|
||||||
|
);
|
||||||
|
}
|
60
thoughts-backend/tests/api/auth.rs
Normal file
60
thoughts-backend/tests/api/auth.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::api::main::setup;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_jwt_request, make_post_request};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_flow() {
|
||||||
|
std::env::set_var("AUTH_SECRET", "test-secret");
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let register_body = json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response =
|
||||||
|
make_post_request(app.router.clone(), "/auth/register", register_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["username"], "testuser");
|
||||||
|
assert!(v["id"].is_number());
|
||||||
|
|
||||||
|
let response = make_post_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/auth/register",
|
||||||
|
json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password456"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
let login_body = json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = make_post_request(app.router.clone(), "/auth/login", login_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let token = v["token"].as_str().expect("token not found").to_string();
|
||||||
|
assert!(!token.is_empty());
|
||||||
|
|
||||||
|
let bad_login_body = json!({
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "wrongpassword"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = make_post_request(app.router.clone(), "/auth/login", bad_login_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
86
thoughts-backend/tests/api/feed.rs
Normal file
86
thoughts-backend/tests/api/feed.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use super::main::{create_user_with_password, setup};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::json;
|
||||||
|
use utils::testing::make_jwt_request;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_feed_and_user_thoughts() {
|
||||||
|
let app = setup().await;
|
||||||
|
create_user_with_password(&app.db, "user1", "password1").await;
|
||||||
|
create_user_with_password(&app.db, "user2", "password2").await;
|
||||||
|
create_user_with_password(&app.db, "user3", "password3").await;
|
||||||
|
|
||||||
|
// As user1, post a thought
|
||||||
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
|
let body = json!({ "content": "A thought from user1" }).to_string();
|
||||||
|
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), &token).await;
|
||||||
|
|
||||||
|
// As a different "user", create thoughts for user2 and user3
|
||||||
|
let token2 = super::main::login_user(app.router.clone(), "user2", "password2").await;
|
||||||
|
let body2 = json!({ "content": "user2 was here" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body2),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let token3 = super::main::login_user(app.router.clone(), "user3", "password3").await;
|
||||||
|
let body3 = json!({ "content": "user3 checking in" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body3),
|
||||||
|
&token3,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 1. Get thoughts for user2 - should only see their thought plus their own
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/thoughts",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
|
||||||
|
|
||||||
|
// 2. user1's feed has only their own thought (not following anyone)
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["thoughts"][0]["author_username"], "user1");
|
||||||
|
assert_eq!(v["thoughts"][0]["content"], "A thought from user1");
|
||||||
|
|
||||||
|
// 3. user1 follows user2
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. user1's feed now has user2's thought
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["thoughts"].as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(v["thoughts"][0]["author_username"], "user2");
|
||||||
|
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
|
||||||
|
assert_eq!(v["thoughts"][1]["author_username"], "user1");
|
||||||
|
assert_eq!(v["thoughts"][1]["content"], "A thought from user1");
|
||||||
|
}
|
69
thoughts-backend/tests/api/follow.rs
Normal file
69
thoughts-backend/tests/api/follow.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use super::main::{create_user_with_password, setup};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use utils::testing::make_jwt_request;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_follow_endpoints() {
|
||||||
|
std::env::set_var("AUTH_SECRET", "test-secret");
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
create_user_with_password(&app.db, "user1", "password1").await;
|
||||||
|
create_user_with_password(&app.db, "user2", "password2").await;
|
||||||
|
|
||||||
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
|
|
||||||
|
// 1. user1 follows user2
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// 2. user1 tries to follow user2 again (should fail)
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// 3. user1 tries to follow a non-existent user
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/nobody/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// 4. user1 unfollows user2
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"DELETE",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// 5. user1 tries to unfollow user2 again (should fail)
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"DELETE",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
57
thoughts-backend/tests/api/main.rs
Normal file
57
thoughts-backend/tests/api/main.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use api::setup_router;
|
||||||
|
use app::persistence::user::create_user;
|
||||||
|
use axum::Router;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use models::params::{auth::RegisterParams, user::CreateUserParams};
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_post_request, setup_test_db};
|
||||||
|
|
||||||
|
pub struct TestApp {
|
||||||
|
pub router: Router,
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup() -> TestApp {
|
||||||
|
std::env::set_var("DATABASE_URL", "sqlite::memory:");
|
||||||
|
std::env::set_var("AUTH_SECRET", "test_secret");
|
||||||
|
std::env::set_var("BASE_URL", "http://localhost:3000");
|
||||||
|
std::env::set_var("HOST", "localhost");
|
||||||
|
std::env::set_var("PORT", "3000");
|
||||||
|
std::env::set_var("LOG_LEVEL", "debug");
|
||||||
|
|
||||||
|
let db = setup_test_db("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("Failed to set up test db");
|
||||||
|
let router = setup_router(db.clone(), &app::config::Config::from_env());
|
||||||
|
TestApp { router, db }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create users for tests
|
||||||
|
pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
|
||||||
|
let params = CreateUserParams {
|
||||||
|
username: username.to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
|
};
|
||||||
|
create_user(db, params)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test user");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user_with_password(db: &DatabaseConnection, username: &str, password: &str) {
|
||||||
|
let params = RegisterParams {
|
||||||
|
username: username.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
};
|
||||||
|
app::persistence::auth::register_user(db, params)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test user with password");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_user(router: Router, username: &str, password: &str) -> String {
|
||||||
|
let login_body = json!({ "username": username, "password": password }).to_string();
|
||||||
|
let response = make_post_request(router, "/auth/login", login_body, None).await;
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
v["token"].as_str().unwrap().to_string()
|
||||||
|
}
|
7
thoughts-backend/tests/api/mod.rs
Normal file
7
thoughts-backend/tests/api/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod activitypub;
|
||||||
|
mod auth;
|
||||||
|
mod feed;
|
||||||
|
mod follow;
|
||||||
|
mod main;
|
||||||
|
mod thought;
|
||||||
|
mod user;
|
41
thoughts-backend/tests/api/thought.rs
Normal file
41
thoughts-backend/tests/api/thought.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use super::main::{create_test_user, setup};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::json;
|
||||||
|
use utils::testing::{make_delete_request, make_post_request};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_thought_endpoints() {
|
||||||
|
let app = setup().await;
|
||||||
|
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
|
||||||
|
create_test_user(&app.db, "user2").await; // Other user is ID 2
|
||||||
|
|
||||||
|
// 1. Post a new thought as user 1
|
||||||
|
let body = json!({ "content": "My first thought!" }).to_string();
|
||||||
|
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["content"], "My first thought!");
|
||||||
|
assert_eq!(v["author_username"], "user1");
|
||||||
|
let thought_id = v["id"].as_i64().unwrap();
|
||||||
|
|
||||||
|
// 2. Post a thought with invalid content
|
||||||
|
let body = json!({ "content": "" }).to_string(); // Too short
|
||||||
|
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
// 3. Attempt to delete another user's thought (user1 tries to delete a non-existent thought, but let's pretend it's user2's)
|
||||||
|
let response =
|
||||||
|
make_delete_request(app.router.clone(), &format!("/thoughts/999"), Some(1)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// 4. Delete the thought created in step 1
|
||||||
|
let response = make_delete_request(
|
||||||
|
app.router.clone(),
|
||||||
|
&format!("/thoughts/{}", thought_id),
|
||||||
|
Some(1),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
}
|
50
thoughts-backend/tests/api/user.rs
Normal file
50
thoughts-backend/tests/api/user.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use utils::testing::{make_get_request, make_post_request};
|
||||||
|
|
||||||
|
use crate::api::main::setup;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_post_users() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
||||||
|
let response = make_post_request(app.router, "/auth/register", body, None).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"}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
pub(super) async fn test_post_users_error() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
|
||||||
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
|
|
||||||
|
println!("{:?}", response);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
pub async fn test_get_users() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
||||||
|
make_post_request(app.router.clone(), "/auth/register", body, None).await;
|
||||||
|
|
||||||
|
let response = make_get_request(app.router, "/users", None).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;
|
14
thoughts-backend/tests/app/persistence/mod.rs
Normal file
14
thoughts-backend/tests/app/persistence/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use utils::testing::setup_test_db;
|
||||||
|
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
21
thoughts-backend/tests/app/persistence/user.rs
Normal file
21
thoughts-backend/tests/app/persistence/user.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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(),
|
||||||
|
password: "password".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()),
|
||||||
|
password_hash: Unchanged(None),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
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;
|
97
thoughts-backend/utils/src/testing/api/mod.rs
Normal file
97
thoughts-backend/utils/src/testing/api/mod.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{header, Request},
|
||||||
|
response::Response,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
pub async fn make_get_request(app: Router, url: &str, user_id: Option<i32>) -> Response {
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.uri(url)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
builder = builder.header("x-test-user-id", user_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.oneshot(builder.body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn make_post_request(
|
||||||
|
app: Router,
|
||||||
|
url: &str,
|
||||||
|
body: String,
|
||||||
|
user_id: Option<i32>,
|
||||||
|
) -> Response {
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(url)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
builder = builder.header("x-test-user-id", user_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.oneshot(builder.body(Body::from(body)).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn make_delete_request(app: Router, url: &str, user_id: Option<i32>) -> Response {
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri(url)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
builder = builder.header("x-test-user-id", user_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.oneshot(builder.body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn make_jwt_request(
|
||||||
|
app: Router,
|
||||||
|
url: &str,
|
||||||
|
method: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
token: &str,
|
||||||
|
) -> Response {
|
||||||
|
let builder = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", format!("Bearer {}", token));
|
||||||
|
|
||||||
|
let request_body = body.unwrap_or_default();
|
||||||
|
app.oneshot(builder.body(Body::from(request_body)).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn make_request_with_headers(
|
||||||
|
app: Router,
|
||||||
|
url: &str,
|
||||||
|
method: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
headers: Vec<(header::HeaderName, &str)>,
|
||||||
|
) -> Response {
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(url)
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
for (key, value) in headers {
|
||||||
|
builder = builder.header(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_body = body.unwrap_or_default();
|
||||||
|
app.oneshot(builder.body(Body::from(request_body)).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user