remove legacy v1 backend
Some checks failed
lint / lint (push) Failing after 8m33s
test / unit (push) Successful in 16m10s

This commit is contained in:
2026-05-28 22:28:59 +02:00
parent c5812100d5
commit a73e7deeff
125 changed files with 0 additions and 11401 deletions

View File

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

View File

@@ -1,8 +0,0 @@
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
BASE_URL=http://0.0.0.0

View File

@@ -1,6 +0,0 @@
HOST=0.0.0.0
PORT=3000
DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
PREFORK=1
AUTH_SECRET=your_secret_key_here
BASE_URL=http://localhost:3000

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
[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", "uuid"] }
validator = { version = "0.20.0", default-features = false }
chrono = { version = "0.4.41", features = ["serde"] }
tokio = { version = "1.45.1", features = ["full"] }
[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"] }
prefork = { version = "0.6.0", default-features = false, optional = true }
tokio = { version = "1.45.1", features = ["full"] }
# 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

@@ -1,44 +0,0 @@
FROM rust:1.89-slim AS builder
RUN apt-get update && apt-get install -y libssl-dev pkg-config && rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-chef --locked
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY api/Cargo.toml ./api/
COPY app/Cargo.toml ./app/
COPY common/Cargo.toml ./common/
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 apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 appgroup && \
useradd --system --uid 1001 --gid appgroup appuser
WORKDIR /app
COPY --from=builder /app/target/release/thoughts-backend .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
CMD ["./thoughts-backend"]

View File

@@ -1,21 +0,0 @@
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.

View File

@@ -1,17 +0,0 @@
# ⚠️ DEPRECATED — thoughts-backend (v1)
> **This directory is the original v1 implementation and is no longer maintained.**
> It will be removed in a future release.
## Use v2 instead
The active codebase lives at the **repository root** (`/crates/`). It is a complete rewrite with:
- Hexagonal (Ports & Adapters) architecture
- Full ActivityPub federation
- Remote actor discovery and profile browsing
- NATS JetStream event bus
- Clean REST API with content negotiation
- Next.js frontend (`/thoughts-frontend/`)
Do not build, run, or modify anything in this directory.

View File

@@ -1,42 +0,0 @@
[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"
# db
sea-orm = { workspace = true }
# doc
utoipa = { workspace = true }
serde_json = { workspace = true }
tokio = { 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"
[dev-dependencies]

View File

@@ -1,41 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,36 +0,0 @@
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

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

View File

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

View File

@@ -1,76 +0,0 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, HeaderMap, StatusCode},
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use once_cell::sync::Lazy;
use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize};
use app::{persistence::api_key, state::AppState};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: Uuid,
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: Uuid,
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
// --- Test User ID (Keep for testing) ---
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::<Uuid>().unwrap_or(Uuid::nil());
return Ok(AuthUser { id: user_id });
}
// --- API Key Authentication ---
if let Some(api_key) = get_api_key_from_header(&parts.headers) {
return match api_key::validate_api_key(&state.conn, &api_key).await {
Ok(user) => Ok(AuthUser { id: user.id }),
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid API Key")),
};
}
// --- JWT Authentication (Fallback) ---
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())
}
fn get_api_key_from_header(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|header| header.to_str().ok())
.and_then(|header| header.strip_prefix("ApiKey "))
.map(|key| key.to_owned())
}

View File

@@ -1,26 +0,0 @@
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

@@ -1,10 +0,0 @@
mod auth;
mod json;
mod optional_auth;
mod valid;
pub use auth::AuthUser;
pub use auth::Claims;
pub use json::Json;
pub use optional_auth::OptionalAuthUser;
pub use valid::Valid;

View File

@@ -1,21 +0,0 @@
use super::AuthUser;
use crate::error::ApiError;
use app::state::AppState;
use axum::{extract::FromRequestParts, http::request::Parts};
pub struct OptionalAuthUser(pub Option<AuthUser>);
impl FromRequestParts<AppState> for OptionalAuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
match AuthUser::from_request_parts(parts, state).await {
Ok(user) => Ok(OptionalAuthUser(Some(user))),
// If the user is not authenticated for any reason, we just treat them as a guest.
Err(_) => Ok(OptionalAuthUser(None)),
}
}
}

View File

@@ -1,23 +0,0 @@
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

@@ -1,34 +0,0 @@
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;
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

@@ -1,9 +0,0 @@
mod error;
mod extractor;
mod init;
mod validation;
pub mod models;
pub mod routers;
pub use init::{setup_config, setup_db, setup_router};

View File

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

View File

@@ -1,27 +0,0 @@
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

@@ -1,93 +0,0 @@
use crate::{
error::ApiError,
extractor::{AuthUser, Json},
models::ApiErrorResponse,
};
use app::{persistence::api_key, state::AppState};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get},
Router,
};
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse};
use sea_orm::prelude::Uuid;
#[utoipa::path(
get,
path = "",
responses(
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("bearerAuth" = [])
)
)]
async fn get_keys(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let keys = api_key::get_api_keys_for_user(&state.conn, auth_user.id).await?;
Ok(Json(ApiKeyListSchema::from(keys)))
}
#[utoipa::path(
post,
path = "",
request_body = ApiKeyRequest,
responses(
(status = 201, description = "API key created", body = ApiKeyResponse),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("bearerAuth" = [])
)
)]
async fn create_key(
State(state): State<AppState>,
auth_user: AuthUser,
Json(params): Json<ApiKeyRequest>,
) -> Result<impl IntoResponse, ApiError> {
let (key_model, plaintext_key) =
api_key::create_api_key(&state.conn, auth_user.id, params.name).await?;
let response = ApiKeyResponse::from_parts(key_model, Some(plaintext_key));
Ok((StatusCode::CREATED, Json(response)))
}
#[utoipa::path(
delete,
path = "/{key_id}",
responses(
(status = 204, description = "API key deleted"),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 404, description = "API key not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
params(
("key_id" = Uuid, Path, description = "The ID of the API key to delete")
),
security(
("bearerAuth" = [])
)
)]
async fn delete_key(
State(state): State<AppState>,
auth_user: AuthUser,
Path(key_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
api_key::delete_api_key(&state.conn, key_id, auth_user.id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn create_api_key_router() -> Router<AppState> {
Router::new()
.route("/", get(get_keys).post(create_key))
.route("/{key_id}", delete(delete_key))
}

View File

@@ -1,93 +0,0 @@
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

@@ -1,67 +0,0 @@
use axum::{
extract::{Query, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use app::{
persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self_paginated},
state::AppState,
};
use models::{
queries::pagination::PaginationQuery,
schemas::{pagination::PaginatedResponse, thought::ThoughtSchema},
};
use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path(
get,
path = "",
params(PaginationQuery),
responses(
(status = 200, description = "Authenticated user's feed", body = PaginatedResponse<ThoughtSchema>)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn feed_get(
State(state): State<AppState>,
auth_user: AuthUser,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> {
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated(
&state.conn,
auth_user.id,
following_ids,
&pagination,
)
.await?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
let page = pagination.page();
let page_size = pagination.page_size();
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let response = PaginatedResponse {
items: thoughts_schema,
total_items,
total_pages,
page,
page_size,
};
Ok(Json(response))
}
pub fn create_feed_router() -> Router<AppState> {
Router::new().route("/", get(feed_get))
}

View File

@@ -1,24 +0,0 @@
use crate::{error::ApiError, extractor::AuthUser};
use app::{persistence::user, state::AppState};
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
use models::schemas::user::UserListSchema;
#[utoipa::path(
get,
path = "",
responses(
(status = 200, description = "List of authenticated user's friends", body = UserListSchema)
),
security(("bearer_auth" = []))
)]
async fn get_friends_list(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let friends = user::get_friends(&state.conn, auth_user.id).await?;
Ok(Json(UserListSchema::from(friends)))
}
pub fn create_friends_router() -> Router<AppState> {
Router::new().route("/", get(get_friends_list))
}

View File

@@ -1,35 +0,0 @@
use axum::Router;
pub mod api_key;
pub mod auth;
pub mod feed;
pub mod friends;
pub mod root;
pub mod search;
pub mod tag;
pub mod thought;
pub mod user;
use crate::routers::auth::create_auth_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("/auth", create_auth_router())
.nest("/users", create_user_router())
.nest("/thoughts", create_thought_router())
.nest("/feed", create_feed_router())
.nest("/tags", tag::create_tag_router())
.nest("/friends", friends::create_friends_router())
.nest("/search", search::create_search_router())
.with_state(state)
.layer(cors)
}

View File

@@ -1,36 +0,0 @@
use axum::{extract::State, http::StatusCode, 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())
}
async fn health_check() -> StatusCode {
StatusCode::OK
}
pub fn create_root_router() -> Router<AppState> {
Router::new()
.route("/", get(root_get))
.route("/health", get(health_check))
}

View File

@@ -1,53 +0,0 @@
use crate::{error::ApiError, extractor::OptionalAuthUser};
use app::{persistence::search, state::AppState};
use axum::{
extract::{Query, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use models::schemas::{
search::SearchResultsSchema,
thought::{ThoughtListSchema, ThoughtSchema},
user::UserListSchema,
};
use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Deserialize, IntoParams)]
pub struct SearchQuery {
q: String,
}
#[utoipa::path(
get,
path = "",
params(SearchQuery),
responses((status = 200, body = SearchResultsSchema))
)]
async fn search_all(
State(state): State<AppState>,
viewer: OptionalAuthUser,
Query(query): Query<SearchQuery>,
) -> Result<impl IntoResponse, ApiError> {
let viewer_id = viewer.0.map(|u| u.id);
let (users, thoughts) = tokio::try_join!(
search::search_users(&state.conn, &query.q),
search::search_thoughts(&state.conn, &query.q, viewer_id)
)?;
let thought_schemas: Vec<ThoughtSchema> =
thoughts.into_iter().map(ThoughtSchema::from).collect();
let response = SearchResultsSchema {
users: UserListSchema::from(users),
thoughts: ThoughtListSchema::from(thought_schemas),
};
Ok(Json(response))
}
pub fn create_search_router() -> Router<AppState> {
Router::new().route("/", get(search_all))
}

View File

@@ -1,51 +0,0 @@
use crate::{error::ApiError, extractor::OptionalAuthUser};
use app::{
persistence::{tag, thought::get_thoughts_by_tag_name},
state::AppState,
};
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
#[utoipa::path(
get,
path = "{tagName}",
params(("tagName" = String, Path, description = "Tag name")),
responses((status = 200, description = "List of thoughts with a specific tag", body = ThoughtListSchema))
)]
async fn get_thoughts_by_tag(
State(state): State<AppState>,
Path(tag_name): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let thoughts_with_authors =
get_thoughts_by_tag_name(&state.conn, &tag_name, viewer.0.map(|u| u.id)).await;
let thoughts_with_authors = thoughts_with_authors?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
}
#[utoipa::path(
get,
path = "/popular",
responses((status = 200, description = "List of popular tags", body = Vec<String>))
)]
async fn get_popular_tags(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let tags = tag::get_popular_tags(&state.conn).await;
println!("Fetched popular tags: {:?}", tags);
let tags = tags?;
Ok(Json(tags))
}
pub fn create_tag_router() -> Router<AppState> {
Router::new()
.route("/{tag_name}", get(get_thoughts_by_tag))
.route("/popular", get(get_popular_tags))
}

View File

@@ -1,145 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Router,
};
use app::{
error::UserError,
persistence::thought::{create_thought, delete_thought, get_thought},
state::AppState,
};
use models::{
params::thought::CreateThoughtParams,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
};
use sea_orm::prelude::Uuid;
use crate::{
error::ApiError,
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
models::{ApiErrorResponse, ParamsErrorResponse},
};
#[utoipa::path(
get,
path = "/{id}",
params(
("id" = Uuid, Path, description = "Thought ID")
),
responses(
(status = 200, description = "Thought found", body = ThoughtSchema),
(status = 404, description = "Not Found", body = ApiErrorResponse)
)
)]
async fn get_thought_by_id(
State(state): State<AppState>,
Path(id): Path<Uuid>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let viewer_id = viewer.0.map(|u| u.id);
let thought = get_thought(&state.conn, id, viewer_id)
.await?
.ok_or(UserError::NotFound)?;
let author = app::persistence::user::get_user(&state.conn, thought.author_id)
.await?
.ok_or(UserError::NotFound)?;
let schema = ThoughtSchema::from_models(&thought, &author);
Ok(Json(schema))
}
#[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
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<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let thought = get_thought(&state.conn, id, Some(auth_user.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)
}
#[utoipa::path(
get,
path = "/{id}/thread",
params(
("id" = Uuid, Path, description = "Thought ID")
),
responses(
(status = 200, description = "Thought thread found", body = ThoughtThreadSchema),
(status = 404, description = "Not Found", body = ApiErrorResponse)
)
)]
async fn get_thought_thread(
State(state): State<AppState>,
Path(id): Path<Uuid>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let viewer_id = viewer.0.map(|u| u.id);
let thread = app::persistence::thought::get_thought_with_replies(&state.conn, id, viewer_id)
.await?
.ok_or(UserError::NotFound)?;
Ok(Json(thread))
}
pub fn create_thought_router() -> Router<AppState> {
Router::new()
.route("/", post(thoughts_post))
.route("/{id}/thread", get(get_thought_thread))
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
}

View File

@@ -1,476 +0,0 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use sea_orm::prelude::Uuid;
use serde_json::{json, Value};
use app::persistence::{
follow,
thought::get_thoughts_by_user,
user::{
get_all_users, get_followers, get_following, get_user, search_users, update_user_profile,
},
};
use app::state::AppState;
use app::{error::UserError, persistence::user::get_user_by_username};
use models::{
params::user::UpdateUserParams,
schemas::{pagination::PaginatedResponse, thought::ThoughtListSchema},
};
use models::{
queries::pagination::PaginationQuery,
schemas::user::{MeSchema, UserListSchema, UserSchema},
};
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::{error::ApiError, extractor::AuthUser};
use crate::{extractor::OptionalAuthUser, models::ApiErrorResponse};
use crate::{
extractor::{Json, Valid},
routers::api_key::create_api_key_router,
};
#[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>,
viewer: OptionalAuthUser,
) -> 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, viewer.0.map(|u| u.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::<Uuid>() {
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)) => {
let top_friends = app::persistence::user::get_top_friends(&state.conn, user.id)
.await
.unwrap_or_default();
Json(UserSchema::from((user, top_friends))).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>,
viewer: OptionalAuthUser,
) -> 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, viewer.0.map(|u| u.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)))
}
#[utoipa::path(
get,
path = "/me",
responses(
(status = 200, description = "Authenticated user's full profile", body = MeSchema)
),
security(
("bearer_auth" = [])
)
)]
async fn get_me(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user(&state.conn, auth_user.id)
.await?
.ok_or(UserError::NotFound)?;
let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
let following = get_following(&state.conn, auth_user.id).await?;
let response = MeSchema {
id: user.id,
username: user.username,
display_name: user.display_name,
bio: user.bio,
avatar_url: user.avatar_url,
header_url: user.header_url,
custom_css: user.custom_css,
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
joined_at: user.created_at.into(),
following: following.into_iter().map(UserSchema::from).collect(),
};
Ok(axum::Json(response))
}
#[utoipa::path(
put,
path = "/me",
request_body = UpdateUserParams,
responses(
(status = 200, description = "Profile updated", body = UserSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ApiErrorResponse)
),
security(
("bearer_auth" = [])
)
)]
async fn update_me(
State(state): State<AppState>,
auth_user: AuthUser,
Valid(Json(params)): Valid<Json<UpdateUserParams>>,
) -> Result<impl IntoResponse, ApiError> {
let updated_user = update_user_profile(&state.conn, auth_user.id, params).await?;
Ok(axum::Json(UserSchema::from(updated_user)))
}
#[utoipa::path(
get,
path = "/{username}/following",
responses((status = 200, body = UserListSchema))
)]
async fn get_user_following(
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 following_list = get_following(&state.conn, user.id).await?;
Ok(Json(UserListSchema::from(following_list)))
}
#[utoipa::path(
get,
path = "/{username}/followers",
responses((status = 200, body = UserListSchema))
)]
async fn get_user_followers(
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 followers_list = get_followers(&state.conn, user.id).await?;
Ok(Json(UserListSchema::from(followers_list)))
}
#[utoipa::path(
get,
path = "/all",
params(PaginationQuery),
responses(
(status = 200, description = "A public, paginated list of all users", body = PaginatedResponse<UserSchema>)
),
tag = "user"
)]
async fn get_all_users_public(
State(state): State<AppState>,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> {
let (users, total_items) = get_all_users(&state.conn, &pagination).await?;
let page = pagination.page();
let page_size = pagination.page_size();
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let response = PaginatedResponse {
items: users.into_iter().map(UserSchema::from).collect(),
page,
page_size,
total_pages,
total_items,
};
Ok(Json(response))
}
async fn get_all_users_count(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let count = app::persistence::user::get_all_users_count(&state.conn).await?;
Ok(Json(json!({ "count": count })))
}
pub fn create_user_router() -> Router<AppState> {
Router::new()
.route("/", get(users_get))
.route("/all", get(get_all_users_public))
.route("/count", get(get_all_users_count))
.route("/me", get(get_me).put(update_me))
.nest("/me/api-keys", create_api_key_router())
.route("/{param}", get(get_user_by_param))
.route("/{username}/thoughts", get(user_thoughts_get))
.route("/{username}/followers", get(get_user_followers))
.route("/{username}/following", get(get_user_following))
.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

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

View File

@@ -1,58 +0,0 @@
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

@@ -1,17 +0,0 @@
[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"
rand = "0.8.5"
sea-orm = { version = "1.1.12" }
chrono = { workspace = true }

View File

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

View File

@@ -1,28 +0,0 @@
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

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

View File

@@ -1,26 +0,0 @@
#[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

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

View File

@@ -1,93 +0,0 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use models::domains::{api_key, user};
use rand::distributions::{Alphanumeric, DistString};
use sea_orm::{
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
};
use crate::error::UserError;
const KEY_PREFIX: &str = "th_";
const KEY_RANDOM_LENGTH: usize = 32;
const KEY_LOOKUP_PREFIX_LENGTH: usize = 8;
fn generate_key() -> String {
let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), KEY_RANDOM_LENGTH);
format!("{}{}", KEY_PREFIX, random_part)
}
pub async fn create_api_key(
db: &DbConn,
user_id: Uuid,
name: String,
) -> Result<(api_key::Model, String), UserError> {
let plaintext_key = generate_key();
let key_hash =
hash(&plaintext_key, DEFAULT_COST).map_err(|e| UserError::Internal(e.to_string()))?;
let key_prefix = plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH].to_string();
let new_key = api_key::ActiveModel {
user_id: Set(user_id),
name: Set(name),
key_hash: Set(key_hash),
key_prefix: Set(key_prefix),
..Default::default()
}
.insert(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
Ok((new_key, plaintext_key))
}
pub async fn validate_api_key(db: &DbConn, plaintext_key: &str) -> Result<user::Model, UserError> {
if !plaintext_key.starts_with(KEY_PREFIX)
|| plaintext_key.len() != KEY_PREFIX.len() + KEY_RANDOM_LENGTH
{
return Err(UserError::Validation("Invalid API key format".to_string()));
}
let key_prefix = &plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH];
let candidate_keys = api_key::Entity::find()
.filter(api_key::Column::KeyPrefix.eq(key_prefix))
.all(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
for key in candidate_keys {
if verify(plaintext_key, &key.key_hash).unwrap_or(false) {
return super::user::get_user(db, key.user_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound);
}
}
Err(UserError::Validation("Invalid API key".to_string()))
}
pub async fn get_api_keys_for_user(
db: &DbConn,
user_id: Uuid,
) -> Result<Vec<api_key::Model>, DbErr> {
api_key::Entity::find()
.filter(api_key::Column::UserId.eq(user_id))
.all(db)
.await
}
pub async fn delete_api_key(db: &DbConn, key_id: Uuid, user_id: Uuid) -> Result<(), UserError> {
let result = api_key::Entity::delete_many()
.filter(api_key::Column::Id.eq(key_id))
.filter(api_key::Column::UserId.eq(user_id)) // Ensure user owns the key
.exec(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
if result.rows_affected == 0 {
Err(UserError::NotFound)
} else {
Ok(())
}
}

View File

@@ -1,55 +0,0 @@
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> {
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.clone()),
password_hash: Set(Some(hashed_password)),
email: Set(Some(params.email)),
display_name: Set(Some(params.username)),
..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

@@ -1,91 +0,0 @@
use sea_orm::{
prelude::Uuid, 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,
following_id: Uuid,
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, following_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
Ok(())
}
pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> {
if follower_id == following_id {
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
}
let follow = follow::ActiveModel {
follower_id: Set(follower_id),
following_id: Set(following_id),
};
follow.insert(db).await?;
Ok(())
}
pub async fn unfollow_user(
db: &DbConn,
follower_id: Uuid,
following_id: Uuid,
) -> Result<(), UserError> {
let deleted_result = follow::Entity::delete_many()
.filter(follow::Column::FollowerId.eq(follower_id))
.filter(follow::Column::FollowingId.eq(following_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_following_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, 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.following_id).collect())
}
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followers = follow::Entity::find()
.filter(follow::Column::FollowingId.eq(user_id))
.all(db)
.await?;
Ok(followers.into_iter().map(|f| f.follower_id).collect())
}
pub async fn get_friend_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let following = get_following_ids(db, user_id).await?;
let followers = get_follower_ids(db, user_id).await?;
let following_set: std::collections::HashSet<Uuid> = following.into_iter().collect();
let followers_set: std::collections::HashSet<Uuid> = followers.into_iter().collect();
Ok(following_set
.intersection(&followers_set)
.cloned()
.collect())
}

View File

@@ -1,7 +0,0 @@
pub mod api_key;
pub mod auth;
pub mod follow;
pub mod search;
pub mod tag;
pub mod thought;
pub mod user;

View File

@@ -1,66 +0,0 @@
use models::{
domains::{thought, user},
schemas::thought::ThoughtWithAuthor,
};
use sea_orm::{
prelude::{Expr, Uuid},
DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait,
Value,
};
use crate::persistence::follow;
fn is_visible(
author_id: Uuid,
viewer_id: Option<Uuid>,
friend_ids: &[Uuid],
visibility: &thought::Visibility,
) -> bool {
match visibility {
thought::Visibility::Public => true,
thought::Visibility::Private => viewer_id.map_or(false, |v| v == author_id),
thought::Visibility::FriendsOnly => {
viewer_id.map_or(false, |v| v == author_id || friend_ids.contains(&author_id))
}
}
}
pub async fn search_thoughts(
db: &DatabaseConnection,
query: &str,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut friend_ids = Vec::new();
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
// We must join with the user table to get the author's username
let thoughts_with_authors = thought::Entity::find()
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(Expr::cust_with_values(
"thought.search_document @@ websearch_to_tsquery('english', $1)",
[Value::from(query)],
))
.into_model::<ThoughtWithAuthor>() // Convert directly in the query
.all(db)
.await?;
// Apply visibility filtering in Rust after the search
Ok(thoughts_with_authors
.into_iter()
.filter(|t| is_visible(t.author_id, viewer_id, &friend_ids, &t.visibility))
.collect())
}
pub async fn search_users(db: &DatabaseConnection, query: &str) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.filter(Expr::cust_with_values(
"\"user\".search_document @@ websearch_to_tsquery('english', $1)",
[Value::from(query)],
))
.all(db)
.await
}

View File

@@ -1,120 +0,0 @@
use chrono::{Duration, Utc};
use models::domains::{tag, thought, thought_tag};
use sea_orm::{
prelude::Expr, sea_query::Alias, sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr,
EntityTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
};
use std::collections::HashSet;
pub fn parse_hashtags(content: &str) -> Vec<String> {
content
.split_whitespace()
.filter_map(|word| {
if word.starts_with('#') && word.len() > 1 {
Some(word[1..].to_lowercase().to_string())
} else {
None
}
})
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
pub async fn find_or_create_tags<C>(db: &C, names: Vec<String>) -> Result<Vec<tag::Model>, DbErr>
where
C: ConnectionTrait,
{
if names.is_empty() {
return Ok(vec![]);
}
let existing_tags = tag::Entity::find()
.filter(tag::Column::Name.is_in(names.clone()))
.all(db)
.await?;
let existing_names: HashSet<String> = existing_tags.iter().map(|t| t.name.clone()).collect();
let new_names: Vec<String> = names
.into_iter()
.filter(|n| !existing_names.contains(n))
.collect();
if !new_names.is_empty() {
let new_tags: Vec<tag::ActiveModel> = new_names
.clone()
.into_iter()
.map(|name| tag::ActiveModel {
name: Set(name),
..Default::default()
})
.collect();
tag::Entity::insert_many(new_tags).exec(db).await?;
}
tag::Entity::find()
.filter(
tag::Column::Name.is_in(
existing_names
.union(&new_names.into_iter().collect())
.cloned()
.collect::<Vec<_>>(),
),
)
.all(db)
.await
}
pub async fn link_tags_to_thought<C>(
db: &C,
thought_id: uuid::Uuid,
tags: Vec<tag::Model>,
) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
if tags.is_empty() {
return Ok(());
}
let links: Vec<thought_tag::ActiveModel> = tags
.into_iter()
.map(|tag| thought_tag::ActiveModel {
thought_id: Set(thought_id),
tag_id: Set(tag.id),
})
.collect();
thought_tag::Entity::insert_many(links).exec(db).await?;
Ok(())
}
pub async fn get_popular_tags<C>(db: &C) -> Result<Vec<String>, DbErr>
where
C: ConnectionTrait,
{
let seven_days_ago = Utc::now() - Duration::days(7);
let popular_tags = tag::Entity::find()
.select_only()
.column(tag::Column::Name)
.column_as(Expr::col((tag::Entity, tag::Column::Id)).count(), "count")
.join(
sea_orm::JoinType::InnerJoin,
tag::Relation::ThoughtTag.def(),
)
.join(
sea_orm::JoinType::InnerJoin,
thought_tag::Relation::Thought.def(),
)
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
.filter(thought::Column::Visibility.eq(thought::Visibility::Public))
.group_by(tag::Column::Name)
.group_by(tag::Column::Id)
.order_by_desc(Expr::col(Alias::new("count")))
.order_by_asc(tag::Column::Name)
.limit(10)
.into_tuple::<(String, i64)>()
.all(db)
.await?;
Ok(popular_tags.into_iter().map(|(name, _)| name).collect())
}

View File

@@ -1,386 +0,0 @@
use sea_orm::{
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
Set, TransactionTrait,
};
use models::{
domains::{tag, thought, thought_tag, user},
params::thought::CreateThoughtParams,
queries::pagination::PaginationQuery,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
};
use crate::{
error::UserError,
persistence::{
follow,
tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags},
},
};
pub async fn create_thought(
db: &DbConn,
author_id: Uuid,
params: CreateThoughtParams,
) -> Result<thought::Model, DbErr> {
let txn = db.begin().await?;
let new_thought = thought::ActiveModel {
author_id: Set(author_id),
content: Set(params.content.clone()),
reply_to_id: Set(params.reply_to_id),
visibility: Set(params.visibility.unwrap_or(thought::Visibility::Public)),
..Default::default()
}
.insert(&txn)
.await?;
if new_thought.visibility == thought::Visibility::Public {
let tag_names = parse_hashtags(&params.content);
if !tag_names.is_empty() {
let tags = find_or_create_tags(&txn, tag_names).await?;
link_tags_to_thought(&txn, new_thought.id, tags).await?;
}
}
txn.commit().await?;
Ok(new_thought)
}
pub async fn get_thought(
db: &DbConn,
thought_id: Uuid,
viewer_id: Option<Uuid>,
) -> Result<Option<thought::Model>, DbErr> {
let thought = thought::Entity::find_by_id(thought_id).one(db).await?;
match thought {
Some(t) => {
if t.visibility == thought::Visibility::Public {
return Ok(Some(t));
}
if let Some(viewer) = viewer_id {
if t.author_id == viewer {
return Ok(Some(t));
}
if t.visibility == thought::Visibility::FriendsOnly {
let author_friends = follow::get_friend_ids(db, t.author_id).await?;
if author_friends.contains(&viewer) {
return Ok(Some(t));
}
}
}
Ok(None)
}
None => Ok(None),
}
}
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
thought::Entity::delete_by_id(thought_id).exec(db).await?;
Ok(())
}
pub async fn get_thoughts_by_user(
db: &DbConn,
user_id: Uuid,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut friend_ids = vec![];
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::AuthorId)
.column(thought::Column::Visibility)
.column_as(user::Column::DisplayName, "author_display_name")
.column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(apply_visibility_filter(user_id, viewer_id, &friend_ids))
.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,
following_ids: Vec<Uuid>,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
if following_ids.is_empty() {
return Ok(vec![]);
}
let mut friend_ids = vec![];
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
}
thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(
Condition::any().add(following_ids.iter().fold(
Condition::all(),
|cond, &author_id| {
cond.add(apply_visibility_filter(author_id, viewer_id, &friend_ids))
},
)),
)
.filter(thought::Column::AuthorId.is_in(following_ids))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))
}
pub async fn get_feed_for_users_and_self(
db: &DbConn,
user_id: Uuid,
following_ids: Vec<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut authors_to_include = following_ids;
authors_to_include.push(user_id);
thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(authors_to_include))
.filter(
Condition::any()
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
)
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
}
pub async fn get_feed_for_users_and_self_paginated(
db: &DbConn,
user_id: Uuid,
following_ids: Vec<Uuid>,
pagination: &PaginationQuery,
) -> Result<(Vec<ThoughtWithAuthor>, u64), DbErr> {
let mut authors_to_include = following_ids;
authors_to_include.push(user_id);
let paginator = thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(authors_to_include))
.filter(
Condition::any()
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
)
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.paginate(db, pagination.page_size());
let total_items = paginator.num_items().await?;
let thoughts = paginator.fetch_page(pagination.page() - 1).await?;
Ok((thoughts, total_items))
}
pub async fn get_thoughts_by_tag_name(
db: &DbConn,
tag_name: &str,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut friend_ids = Vec::new();
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
let thoughts = thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::AuthorId)
.column(thought::Column::Visibility)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
.filter(tag::Column::Name.eq(tag_name.to_lowercase()))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await?;
let visible_thoughts = thoughts
.into_iter()
.filter(|thought| {
let mut condition = thought.visibility == thought::Visibility::Public;
if let Some(viewer) = viewer_id {
if thought.author_id == viewer {
condition = true;
}
if thought.visibility == thought::Visibility::FriendsOnly
&& friend_ids.contains(&thought.author_id)
{
condition = true;
}
}
condition
})
.collect();
Ok(visible_thoughts)
}
pub fn apply_visibility_filter(
user_id: Uuid,
viewer_id: Option<Uuid>,
friend_ids: &[Uuid],
) -> SimpleExpr {
let mut condition =
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
if let Some(viewer) = viewer_id {
if user_id == viewer {
condition = condition
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
} else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
condition =
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
}
}
condition.into()
}
pub async fn get_thought_with_replies(
db: &DbConn,
thought_id: Uuid,
viewer_id: Option<Uuid>,
) -> Result<Option<ThoughtThreadSchema>, DbErr> {
let root_thought = match get_thought(db, thought_id, viewer_id).await? {
Some(t) => t,
None => return Ok(None),
};
let mut all_thoughts_in_thread = vec![root_thought.clone()];
let mut ids_to_fetch = vec![root_thought.id];
let mut friend_ids = vec![];
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
while !ids_to_fetch.is_empty() {
let replies = thought::Entity::find()
.filter(thought::Column::ReplyToId.is_in(ids_to_fetch))
.all(db)
.await?;
if replies.is_empty() {
break;
}
ids_to_fetch = replies.iter().map(|r| r.id).collect();
all_thoughts_in_thread.extend(replies);
}
let mut thought_schemas = vec![];
for thought in all_thoughts_in_thread {
if let Some(author) = user::Entity::find_by_id(thought.author_id).one(db).await? {
let is_visible = match thought.visibility {
thought::Visibility::Public => true,
thought::Visibility::Private => viewer_id.map_or(false, |v| v == thought.author_id),
thought::Visibility::FriendsOnly => viewer_id.map_or(false, |v| {
v == thought.author_id || friend_ids.contains(&thought.author_id)
}),
};
if is_visible {
thought_schemas.push(ThoughtSchema::from_models(&thought, &author));
}
}
}
fn build_thread(
thought_id: Uuid,
schemas_map: &std::collections::HashMap<Uuid, ThoughtSchema>,
replies_map: &std::collections::HashMap<Uuid, Vec<Uuid>>,
) -> Option<ThoughtThreadSchema> {
schemas_map.get(&thought_id).map(|thought_schema| {
let replies = replies_map
.get(&thought_id)
.unwrap_or(&vec![])
.iter()
.filter_map(|reply_id| build_thread(*reply_id, schemas_map, replies_map))
.collect();
ThoughtThreadSchema {
id: thought_schema.id,
author_username: thought_schema.author_username.clone(),
author_display_name: thought_schema.author_display_name.clone(),
content: thought_schema.content.clone(),
visibility: thought_schema.visibility.clone(),
reply_to_id: thought_schema.reply_to_id,
created_at: thought_schema.created_at.clone(),
replies,
}
})
}
let schemas_map: std::collections::HashMap<Uuid, ThoughtSchema> =
thought_schemas.into_iter().map(|s| (s.id, s)).collect();
let mut replies_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
std::collections::HashMap::new();
for thought in schemas_map.values() {
if let Some(parent_id) = thought.reply_to_id {
if schemas_map.contains_key(&parent_id) {
replies_map.entry(parent_id).or_default().push(thought.id);
}
}
}
Ok(build_thread(root_thought.id, &schemas_map, &replies_map))
}

View File

@@ -1,186 +0,0 @@
use models::queries::pagination::PaginationQuery;
use sea_orm::prelude::Uuid;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
};
use models::domains::{top_friends, user};
use models::params::user::{CreateUserParams, UpdateUserParams};
use models::queries::user::UserQuery;
use crate::error::UserError;
use crate::persistence::follow::{get_follower_ids, get_following_ids, get_friend_ids};
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: Uuid) -> 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<Uuid>) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.filter(user::Column::Id.is_in(ids))
.all(db)
.await
}
pub async fn update_user_profile(
db: &DbConn,
user_id: Uuid,
params: UpdateUserParams,
) -> Result<user::Model, UserError> {
let mut user: user::ActiveModel = get_user(db, user_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound)?
.into();
if let Some(display_name) = params.display_name {
user.display_name = Set(Some(display_name));
}
if let Some(bio) = params.bio {
user.bio = Set(Some(bio));
}
if let Some(avatar_url) = params.avatar_url {
user.avatar_url = Set(Some(avatar_url));
}
if let Some(header_url) = params.header_url {
user.header_url = Set(Some(header_url));
}
if let Some(custom_css) = params.custom_css {
user.custom_css = Set(Some(custom_css));
}
if let Some(friend_usernames) = params.top_friends {
let txn = db
.begin()
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
top_friends::Entity::delete_many()
.filter(top_friends::Column::UserId.eq(user_id))
.exec(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
let friends = user::Entity::find()
.filter(user::Column::Username.is_in(friend_usernames.clone()))
.all(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
if friends.len() != friend_usernames.len() {
return Err(UserError::Validation(
"One or more usernames in top_friends do not exist".to_string(),
));
}
let new_top_friends: Vec<top_friends::ActiveModel> = friends
.iter()
.enumerate()
.map(|(index, friend)| top_friends::ActiveModel {
user_id: Set(user_id),
friend_id: Set(friend.id),
position: Set((index + 1) as i16),
..Default::default()
})
.collect();
if !new_top_friends.is_empty() {
top_friends::Entity::insert_many(new_top_friends)
.exec(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
}
txn.commit()
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
}
user.update(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))
}
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.join(
JoinType::InnerJoin,
top_friends::Relation::Friend.def().rev(),
)
.filter(top_friends::Column::UserId.eq(user_id))
.order_by_asc(top_friends::Column::Position)
.all(db)
.await
}
pub async fn get_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
let friend_ids = get_friend_ids(db, user_id).await?;
if friend_ids.is_empty() {
return Ok(vec![]);
}
get_users_by_ids(db, friend_ids).await
}
pub async fn get_following(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
let following_ids = get_following_ids(db, user_id).await?;
if following_ids.is_empty() {
return Ok(vec![]);
}
get_users_by_ids(db, following_ids).await
}
pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
let follower_ids = get_follower_ids(db, user_id).await?;
if follower_ids.is_empty() {
return Ok(vec![]);
}
get_users_by_ids(db, follower_ids).await
}
pub async fn get_all_users(
db: &DbConn,
pagination: &PaginationQuery,
) -> Result<(Vec<user::Model>, u64), DbErr> {
let paginator = user::Entity::find()
.order_by_desc(user::Column::CreatedAt)
.paginate(db, pagination.page_size());
let total_items = paginator.num_items().await?;
let users = paginator.fetch_page(pagination.page() - 1).await?;
Ok((users, total_items))
}
pub async fn get_all_users_count(db: &DbConn) -> Result<u64, DbErr> {
user::Entity::find().count(db).await
}

View File

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

View File

@@ -1,14 +0,0 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[lib]
name = "common"
path = "src/lib.rs"
[dependencies]
serde = { workspace = true }
utoipa = { workspace = true }
sea-orm = { workspace = true }
sea-query = { workspace = true }

View File

@@ -1,53 +0,0 @@
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, Clone)]
#[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

@@ -1,24 +0,0 @@
[package]
name = "doc"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "doc"
path = "src/lib.rs"
[dependencies]
axum = { workspace = true }
tracing = { 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

@@ -1,16 +0,0 @@
use api::{models::ApiErrorResponse, routers::api_key::*};
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse, ApiKeySchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(get_keys, create_key, delete_key),
components(schemas(
ApiKeySchema,
ApiKeyListSchema,
ApiKeyRequest,
ApiKeyResponse,
ApiErrorResponse,
))
)]
pub(super) struct ApiKeyApi;

View File

@@ -1,23 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,12 +0,0 @@
use utoipa::OpenApi;
use api::models::{ApiErrorResponse, ParamsErrorResponse};
use api::routers::friends::*;
use models::schemas::user::{UserListSchema, UserSchema};
#[derive(OpenApi)]
#[openapi(
paths(get_friends_list,),
components(schemas(UserListSchema, ApiErrorResponse, ParamsErrorResponse, UserSchema))
)]
pub(super) struct FriendsApi;

View File

@@ -1,51 +0,0 @@
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;
#[derive(OpenApi)]
#[openapi(
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"),
(name = "tag", description = "Tag Discovery API"),
(name = "friends", description = "Friends API"),
(name = "search", description = "Search 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 ApiDocExt {
fn attach_doc(self) -> Self;
}
impl ApiDocExt for Router {
fn attach_doc(self) -> Self {
tracing::info!("Attaching API documentation");
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
}
}

View File

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

View File

@@ -1,21 +0,0 @@
use api::{models::ApiErrorResponse, routers::search::*};
use models::schemas::{
search::SearchResultsSchema,
thought::{ThoughtListSchema, ThoughtSchema},
user::{UserListSchema, UserSchema},
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(search_all),
components(schemas(
SearchResultsSchema,
ApiErrorResponse,
ThoughtSchema,
ThoughtListSchema,
UserSchema,
UserListSchema
))
)]
pub(super) struct SearchApi;

View File

@@ -1,12 +0,0 @@
// in thoughts-backend/doc/src/tag.rs
use api::{models::ApiErrorResponse, routers::tag::*};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(get_thoughts_by_tag, get_popular_tags),
components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse))
)]
pub(super) struct TagApi;

View File

@@ -1,22 +0,0 @@
use api::{
models::{ApiErrorResponse, ParamsErrorResponse},
routers::thought::*,
};
use models::{
params::thought::CreateThoughtParams,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread),
components(schemas(
CreateThoughtParams,
ThoughtSchema,
ThoughtThreadSchema,
ApiErrorResponse,
ParamsErrorResponse
))
)]
pub(super) struct ThoughtApi;

View File

@@ -1,37 +0,0 @@
use utoipa::OpenApi;
use api::models::{ApiErrorResponse, ParamsErrorResponse};
use api::routers::user::*;
use models::params::user::{CreateUserParams, UpdateUserParams};
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,
get_me,
update_me,
get_user_followers,
get_user_following
),
components(schemas(
CreateUserParams,
UserListSchema,
UpdateUserParams,
UserSchema,
ThoughtSchema,
ThoughtListSchema,
ApiErrorResponse,
ParamsErrorResponse,
))
)]
pub(super) struct UserApi;

View File

@@ -1,15 +0,0 @@
[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-postgres"] }

View File

@@ -1,59 +0,0 @@
# 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

@@ -1,28 +0,0 @@
pub use sea_orm_migration::prelude::*;
mod m20240101_000001_init;
mod m20250905_000001_init;
mod m20250906_100000_add_profile_fields;
mod m20250906_130237_add_tags;
mod m20250906_134056_add_api_keys;
mod m20250906_145148_add_reply_to_thoughts;
mod m20250906_145755_add_visibility_to_thoughts;
mod m20250906_231359_add_full_text_search;
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),
Box::new(m20250906_100000_add_profile_fields::Migration),
Box::new(m20250906_130237_add_tags::Migration),
Box::new(m20250906_134056_add_api_keys::Migration),
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
Box::new(m20250906_231359_add_full_text_search::Migration),
]
}
}

View File

@@ -1,47 +0,0 @@
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)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.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

@@ -1,101 +0,0 @@
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(
ColumnDef::new(Thought::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(uuid(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(uuid(Follow::FollowerId).not_null())
.col(uuid(Follow::FollowingId).not_null())
// Composite Primary Key to ensure a user can only follow another once
.primary_key(
Index::create()
.col(Follow::FollowerId)
.col(Follow::FollowingId),
)
.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_following_id")
.from(Follow::Table, Follow::FollowingId)
.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)]
pub enum Thought {
Table,
Id,
AuthorId,
Content,
CreatedAt,
}
#[derive(DeriveIden)]
pub enum Follow {
Table,
// The user who is initiating the follow
FollowerId,
// The user who is being followed
FollowingId,
}

View File

@@ -1,107 +0,0 @@
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> {
manager
.alter_table(
Table::alter()
.table(User::Table)
.add_column(string_null(UserExtension::Email).unique_key())
.add_column(string_null(UserExtension::DisplayName))
.add_column(string_null(UserExtension::Bio))
.add_column(text_null(UserExtension::AvatarUrl))
.add_column(text_null(UserExtension::HeaderUrl))
.add_column(text_null(UserExtension::CustomCss))
.add_column(
timestamp_with_time_zone(UserExtension::CreatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.add_column(
timestamp_with_time_zone(UserExtension::UpdatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(TopFriends::Table)
.if_not_exists()
.col(uuid(TopFriends::UserId).not_null())
.col(uuid(TopFriends::FriendId).not_null())
.col(small_integer(TopFriends::Position).not_null())
.primary_key(
Index::create()
.col(TopFriends::UserId)
.col(TopFriends::FriendId),
)
.foreign_key(
ForeignKey::create()
.name("fk_top_friends_user_id")
.from(TopFriends::Table, TopFriends::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_top_friends_friend_id")
.from(TopFriends::Table, TopFriends::FriendId)
.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(TopFriends::Table).to_owned())
.await?;
manager
.alter_table(
Table::alter()
.table(User::Table)
.drop_column(UserExtension::Email)
.drop_column(UserExtension::DisplayName)
.drop_column(UserExtension::Bio)
.drop_column(UserExtension::AvatarUrl)
.drop_column(UserExtension::HeaderUrl)
.drop_column(UserExtension::CustomCss)
.drop_column(UserExtension::CreatedAt)
.drop_column(UserExtension::UpdatedAt)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum UserExtension {
Email,
DisplayName,
Bio,
AvatarUrl,
HeaderUrl,
CustomCss,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum TopFriends {
Table,
UserId,
FriendId,
Position,
}

View File

@@ -1,74 +0,0 @@
use super::m20250905_000001_init::Thought;
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> {
manager
.create_table(
Table::create()
.table(Tag::Table)
.if_not_exists()
.col(pk_auto(Tag::Id))
.col(string(Tag::Name).not_null().unique_key())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ThoughtTag::Table)
.if_not_exists()
.col(uuid(ThoughtTag::ThoughtId).not_null())
.col(integer(ThoughtTag::TagId).not_null())
.primary_key(
Index::create()
.col(ThoughtTag::ThoughtId)
.col(ThoughtTag::TagId),
)
.foreign_key(
ForeignKey::create()
.name("fk_thought_tag_thought_id")
.from(ThoughtTag::Table, ThoughtTag::ThoughtId)
.to(Thought::Table, Thought::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_thought_tag_tag_id")
.from(ThoughtTag::Table, ThoughtTag::TagId)
.to(Tag::Table, Tag::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ThoughtTag::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Tag::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Tag {
Table,
Id,
Name,
}
#[derive(DeriveIden)]
enum ThoughtTag {
Table,
ThoughtId,
TagId,
}

View File

@@ -1,69 +0,0 @@
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> {
manager
.create_table(
Table::create()
.table(ApiKey::Table)
.if_not_exists()
.col(
ColumnDef::new(ApiKey::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(uuid(ApiKey::UserId).not_null())
.foreign_key(
ForeignKey::create()
.name("fk_api_key_user_id")
.from(ApiKey::Table, ApiKey::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.col(text(ApiKey::KeyHash).not_null().unique_key())
.col(string(ApiKey::Name).not_null())
.col(
timestamp_with_time_zone(ApiKey::CreatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(ApiKey::KeyPrefix).string_len(8).not_null())
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-api_keys-key_prefix")
.table(ApiKey::Table)
.col(ApiKey::KeyPrefix)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ApiKey::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ApiKey {
Table,
Id,
UserId,
KeyHash,
Name,
CreatedAt,
KeyPrefix,
}

View File

@@ -1,46 +0,0 @@
use sea_orm_migration::{prelude::*, schema::*};
use crate::m20250905_000001_init::Thought;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.add_column(uuid_null(ThoughtExtension::ReplyToId))
.add_foreign_key(
TableForeignKey::new()
.name("fk_thought_reply_to_id")
.from_tbl(Thought::Table)
.from_col(ThoughtExtension::ReplyToId)
.to_tbl(Thought::Table)
.to_col(Thought::Id)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.drop_foreign_key(Alias::new("fk_thought_reply_to_id"))
.drop_column(ThoughtExtension::ReplyToId)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum ThoughtExtension {
ReplyToId,
}

View File

@@ -1,59 +0,0 @@
use super::m20250905_000001_init::Thought;
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
.get_connection()
.execute_unprepared(
"CREATE TYPE thought_visibility AS ENUM ('public', 'friends_only', 'private')",
)
.await?;
// 2. Add the new column to the thoughts table
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.add_column(
ColumnDef::new(ThoughtExtension::Visibility)
.enumeration(
"thought_visibility",
["public", "friends_only", "private"],
)
.not_null()
.default("public"), // Default new thoughts to public
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.drop_column(ThoughtExtension::Visibility)
.to_owned(),
)
.await?;
// Drop the ENUM type
manager
.get_connection()
.execute_unprepared("DROP TYPE thought_visibility")
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum ThoughtExtension {
Visibility,
}

View File

@@ -1,48 +0,0 @@
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> {
// --- Users Table ---
// Add the tsvector column for users
manager.get_connection().execute_unprepared(
"ALTER TABLE \"user\" ADD COLUMN \"search_document\" tsvector \
GENERATED ALWAYS AS (to_tsvector('english', username || ' ' || coalesce(display_name, ''))) STORED"
).await?;
// Add the GIN index for users
manager.get_connection().execute_unprepared(
"CREATE INDEX \"user_search_document_idx\" ON \"user\" USING GIN(\"search_document\")"
).await?;
// --- Thoughts Table ---
// Add the tsvector column for thoughts
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE \"thought\" ADD COLUMN \"search_document\" tsvector \
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED",
)
.await?;
// Add the GIN index for thoughts
manager.get_connection().execute_unprepared(
"CREATE INDEX \"thought_search_document_idx\" ON \"thought\" USING GIN(\"search_document\")"
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared("ALTER TABLE \"user\" DROP COLUMN \"search_document\"")
.await?;
manager
.get_connection()
.execute_unprepared("ALTER TABLE \"thought\" DROP COLUMN \"search_document\"")
.await?;
Ok(())
}
}

View File

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

View File

@@ -1,23 +0,0 @@
[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",
"runtime-tokio-rustls",
"macros",
] }
uuid = { version = "1.18.1", features = ["v4", "serde"] }
validator = { workspace = true, features = ["derive"] }
utoipa = { workspace = true }
common = { path = "../common" }

View File

@@ -1,13 +0,0 @@
# 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

@@ -1,32 +0,0 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "api_key")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_id: Uuid,
pub key_prefix: String,
#[sea_orm(unique)]
pub key_hash: String,
pub name: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,38 +0,0 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "follow")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub follower_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub following_id: Uuid,
}
#[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::FollowingId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Following,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Follower.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,11 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub mod prelude;
pub mod api_key;
pub mod follow;
pub mod tag;
pub mod thought;
pub mod thought_tag;
pub mod top_friends;
pub mod user;

View File

@@ -1,9 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub use super::api_key::Entity as ApiKey;
pub use super::follow::Entity as Follow;
pub use super::tag::Entity as Tag;
pub use super::thought::Entity as Thought;
pub use super::thought_tag::Entity as ThoughtTag;
pub use super::top_friends::Entity as TopFriends;
pub use super::user::Entity as User;

View File

@@ -1,27 +0,0 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "tag")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::thought_tag::Entity")]
ThoughtTag,
}
impl Related<super::thought::Entity> for Entity {
fn to() -> RelationDef {
super::thought_tag::Relation::Thought.def()
}
fn via() -> Option<RelationDef> {
Some(super::thought_tag::Relation::Tag.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,62 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(
Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, ToSchema,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "thought_visibility")]
pub enum Visibility {
#[sea_orm(string_value = "public")]
Public,
#[sea_orm(string_value = "friends_only")]
FriendsOnly,
#[sea_orm(string_value = "private")]
Private,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "thought")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub author_id: Uuid,
pub content: String,
pub reply_to_id: Option<Uuid>,
pub visibility: Visibility,
pub created_at: DateTimeWithTimeZone,
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
pub search_document: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::AuthorId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
#[sea_orm(has_many = "super::thought_tag::Entity")]
ThoughtTag,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
super::thought_tag::Relation::Tag.def()
}
fn via() -> Option<RelationDef> {
Some(super::thought_tag::Relation::Thought.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,40 +0,0 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "thought_tag")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub thought_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::thought::Entity",
from = "Column::ThoughtId",
to = "super::thought::Column::Id"
)]
Thought,
#[sea_orm(
belongs_to = "super::tag::Entity",
from = "Column::TagId",
to = "super::tag::Column::Id"
)]
Tag,
}
impl Related<super::thought::Entity> for Entity {
fn to() -> RelationDef {
Relation::Thought.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,35 +0,0 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "top_friends")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub friend_id: Uuid,
pub position: i16,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::FriendId",
to = "super::user::Column::Id"
)]
Friend,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,38 +0,0 @@
//! `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, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub username: String,
pub password_hash: Option<String>,
#[sea_orm(unique)]
pub email: Option<String>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
pub search_document: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::thought::Entity")]
Thought,
#[sea_orm(has_many = "super::top_friends::Entity")]
TopFriends,
#[sea_orm(has_many = "super::api_key::Entity")]
ApiKey,
}
impl ActiveModelBehavior for ActiveModel {}

View File

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

View File

@@ -1,21 +0,0 @@
use serde::Deserialize;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Deserialize, Validate, ToSchema)]
pub struct RegisterParams {
#[validate(length(min = 3))]
pub username: String,
#[validate(email)]
pub email: 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

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

View File

@@ -1,19 +0,0 @@
use serde::Deserialize;
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
use crate::domains::thought::Visibility;
#[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,
pub visibility: Option<Visibility>,
#[serde(rename = "replyToId")]
pub reply_to_id: Option<Uuid>,
}

View File

@@ -1,38 +0,0 @@
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,
}
#[derive(Deserialize, Validate, ToSchema, Default)]
pub struct UpdateUserParams {
#[validate(length(max = 50))]
#[schema(example = "Frutiger Aero Fan")]
#[serde(rename = "displayName")]
pub display_name: Option<String>,
#[validate(length(max = 4000))]
#[schema(example = "Est. 2004")]
pub bio: Option<String>,
#[validate(url)]
#[serde(rename = "avatarUrl")]
pub avatar_url: Option<String>,
#[validate(url)]
#[serde(rename = "headerUrl")]
pub header_url: Option<String>,
#[serde(rename = "customCss")]
pub custom_css: Option<String>,
#[validate(length(max = 8))]
#[schema(example = json!(["username1", "username2"]))]
#[serde(rename = "topFriends")]
pub top_friends: Option<Vec<String>>,
}

View File

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

View File

@@ -1,27 +0,0 @@
use serde::Deserialize;
use utoipa::IntoParams;
const DEFAULT_PAGE: u64 = 1;
const DEFAULT_PAGE_SIZE: u64 = 20;
#[derive(Deserialize, IntoParams)]
pub struct PaginationQuery {
#[param(nullable = true, example = 1)]
page: Option<u64>,
#[param(nullable = true, example = 20)]
page_size: Option<u64>,
}
impl PaginationQuery {
pub fn page(&self) -> u64 {
self.page.unwrap_or(DEFAULT_PAGE).max(1)
}
pub fn page_size(&self) -> u64 {
self.page_size.unwrap_or(DEFAULT_PAGE_SIZE).max(1)
}
pub fn offset(&self) -> u64 {
(self.page() - 1) * self.page_size()
}
}

View File

@@ -1,9 +0,0 @@
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

@@ -1,64 +0,0 @@
use crate::domains::api_key;
use common::DateTimeWithTimeZoneWrapper;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Serialize, ToSchema)]
pub struct ApiKeySchema {
pub id: Uuid,
pub name: String,
#[serde(rename = "keyPrefix")]
pub key_prefix: String,
#[serde(rename = "createdAt")]
pub created_at: DateTimeWithTimeZoneWrapper,
}
#[derive(Serialize, ToSchema)]
pub struct ApiKeyResponse {
#[serde(flatten)]
pub key: ApiKeySchema,
#[serde(skip_serializing_if = "Option::is_none", rename = "plaintextKey")]
pub plaintext_key: Option<String>,
}
impl ApiKeyResponse {
pub fn from_parts(model: api_key::Model, plaintext_key: Option<String>) -> Self {
Self {
key: ApiKeySchema {
id: model.id,
name: model.name,
key_prefix: model.key_prefix,
created_at: model.created_at.into(),
},
plaintext_key,
}
}
}
#[derive(Serialize, ToSchema)]
pub struct ApiKeyListSchema {
#[serde(rename = "apiKeys")]
pub api_keys: Vec<ApiKeySchema>,
}
impl From<Vec<api_key::Model>> for ApiKeyListSchema {
fn from(keys: Vec<api_key::Model>) -> Self {
Self {
api_keys: keys
.into_iter()
.map(|k| ApiKeySchema {
id: k.id,
name: k.name,
key_prefix: k.key_prefix,
created_at: k.created_at.into(),
})
.collect(),
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct ApiKeyRequest {
pub name: String,
}

View File

@@ -1,5 +0,0 @@
pub mod api_key;
pub mod pagination;
pub mod search;
pub mod thought;
pub mod user;

View File

@@ -1,12 +0,0 @@
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
pub total_items: u64,
}

View File

@@ -1,9 +0,0 @@
use super::{thought::ThoughtListSchema, user::UserListSchema};
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct SearchResultsSchema {
pub users: UserListSchema,
pub thoughts: ThoughtListSchema,
}

View File

@@ -1,88 +0,0 @@
use crate::domains::{
thought::{self, Visibility},
user,
};
use common::DateTimeWithTimeZoneWrapper;
use sea_orm::FromQueryResult;
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtSchema {
pub id: Uuid,
#[schema(example = "frutiger")]
pub author_username: String,
pub author_display_name: Option<String>,
#[schema(example = "This is my first thought! #welcome")]
pub content: String,
pub visibility: Visibility,
pub reply_to_id: Option<Uuid>,
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(),
author_display_name: author.display_name.clone(),
content: thought.content.clone(),
visibility: thought.visibility.clone(),
reply_to_id: thought.reply_to_id,
created_at: thought.created_at.into(),
}
}
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
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: Uuid,
pub content: String,
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
pub visibility: Visibility,
pub author_id: Uuid,
pub author_username: String,
pub author_display_name: Option<String>,
pub reply_to_id: Option<Uuid>,
}
impl From<ThoughtWithAuthor> for ThoughtSchema {
fn from(model: ThoughtWithAuthor) -> Self {
Self {
id: model.id,
author_username: model.author_username,
author_display_name: model.author_display_name,
content: model.content,
created_at: model.created_at.into(),
reply_to_id: model.reply_to_id,
visibility: model.visibility,
}
}
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtThreadSchema {
pub id: Uuid,
pub author_username: String,
pub author_display_name: Option<String>,
pub content: String,
pub visibility: Visibility,
pub reply_to_id: Option<Uuid>,
pub created_at: DateTimeWithTimeZoneWrapper,
pub replies: Vec<ThoughtThreadSchema>,
}

Some files were not shown because too many files have changed in this diff Show More