remove legacy v1 backend
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
# Ignore build artifacts
|
|
||||||
target/
|
|
||||||
# Ignore git directory
|
|
||||||
.git/
|
|
||||||
# Ignore local environment files
|
|
||||||
.env
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
2
thoughts-backend/.gitignore
vendored
2
thoughts-backend/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
/target
|
|
||||||
.env
|
|
||||||
5093
thoughts-backend/Cargo.lock
generated
5093
thoughts-backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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]
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
mod adapter;
|
|
||||||
mod core;
|
|
||||||
mod handler;
|
|
||||||
mod traits;
|
|
||||||
|
|
||||||
pub use core::ApiError;
|
|
||||||
pub use traits::HTTPError;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
use axum::http::StatusCode;
|
|
||||||
|
|
||||||
pub trait HTTPError {
|
|
||||||
fn to_status_code(&self) -> StatusCode;
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
mod response;
|
|
||||||
|
|
||||||
pub use response::{ApiErrorResponse, ParamsErrorResponse, ValidationErrorResponse};
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
mod rejection;
|
|
||||||
|
|
||||||
pub use rejection::ValidRejection;
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# app
|
|
||||||
|
|
||||||
No axum or api dependencies should be introduced into this folder.
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
mod user;
|
|
||||||
|
|
||||||
pub use user::UserError;
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod config;
|
|
||||||
pub mod error;
|
|
||||||
pub mod persistence;
|
|
||||||
pub mod state;
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(¶ms.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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(¶ms.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))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
use sea_orm::DatabaseConnection;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppState {
|
|
||||||
pub conn: DatabaseConnection,
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
use utoipa::OpenApi;
|
|
||||||
|
|
||||||
use api::routers::root::*;
|
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
|
||||||
#[openapi(paths(root_get))]
|
|
||||||
pub(super) struct RootApi;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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"] }
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
#[async_std::main]
|
|
||||||
async fn main() {
|
|
||||||
cli::run_cli(migration::Migrator).await;
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
@@ -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.
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod domains;
|
|
||||||
pub mod params;
|
|
||||||
pub mod queries;
|
|
||||||
pub mod schemas;
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod auth;
|
|
||||||
pub mod thought;
|
|
||||||
pub mod user;
|
|
||||||
@@ -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>,
|
|
||||||
}
|
|
||||||
@@ -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>>,
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod pagination;
|
|
||||||
pub mod user;
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod api_key;
|
|
||||||
pub mod pagination;
|
|
||||||
pub mod search;
|
|
||||||
pub mod thought;
|
|
||||||
pub mod user;
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user