Compare commits

..

10 Commits

Author SHA1 Message Date
6e63dca513 feat: add environment configuration for database and authentication, update router setup 2025-09-06 01:55:59 +02:00
3dd6c0f64b feat(activitypub): implement user outbox endpoint and federate thoughts to followers 2025-09-06 01:46:11 +02:00
e9c4088e68 feat(activitypub): implement user inbox for receiving follow activities and add corresponding tests 2025-09-06 01:37:23 +02:00
c7c573f3f4 feat: Implement WebFinger discovery and ActivityPub user actor endpoint
- Added a new router for handling well-known endpoints, specifically WebFinger.
- Implemented the `webfinger` function to respond to WebFinger queries.
- Created a new route for WebFinger in the router.
- Refactored user retrieval logic to support both user ID and username in a single endpoint.
- Updated user router to use the new `get_user_by_param` function.
- Added tests for WebFinger discovery and ActivityPub user actor endpoint.
- Updated dependencies in Cargo.toml files to include necessary libraries.
2025-09-06 01:18:04 +02:00
3d73c7f198 feat(auth): implement user registration and login with JWT authentication
- Added `bcrypt`, `jsonwebtoken`, and `once_cell` dependencies to manage password hashing and JWT handling.
- Created `Claims` struct for JWT claims and implemented token generation in the login route.
- Implemented user registration and authentication logic in the `auth` module.
- Updated error handling to include validation errors.
- Created new routes for user registration and login, and integrated them into the main router.
- Added tests for the authentication flow, including registration and login scenarios.
- Updated user model to include a password hash field.
- Refactored user creation logic to include password validation.
- Adjusted feed and user routes to utilize JWT for authentication.
2025-09-06 00:06:30 +02:00
d70015c887 feat: update API endpoints and enhance feed retrieval logic, add CORS support 2025-09-05 22:26:39 +02:00
0e6c072387 feat: enhance error handling and user follow functionality, update tests for user context 2025-09-05 21:44:46 +02:00
decf81e535 feat: implement user follow/unfollow functionality and thought retrieval by user
- Added follow and unfollow endpoints for users.
- Implemented logic to retrieve thoughts by a specific user.
- Updated user error handling to include cases for already following and not following.
- Created persistence layer for follow relationships.
- Enhanced user and thought schemas to support new features.
- Added tests for follow/unfollow endpoints and thought retrieval.
- Updated frontend to display thoughts and allow posting new thoughts.
2025-09-05 19:08:37 +02:00
912259ef54 Refactor blog module and remove blog-related functionality
- Removed blog router and associated API endpoints.
- Deleted blog persistence functions and related query parameters.
- Removed blog schemas and models from the codebase.
- Introduced common crate for shared types, including DateTimeWithTimeZoneWrapper.
- Added Thought and Follow models with corresponding migrations.
- Updated dependencies in Cargo.toml files to reflect changes.
- Adjusted tests to remove references to the blog module.
2025-09-05 18:10:58 +02:00
e5747eaaf3 feat: initialize thoughts-frontend with Next.js, TypeScript, and ESLint
- Add ESLint configuration for Next.js and TypeScript support.
- Create Next.js configuration file with standalone output option.
- Initialize package.json with scripts for development, build, and linting.
- Set up PostCSS configuration for Tailwind CSS.
- Add SVG assets for UI components.
- Create TypeScript configuration for strict type checking and module resolution.
2025-09-05 17:14:45 +02:00
121 changed files with 10336 additions and 1 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
POSTGRES_USER=thoughts_user
POSTGRES_PASSWORD=postgres
POSTGRES_DB=thoughts_db

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
backend-codebase.txt
frontend-codebase.txt
frontend-codebase.txt
.env

View File

@@ -0,0 +1,6 @@
# Ignore build artifacts
target/
# Ignore git directory
.git/
# Ignore local environment files
.env

7
thoughts-backend/.env Normal file
View 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

View 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
View File

@@ -0,0 +1,2 @@
/target
.env

5719
thoughts-backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View 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"]

View 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
View 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
View 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\=
```

View 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]

View 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,
}
}
}

View 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())
}
}

View 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()
}
}

View File

@@ -0,0 +1,7 @@
mod adapter;
mod core;
mod handler;
mod traits;
pub use core::ApiError;
pub use traits::HTTPError;

View File

@@ -0,0 +1,5 @@
use axum::http::StatusCode;
pub trait HTTPError {
fn to_status_code(&self) -> StatusCode;
}

View 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())
}

View 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()
}
}

View 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;

View 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))
}
}

View 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);
}
}
}

View 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")
}

View 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};

View File

@@ -0,0 +1,3 @@
mod response;
pub use response::{ApiErrorResponse, ParamsErrorResponse, ValidationErrorResponse};

View 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,
}
}
}

View 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))
}

View 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))
}

View 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)
}

View 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))
}

View 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))
}

View 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))
}

View 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))
}

View File

@@ -0,0 +1,3 @@
mod rejection;
pub use rejection::ValidRejection;

View 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)
}
}

View 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 }

View File

@@ -0,0 +1,3 @@
# app
No axum or api dependencies should be introduced into this folder.

View 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)
}
}

View File

@@ -0,0 +1,3 @@
mod user;
pub use user::UserError;

View 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 {}

View File

@@ -0,0 +1,4 @@
pub mod config;
pub mod error;
pub mod persistence;
pub mod state;

View 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(&params.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)
}
}

View 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())
}

View File

@@ -0,0 +1,4 @@
pub mod auth;
pub mod follow;
pub mod thought;
pub mod user;

View 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()))
}

View 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
}

View File

@@ -0,0 +1,7 @@
use sea_orm::DatabaseConnection;
#[derive(Clone)]
pub struct AppState {
pub conn: DatabaseConnection,
pub base_url: String,
}

View 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 }

View 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()
}
}

View 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" }

View 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;

View 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;

View 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()))
}
}

View File

@@ -0,0 +1,7 @@
use utoipa::OpenApi;
use api::routers::root::*;
#[derive(OpenApi)]
#[openapi(paths(root_get))]
pub(super) struct RootApi;

View 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;

View 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;

View 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",
] }

View 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
```

View 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),
]
}
}

View 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,
}

View 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,
}

View File

@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

View 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" }

View 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.

View 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 {}

View 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;

View 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;

View 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 {}

View 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 {}

View File

@@ -0,0 +1,4 @@
pub mod domains;
pub mod params;
pub mod queries;
pub mod schemas;

View 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,
}

View File

@@ -0,0 +1,3 @@
pub mod auth;
pub mod thought;
pub mod user;

View 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,
}

View 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,
}

View File

@@ -0,0 +1 @@
pub mod user;

View 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>,
}

View File

@@ -0,0 +1,2 @@
pub mod thought;
pub mod user;

View 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(),
}
}
}

View 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(),
}
}
}

View 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
}

View 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())
}

View 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);
}

View 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!"
);
}

View 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);
}

View 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");
}

View 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);
}

View 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()
}

View File

@@ -0,0 +1,7 @@
mod activitypub;
mod auth;
mod feed;
mod follow;
mod main;
mod thought;
mod user;

View 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);
}

View 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"}]}"#);
}

View File

@@ -0,0 +1 @@
mod persistence;

View 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;
}

View 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);
}

View File

@@ -0,0 +1,2 @@
mod api;
mod app;

View 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"] }

View File

@@ -0,0 +1,3 @@
# utils
Some utility functions to facilitate development.

View 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(())
}

View 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);
}
}

View File

@@ -0,0 +1,6 @@
mod db;
mod file;
pub mod testing;
pub use db::migrate;
pub use file::create_dev_db;

View 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