├── migrations ├── .gitkeep ├── 2021-10-21-064114_create_users │ ├── down.sql │ └── up.sql ├── 2021-10-25-170453_create_tags │ ├── down.sql │ └── up.sql ├── 2021-10-24-020751_create_follows │ ├── down.sql │ └── up.sql ├── 2021-10-24-230744_create_articles │ ├── down.sql │ └── up.sql ├── 2021-10-29-195721_create_comments │ ├── down.sql │ └── up.sql ├── 2021-10-29-224838_create_favorites │ ├── down.sql │ └── up.sql └── 00000000000000_diesel_initial_setup │ ├── down.sql │ └── up.sql ├── .gitignore ├── .dockerignore ├── src ├── app │ ├── features │ │ ├── follow │ │ │ ├── mod.rs │ │ │ └── entities.rs │ │ ├── healthcheck │ │ │ ├── mod.rs │ │ │ └── controllers.rs │ │ ├── tag │ │ │ ├── mod.rs │ │ │ ├── controllers.rs │ │ │ ├── repositories.rs │ │ │ ├── usecases.rs │ │ │ ├── presenters.rs │ │ │ └── entities.rs │ │ ├── favorite │ │ │ ├── mod.rs │ │ │ ├── controllers.rs │ │ │ ├── presenters.rs │ │ │ ├── usecases.rs │ │ │ ├── repositories.rs │ │ │ └── entities.rs │ │ ├── profile │ │ │ ├── mod.rs │ │ │ ├── entities.rs │ │ │ ├── repositories.rs │ │ │ ├── controllers.rs │ │ │ ├── presenters.rs │ │ │ └── usecases.rs │ │ ├── article │ │ │ ├── mod.rs │ │ │ ├── requests.rs │ │ │ ├── controllers.rs │ │ │ ├── usecases.rs │ │ │ ├── presenters.rs │ │ │ ├── entities.rs │ │ │ └── repositories.rs │ │ ├── comment │ │ │ ├── mod.rs │ │ │ ├── request.rs │ │ │ ├── controllers.rs │ │ │ ├── usecases.rs │ │ │ ├── entities.rs │ │ │ ├── repositories.rs │ │ │ └── presenters.rs │ │ ├── user │ │ │ ├── mod.rs │ │ │ ├── requests.rs │ │ │ ├── controllers.rs │ │ │ ├── presenters.rs │ │ │ ├── usecases.rs │ │ │ ├── repositories.rs │ │ │ └── entities.rs │ │ └── mod.rs │ ├── mod.rs │ └── drivers │ │ ├── mod.rs │ │ ├── middlewares │ │ ├── mod.rs │ │ ├── state.rs │ │ ├── error.rs │ │ ├── cors.rs │ │ └── auth.rs │ │ └── routes.rs ├── utils │ ├── api.rs │ ├── mod.rs │ ├── uuid.rs │ ├── hasher.rs │ ├── converter.rs │ ├── date.rs │ ├── db.rs │ ├── token.rs │ └── di.rs ├── constants.rs ├── main.rs ├── schema.rs └── error.rs ├── scripts ├── copy-env.sh └── wait-for-it.sh ├── diesel.toml ├── .env.example ├── e2e ├── run-api-tests.sh └── openapi.yml ├── Dockerfile ├── compose.yaml ├── LICENSE.md ├── Cargo.toml ├── README.md └── .github └── workflows └── ci.yml /migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | .env -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | .github 3 | -------------------------------------------------------------------------------- /src/app/features/follow/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod entities; 2 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod drivers; 2 | pub mod features; 3 | -------------------------------------------------------------------------------- /src/app/features/healthcheck/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | -------------------------------------------------------------------------------- /migrations/2021-10-21-064114_create_users/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users; -------------------------------------------------------------------------------- /migrations/2021-10-25-170453_create_tags/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE tags; -------------------------------------------------------------------------------- /src/app/drivers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod middlewares; 2 | pub mod routes; 3 | -------------------------------------------------------------------------------- /migrations/2021-10-24-020751_create_follows/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE follows; -------------------------------------------------------------------------------- /migrations/2021-10-24-230744_create_articles/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE articles; -------------------------------------------------------------------------------- /migrations/2021-10-29-195721_create_comments/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE comments; -------------------------------------------------------------------------------- /migrations/2021-10-29-224838_create_favorites/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE favorites; -------------------------------------------------------------------------------- /scripts/copy-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Create .env 4 | cp .env.example .env -------------------------------------------------------------------------------- /src/app/drivers/middlewares/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod cors; 3 | pub mod error; 4 | pub mod state; 5 | -------------------------------------------------------------------------------- /src/app/features/tag/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod entities; 3 | pub mod presenters; 4 | pub mod repositories; 5 | pub mod usecases; 6 | -------------------------------------------------------------------------------- /src/utils/api.rs: -------------------------------------------------------------------------------- 1 | use crate::error::AppError; 2 | use actix_web::HttpResponse; 3 | 4 | pub type ApiResponse = Result; 5 | -------------------------------------------------------------------------------- /src/app/features/favorite/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod entities; 3 | pub mod presenters; 4 | pub mod repositories; 5 | pub mod usecases; 6 | -------------------------------------------------------------------------------- /src/app/features/profile/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod entities; 3 | pub mod presenters; 4 | pub mod repositories; 5 | pub mod usecases; 6 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod converter; 3 | pub mod date; 4 | pub mod db; 5 | pub mod di; 6 | pub mod hasher; 7 | pub mod token; 8 | pub mod uuid; 9 | -------------------------------------------------------------------------------- /diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/schema.rs" 6 | -------------------------------------------------------------------------------- /src/app/features/article/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod entities; 3 | pub mod presenters; 4 | pub mod repositories; 5 | pub mod requests; 6 | pub mod usecases; 7 | -------------------------------------------------------------------------------- /src/app/features/comment/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod entities; 3 | pub mod presenters; 4 | pub mod repositories; 5 | pub mod request; 6 | pub mod usecases; 7 | -------------------------------------------------------------------------------- /src/app/features/user/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod entities; 3 | pub mod presenters; 4 | pub mod repositories; 5 | pub mod requests; 6 | pub mod usecases; 7 | -------------------------------------------------------------------------------- /src/app/features/healthcheck/controllers.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{HttpResponse, Responder}; 2 | 3 | pub async fn index() -> impl Responder { 4 | HttpResponse::Ok().body("OK") 5 | } 6 | -------------------------------------------------------------------------------- /src/app/features/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod article; 2 | pub mod comment; 3 | pub mod favorite; 4 | pub mod follow; 5 | pub mod healthcheck; 6 | pub mod profile; 7 | pub mod tag; 8 | pub mod user; 9 | -------------------------------------------------------------------------------- /src/utils/uuid.rs: -------------------------------------------------------------------------------- 1 | use crate::error::AppError; 2 | use uuid::Uuid; 3 | 4 | pub fn parse(maybe_uuid: &str) -> Result { 5 | let uuid = Uuid::parse_str(maybe_uuid)?; 6 | Ok(uuid) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/hasher.rs: -------------------------------------------------------------------------------- 1 | pub use bcrypt::verify; 2 | use bcrypt::{hash, BcryptResult, DEFAULT_COST}; 3 | 4 | pub fn hash_password(naive_pw: &str) -> BcryptResult { 5 | hash(naive_pw, DEFAULT_COST) 6 | } 7 | -------------------------------------------------------------------------------- /src/app/features/profile/entities.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Queryable, Serialize, Deserialize, Debug, Clone)] 4 | pub struct Profile { 5 | pub username: String, 6 | pub bio: Option, 7 | pub image: Option, 8 | pub following: bool, 9 | } 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ## for docker 2 | # DATABASE_URL=postgres://postgres:postgres@db:5432/realworld-rust-actix-web 3 | 4 | ## for naive server 5 | DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/realworld-rust-actix-web 6 | 7 | FRONTEND_ORIGIN=http://localhost:3000 8 | 9 | SECRET_KEY=0123456789012345 10 | -------------------------------------------------------------------------------- /src/app/features/comment/request.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize)] 4 | pub struct CreateCommentRequest { 5 | pub comment: InnerComment, 6 | } 7 | 8 | #[derive(Serialize, Deserialize)] 9 | pub struct InnerComment { 10 | pub body: String, 11 | } 12 | -------------------------------------------------------------------------------- /src/app/features/tag/controllers.rs: -------------------------------------------------------------------------------- 1 | extern crate serde_json; 2 | use crate::app::drivers::middlewares::state::AppState; 3 | use crate::utils::api::ApiResponse; 4 | use actix_web::web; 5 | 6 | pub async fn index(state: web::Data) -> ApiResponse { 7 | state.di_container.tag_usecase.fetch_tags() 8 | } 9 | -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | pub const AUTHORIZATION: &str = "Authorization"; 2 | 3 | pub const BIND: &str = "0.0.0.0:8080"; 4 | 5 | pub mod env_key { 6 | pub const DATABASE_URL: &str = "DATABASE_URL"; 7 | pub const FRONTEND_ORIGIN: &str = "FRONTEND_ORIGIN"; 8 | pub const SECRET_KEY: &str = "SECRET_KEY"; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/converter.rs: -------------------------------------------------------------------------------- 1 | use convert_case::{Case, Casing}; 2 | 3 | pub fn to_kebab(text: &str) -> String { 4 | text.to_case(Case::Kebab) 5 | } 6 | 7 | #[cfg(test)] 8 | mod tests { 9 | use super::*; 10 | 11 | #[test] 12 | fn str_to_kebab() { 13 | assert_eq!("this-is-blog-title", to_kebab("this is blog title")); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/drivers/middlewares/state.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::db::DbPool; 2 | use crate::utils::di::DiContainer; 3 | 4 | #[derive(Clone)] 5 | pub struct AppState { 6 | pub di_container: DiContainer, 7 | } 8 | 9 | impl AppState { 10 | pub fn new(pool: DbPool) -> Self { 11 | let di_container = DiContainer::new(&pool); 12 | Self { di_container } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /src/utils/date.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize, Serializer}; 2 | 3 | #[derive(Debug, PartialEq, Deserialize)] 4 | pub struct Iso8601(pub chrono::NaiveDateTime); 5 | 6 | impl Serialize for Iso8601 { 7 | fn serialize(&self, serializer: S) -> Result { 8 | let s = self.0.format("%Y-%m-%dT%H:%M:%S.%3fZ"); 9 | serializer.serialize_str(&s.to_string()) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /migrations/2021-10-21-064114_create_users/up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | 3 | CREATE TABLE users ( 4 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 5 | email TEXT NOT NULL UNIQUE, 6 | username TEXT NOT NULL, 7 | password Text NOT NULL, 8 | bio TEXT, 9 | image TEXT, 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 12 | ); -------------------------------------------------------------------------------- /migrations/2021-10-25-170453_create_tags/up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | 3 | CREATE TABLE tags ( 4 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 5 | article_id UUID NOT NULL REFERENCES articles (id) ON DELETE CASCADE, 6 | name TEXT NOT NULL, 7 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 8 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 9 | ); 10 | 11 | CREATE INDEX tags_article_id_idx ON tags (article_id); -------------------------------------------------------------------------------- /migrations/2021-10-24-230744_create_articles/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE articles ( 2 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 3 | author_id UUID NOT NULL REFERENCES users (id), 4 | slug TEXT UNIQUE NOT NULL, 5 | title TEXT NOT NULL, 6 | description TEXT NOT NULL, 7 | body TEXT NOT NULL, 8 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 9 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 10 | ); 11 | 12 | CREATE INDEX articles_author_id_idx ON articles (author_id); 13 | 14 | -------------------------------------------------------------------------------- /src/app/drivers/middlewares/error.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::convert::From; 3 | 4 | #[derive(Deserialize, Serialize)] 5 | pub struct ErrorResponse { 6 | pub errors: Inner, 7 | } 8 | 9 | impl From<&str> for ErrorResponse { 10 | fn from(msg: &str) -> Self { 11 | Self { 12 | errors: Inner { 13 | body: vec![msg.to_owned()], 14 | }, 15 | } 16 | } 17 | } 18 | 19 | #[derive(Deserialize, Serialize)] 20 | pub struct Inner { 21 | body: Vec, 22 | } 23 | -------------------------------------------------------------------------------- /e2e/run-api-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | 6 | APIURL=${APIURL:-https://api.realworld.io/api} 7 | USERNAME=${USERNAME:-u`date +%s`} 8 | EMAIL=${EMAIL:-$USERNAME@mail.com} 9 | PASSWORD=${PASSWORD:-password} 10 | 11 | npx newman run $SCRIPTDIR/e2e/Conduit.postman_collection.json \ 12 | --delay-request 500 \ 13 | --global-var "APIURL=$APIURL" \ 14 | --global-var "USERNAME=$USERNAME" \ 15 | --global-var "EMAIL=$EMAIL" \ 16 | --global-var "PASSWORD=$PASSWORD" \ 17 | "$@" -------------------------------------------------------------------------------- /migrations/2021-10-29-195721_create_comments/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE comments ( 2 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 3 | article_id UUID NOT NULL REFERENCES articles (id) ON DELETE CASCADE, 4 | author_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, 5 | body TEXT NOT NULL, 6 | create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 8 | ); 9 | 10 | CREATE INDEX comments_article_id_idx ON comments (article_id); 11 | CREATE INDEX comments_author_id_idx ON comments (author_id); 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.74-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | # Upgrade the system and install dependencies for PostgreSQL 8 | RUN apt-get update && \ 9 | apt-get upgrade -y -o DPkg::Options::=--force-confold && \ 10 | apt-get install -y -o DPkg::Options::=--force-confold \ 11 | curl unzip build-essential pkg-config libssl-dev \ 12 | postgresql-client libpq-dev 13 | 14 | # Install cargo-watch 15 | RUN cargo install cargo-watch 16 | 17 | # Install diesel_cli for PostgreSQL 18 | RUN cargo install diesel_cli --no-default-features --features "postgres" 19 | -------------------------------------------------------------------------------- /migrations/2021-10-29-224838_create_favorites/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE favorites ( 2 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 3 | article_id UUID NOT NULL REFERENCES articles (id) ON DELETE CASCADE, 4 | user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, 5 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 6 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | UNIQUE(article_id, user_id) 8 | ); 9 | 10 | CREATE INDEX favorites_article_id_idx ON favorites (article_id); 11 | CREATE INDEX users_article_id_idx ON favorites (user_id); 12 | -------------------------------------------------------------------------------- /src/app/drivers/middlewares/cors.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::env_key; 2 | use actix_cors::Cors; 3 | use actix_web::http; 4 | use std::env; 5 | 6 | pub fn cors() -> Cors { 7 | let frontend_origin = env::var(env_key::FRONTEND_ORIGIN).unwrap_or_else(|_| "*".to_string()); 8 | 9 | Cors::default() 10 | .allowed_origin(&frontend_origin) 11 | .allowed_origin_fn(|origin, _req_head| origin.as_bytes().ends_with(b".rust-lang.org")) 12 | .allowed_headers(vec![http::header::AUTHORIZATION, http::header::ACCEPT]) 13 | .allowed_header(http::header::CONTENT_TYPE) 14 | .max_age(3600) 15 | } 16 | -------------------------------------------------------------------------------- /migrations/2021-10-24-020751_create_follows/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE follows ( 2 | followee_id UUID NOT NULL REFERENCES users (id), 3 | follower_id UUID NOT NULL REFERENCES users (id), 4 | PRIMARY KEY (follower_id, followee_id), 5 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 6 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 7 | ); 8 | 9 | CREATE INDEX follows_follower_id ON follows (follower_id); 10 | CREATE INDEX follows_followee_id ON follows (followee_id); 11 | 12 | ALTER TABLE follows 13 | ADD CONSTRAINT follower_id_cannot_be_equal_to_followee_id 14 | CHECK (follower_id != followee_id); 15 | -------------------------------------------------------------------------------- /src/app/features/tag/repositories.rs: -------------------------------------------------------------------------------- 1 | use super::entities::Tag; 2 | use crate::error::AppError; 3 | use crate::utils::db::DbPool; 4 | 5 | pub trait TagRepository: Send + Sync + 'static { 6 | fn fetch_tags(&self) -> Result, AppError>; 7 | } 8 | 9 | #[derive(Clone)] 10 | pub struct TagRepositoryImpl { 11 | pool: DbPool, 12 | } 13 | 14 | impl TagRepositoryImpl { 15 | pub fn new(pool: DbPool) -> Self { 16 | Self { pool } 17 | } 18 | } 19 | 20 | impl TagRepository for TagRepositoryImpl { 21 | fn fetch_tags(&self) -> Result, AppError> { 22 | let conn = &mut self.pool.get()?; 23 | Tag::fetch(conn) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/db.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::env_key; 2 | use diesel::pg::PgConnection; 3 | use diesel::r2d2::{ConnectionManager, Pool, PoolError}; 4 | use dotenv::dotenv; 5 | use std::env; 6 | 7 | pub type DbPool = Pool>; 8 | 9 | fn init_pool(database_url: &str) -> Result { 10 | let manager = ConnectionManager::::new(database_url); 11 | Pool::builder().build(manager) 12 | } 13 | 14 | pub fn establish_connection() -> DbPool { 15 | dotenv().ok(); 16 | let database_url = env::var(env_key::DATABASE_URL).expect("DATABASE_URL must be set"); 17 | init_pool(&database_url).expect("Failed to create pool") 18 | } 19 | -------------------------------------------------------------------------------- /src/app/features/article/requests.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize)] 4 | pub struct CreateArticleRequest { 5 | pub article: CreateArticleInner, 6 | } 7 | 8 | #[derive(Deserialize, Serialize)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct CreateArticleInner { 11 | pub title: String, 12 | pub description: String, 13 | pub body: String, 14 | pub tag_list: Option>, 15 | } 16 | 17 | #[derive(Deserialize, Serialize)] 18 | pub struct UpdateArticleRequest { 19 | pub article: UpdateArticleInner, 20 | } 21 | 22 | #[derive(Deserialize, Serialize)] 23 | pub struct UpdateArticleInner { 24 | pub title: Option, 25 | pub description: Option, 26 | pub body: Option, 27 | } 28 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | container_name: app 4 | build: . 5 | command: 'bash -c "diesel setup && cargo watch --exec run"' 6 | volumes: 7 | - .:/app 8 | depends_on: 9 | db: 10 | condition: service_healthy 11 | ports: 12 | - "8080:8080" 13 | environment: 14 | DATABASE_URL: postgres://postgres:postgres@db:5432/realworld-rust-actix-web 15 | db: 16 | container_name: db 17 | image: "postgres:14.0-alpine" 18 | healthcheck: 19 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 20 | ports: 21 | - "5432:5432" 22 | environment: 23 | POSTGRES_DB: realworld-rust-actix-web 24 | POSTGRES_USER: postgres 25 | POSTGRES_PASSWORD: postgres 26 | volumes: 27 | .: 28 | -------------------------------------------------------------------------------- /src/app/features/tag/usecases.rs: -------------------------------------------------------------------------------- 1 | use super::presenters::TagPresenter; 2 | use super::repositories::TagRepository; 3 | use crate::error::AppError; 4 | use actix_web::HttpResponse; 5 | use std::sync::Arc; 6 | 7 | #[derive(Clone)] 8 | pub struct TagUsecase { 9 | tag_repository: Arc, 10 | tag_presenter: Arc, 11 | } 12 | 13 | impl TagUsecase { 14 | pub fn new( 15 | tag_repository: Arc, 16 | tag_presenter: Arc, 17 | ) -> Self { 18 | Self { 19 | tag_repository, 20 | tag_presenter, 21 | } 22 | } 23 | 24 | pub fn fetch_tags(&self) -> Result { 25 | let list = self.tag_repository.fetch_tags()?; 26 | let res = self.tag_presenter.to_json(list); 27 | Ok(res) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/features/favorite/controllers.rs: -------------------------------------------------------------------------------- 1 | use crate::app::drivers::middlewares::auth; 2 | use crate::app::drivers::middlewares::state::AppState; 3 | use crate::utils::api::ApiResponse; 4 | use actix_web::{web, HttpRequest}; 5 | 6 | type ArticleIdSlug = String; 7 | 8 | pub async fn favorite( 9 | state: web::Data, 10 | req: HttpRequest, 11 | path: web::Path, 12 | ) -> ApiResponse { 13 | let current_user = auth::get_current_user(&req)?; 14 | let article_title_slug = path.into_inner(); 15 | state 16 | .di_container 17 | .favorite_usecase 18 | .favorite_article(current_user, article_title_slug) 19 | } 20 | 21 | pub async fn unfavorite( 22 | state: web::Data, 23 | req: HttpRequest, 24 | path: web::Path, 25 | ) -> ApiResponse { 26 | let current_user = auth::get_current_user(&req)?; 27 | let article_title_slug = path.into_inner(); 28 | state 29 | .di_container 30 | .favorite_usecase 31 | .unfavorite_article(current_user, article_title_slug) 32 | } 33 | -------------------------------------------------------------------------------- /src/app/features/favorite/presenters.rs: -------------------------------------------------------------------------------- 1 | use super::entities::FavoriteInfo; 2 | use crate::app::features::article::entities::Article; 3 | pub use crate::app::features::article::presenters::SingleArticleResponse; 4 | use crate::app::features::profile::entities::Profile; 5 | use crate::app::features::tag::entities::Tag; 6 | use actix_web::HttpResponse; 7 | 8 | pub trait FavoritePresenter: Send + Sync + 'static { 9 | fn to_single_json(&self, item: (Article, Profile, FavoriteInfo, Vec)) -> HttpResponse; 10 | } 11 | 12 | #[derive(Clone)] 13 | pub struct FavoritePresenterImpl {} 14 | impl FavoritePresenterImpl { 15 | pub fn new() -> Self { 16 | Self {} 17 | } 18 | } 19 | impl FavoritePresenter for FavoritePresenterImpl { 20 | fn to_single_json( 21 | &self, 22 | (article, profile, favorite_info, tags_list): (Article, Profile, FavoriteInfo, Vec), 23 | ) -> HttpResponse { 24 | let res_model = SingleArticleResponse::from((article, profile, favorite_info, tags_list)); 25 | HttpResponse::Ok().json(res_model) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate diesel; 3 | 4 | #[macro_use] 5 | extern crate log; 6 | 7 | use actix_web::middleware::Logger; 8 | use actix_web::{App, HttpServer}; 9 | mod app; 10 | mod constants; 11 | mod error; 12 | mod schema; 13 | mod utils; 14 | 15 | #[actix_web::main] 16 | async fn main() -> std::io::Result<()> { 17 | println!("start conduit server..."); 18 | std::env::set_var("RUST_LOG", "actix_web=trace"); 19 | env_logger::init(); 20 | 21 | let state = { 22 | let pool = utils::db::establish_connection(); 23 | use app::drivers::middlewares::state::AppState; 24 | AppState::new(pool) 25 | }; 26 | 27 | HttpServer::new(move || { 28 | App::new() 29 | .wrap(Logger::default()) 30 | .app_data(actix_web::web::Data::new(state.clone())) 31 | .wrap(app::drivers::middlewares::cors::cors()) 32 | .wrap(app::drivers::middlewares::auth::Authentication) 33 | .configure(app::drivers::routes::api) 34 | }) 35 | .bind(constants::BIND)? 36 | .run() 37 | .await 38 | } 39 | -------------------------------------------------------------------------------- /src/app/features/tag/presenters.rs: -------------------------------------------------------------------------------- 1 | use super::entities::Tag; 2 | use actix_web::HttpResponse; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Deserialize, Serialize, Debug, Clone)] 6 | pub struct TagsResponse { 7 | // SPEC: https://gothinkster.github.io/realworld/docs/specs/backend-specs/endpoints#registration 8 | pub tags: Vec, 9 | } 10 | 11 | impl std::convert::From> for TagsResponse { 12 | fn from(tags: Vec) -> Self { 13 | let list = tags.iter().map(move |tag| tag.name.clone()).collect(); 14 | TagsResponse { tags: list } 15 | } 16 | } 17 | 18 | pub trait TagPresenter: Send + Sync + 'static { 19 | fn to_json(&self, list: Vec) -> HttpResponse; 20 | } 21 | 22 | #[derive(Clone)] 23 | pub struct TagPresenterImpl {} 24 | impl TagPresenterImpl { 25 | pub fn new() -> Self { 26 | Self {} 27 | } 28 | } 29 | impl TagPresenter for TagPresenterImpl { 30 | fn to_json(&self, list: Vec) -> HttpResponse { 31 | let res = TagsResponse::from(list); 32 | HttpResponse::Ok().json(res) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/features/profile/repositories.rs: -------------------------------------------------------------------------------- 1 | use super::entities::Profile; 2 | use crate::app::features::user::entities::User; 3 | use crate::error::AppError; 4 | use crate::utils::db::DbPool; 5 | 6 | pub trait ProfileRepository: Send + Sync + 'static { 7 | fn fetch_profile_by_name( 8 | &self, 9 | current_user: &User, 10 | username: &str, 11 | ) -> Result; 12 | } 13 | 14 | #[derive(Clone)] 15 | pub struct ProfileRepositoryImpl { 16 | pool: DbPool, 17 | } 18 | 19 | impl ProfileRepositoryImpl { 20 | pub fn new(pool: DbPool) -> Self { 21 | Self { pool } 22 | } 23 | } 24 | 25 | impl ProfileRepository for ProfileRepositoryImpl { 26 | fn fetch_profile_by_name( 27 | &self, 28 | current_user: &User, 29 | username: &str, 30 | ) -> Result { 31 | let conn = &mut self.pool.get()?; 32 | let profile = { 33 | let followee = User::find_by_username(conn, username)?; 34 | current_user.fetch_profile(conn, &followee.id)? 35 | }; 36 | Ok(profile) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Shun Namiki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /src/app/features/user/requests.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | pub struct Signup { 5 | // SPEC: https://gothinkster.github.io/realworld/docs/specs/backend-specs/endpoints#registration 6 | pub user: SignupUser, 7 | } 8 | 9 | #[derive(Deserialize, Serialize, Debug, Clone)] 10 | pub struct SignupUser { 11 | pub username: String, 12 | pub email: String, 13 | pub password: String, 14 | } 15 | 16 | #[derive(Deserialize, Serialize, Debug, Clone)] 17 | pub struct Signin { 18 | // SPEC: https://gothinkster.github.io/realworld/docs/specs/backend-specs/endpoints#authentication 19 | pub user: SigninUser, 20 | } 21 | 22 | #[derive(Deserialize, Serialize, Debug, Clone)] 23 | pub struct SigninUser { 24 | pub email: String, 25 | pub password: String, 26 | } 27 | 28 | #[derive(Deserialize, Serialize, Debug, Clone)] 29 | pub struct Update { 30 | // SPEC: https://gothinkster.github.io/realworld/docs/specs/backend-specs/endpoints#authentication 31 | pub user: UpdateUser, 32 | } 33 | 34 | #[derive(Deserialize, Serialize, Debug, Clone)] 35 | pub struct UpdateUser { 36 | pub email: Option, 37 | pub username: Option, 38 | pub password: Option, 39 | pub image: Option, 40 | pub bio: Option, 41 | } 42 | -------------------------------------------------------------------------------- /src/app/features/profile/controllers.rs: -------------------------------------------------------------------------------- 1 | use crate::app::drivers::middlewares::{auth, state::AppState}; 2 | use crate::utils::api::ApiResponse; 3 | use actix_web::{web, HttpRequest}; 4 | 5 | type UsernameSlug = String; 6 | 7 | pub async fn show( 8 | state: web::Data, 9 | req: HttpRequest, 10 | path: web::Path, 11 | ) -> ApiResponse { 12 | let current_user = auth::get_current_user(&req)?; 13 | let username = path.into_inner(); 14 | state 15 | .di_container 16 | .profile_usecase 17 | .fetch_profile_by_name(¤t_user, &username) 18 | } 19 | 20 | pub async fn follow( 21 | state: web::Data, 22 | req: HttpRequest, 23 | path: web::Path, 24 | ) -> ApiResponse { 25 | let current_user = auth::get_current_user(&req)?; 26 | let target_username = path.into_inner(); 27 | state 28 | .di_container 29 | .profile_usecase 30 | .follow_user(¤t_user, &target_username) 31 | } 32 | 33 | pub async fn unfollow( 34 | state: web::Data, 35 | req: HttpRequest, 36 | path: web::Path, 37 | ) -> ApiResponse { 38 | let current_user = auth::get_current_user(&req)?; 39 | let target_username = path.into_inner(); 40 | state 41 | .di_container 42 | .profile_usecase 43 | .unfollow_user(¤t_user, &target_username) 44 | } 45 | -------------------------------------------------------------------------------- /src/app/features/profile/presenters.rs: -------------------------------------------------------------------------------- 1 | use super::entities::Profile as ProfileModel; 2 | use actix_web::HttpResponse; 3 | use serde::{Deserialize, Serialize}; 4 | use std::convert::From; 5 | 6 | #[derive(Deserialize, Serialize, Debug, Clone)] 7 | pub struct ProfileResponse { 8 | pub profile: ProfileContent, 9 | } 10 | 11 | #[derive(Deserialize, Serialize, Debug, Clone)] 12 | pub struct ProfileContent { 13 | pub username: String, 14 | pub bio: Option, 15 | pub image: Option, 16 | pub following: bool, 17 | } 18 | 19 | impl From for ProfileResponse { 20 | fn from(profile_model: ProfileModel) -> Self { 21 | let profile = ProfileContent { 22 | username: profile_model.username, 23 | bio: profile_model.bio, 24 | image: profile_model.image, 25 | following: profile_model.following, 26 | }; 27 | ProfileResponse { profile } 28 | } 29 | } 30 | 31 | pub trait ProfilePresenter: Send + Sync + 'static { 32 | fn to_json(&self, model: ProfileModel) -> HttpResponse; 33 | } 34 | 35 | #[derive(Clone)] 36 | pub struct ProfilePresenterImpl {} 37 | impl ProfilePresenterImpl { 38 | pub fn new() -> Self { 39 | Self {} 40 | } 41 | } 42 | impl ProfilePresenter for ProfilePresenterImpl { 43 | fn to_json(&self, model: ProfileModel) -> HttpResponse { 44 | let res_model = ProfileResponse::from(model); 45 | HttpResponse::Ok().json(res_model) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/features/user/controllers.rs: -------------------------------------------------------------------------------- 1 | use super::entities::UpdateUser; 2 | use super::requests; 3 | use crate::app::drivers::middlewares::auth; 4 | use crate::app::drivers::middlewares::state::AppState; 5 | use crate::utils::api::ApiResponse; 6 | use actix_web::{web, HttpRequest}; 7 | 8 | pub async fn signin(state: web::Data, form: web::Json) -> ApiResponse { 9 | state 10 | .di_container 11 | .user_usecase 12 | .signin(&form.user.email, &form.user.password) 13 | } 14 | 15 | pub async fn signup(state: web::Data, form: web::Json) -> ApiResponse { 16 | state.di_container.user_usecase.signup( 17 | &form.user.email, 18 | &form.user.username, 19 | &form.user.password, 20 | ) 21 | } 22 | 23 | pub async fn me(state: web::Data, req: HttpRequest) -> ApiResponse { 24 | let current_user = auth::get_current_user(&req)?; 25 | state.di_container.user_usecase.get_token(¤t_user) 26 | } 27 | 28 | pub async fn update( 29 | state: web::Data, 30 | req: HttpRequest, 31 | form: web::Json, 32 | ) -> ApiResponse { 33 | let current_user = auth::get_current_user(&req)?; 34 | state.di_container.user_usecase.update_user( 35 | current_user.id, 36 | UpdateUser { 37 | email: form.user.email.clone(), 38 | username: form.user.username.clone(), 39 | password: form.user.password.clone(), 40 | image: form.user.image.clone(), 41 | bio: form.user.bio.clone(), 42 | }, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/app/features/comment/controllers.rs: -------------------------------------------------------------------------------- 1 | use super::request; 2 | use crate::app::drivers::middlewares::auth; 3 | use crate::app::drivers::middlewares::state::AppState; 4 | use crate::utils::api::ApiResponse; 5 | use crate::utils::uuid; 6 | use actix_web::{web, HttpRequest}; 7 | 8 | type ArticleIdSlug = String; 9 | type CommentIdSlug = String; 10 | 11 | pub async fn index(state: web::Data, req: HttpRequest) -> ApiResponse { 12 | let current_user = auth::get_current_user(&req).ok(); 13 | state 14 | .di_container 15 | .comment_usecase 16 | .fetch_comments(¤t_user) 17 | } 18 | 19 | pub async fn create( 20 | state: web::Data, 21 | req: HttpRequest, 22 | path: web::Path, 23 | form: web::Json, 24 | ) -> ApiResponse { 25 | let current_user = auth::get_current_user(&req)?; 26 | let article_title_slug = path.into_inner(); 27 | let body = form.comment.body.to_owned(); 28 | state 29 | .di_container 30 | .comment_usecase 31 | .create_comment(body, article_title_slug, current_user) 32 | } 33 | 34 | pub async fn delete( 35 | state: web::Data, 36 | req: HttpRequest, 37 | path: web::Path<(ArticleIdSlug, CommentIdSlug)>, 38 | ) -> ApiResponse { 39 | let current_user = auth::get_current_user(&req)?; 40 | let (article_title_slug, comment_id) = path.into_inner(); 41 | let comment_id = uuid::parse(&comment_id)?; 42 | state.di_container.comment_usecase.delete_comment( 43 | &article_title_slug, 44 | comment_id, 45 | current_user.id, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/token.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::env_key; 2 | use jsonwebtoken::{errors::Error, DecodingKey, EncodingKey, Header, TokenData, Validation}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::env; 5 | use uuid::Uuid; 6 | 7 | static ONE_DAY: i64 = 60 * 60 * 24; // in seconds 8 | 9 | fn get_secret_key() -> String { 10 | env::var(env_key::SECRET_KEY).expect("SECRET_KEY must be set") 11 | } 12 | 13 | pub fn decode(token: &str) -> jsonwebtoken::errors::Result> { 14 | let binding = get_secret_key(); 15 | let secret_key = binding.as_bytes(); 16 | jsonwebtoken::decode::( 17 | token, 18 | &DecodingKey::from_secret(secret_key), 19 | &Validation::default(), 20 | ) 21 | } 22 | 23 | pub fn generate(user_id: Uuid, now: i64) -> Result { 24 | let claims = Claims::new(user_id, now); 25 | let binding = get_secret_key(); 26 | let secret_key = binding.as_bytes(); 27 | jsonwebtoken::encode( 28 | &Header::default(), 29 | &claims, 30 | &EncodingKey::from_secret(secret_key), 31 | ) 32 | } 33 | 34 | #[derive(Debug, Serialize, Deserialize)] 35 | pub struct Claims { 36 | // aud: String, // Optional. Audience 37 | exp: i64, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp) 38 | iat: i64, // Optional. Issued at (as UTC timestamp) 39 | // iss: String, // Optional. Issuer 40 | // nbf: usize, // Optional. Not Before (as UTC timestamp) 41 | // sub: String, // Optional. Subject (whom token refers to) 42 | // --- 43 | pub user_id: Uuid, 44 | } 45 | 46 | impl Claims { 47 | pub fn new(user_id: Uuid, now: i64) -> Self { 48 | Claims { 49 | iat: now, 50 | exp: now + ONE_DAY, 51 | user_id, 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/features/comment/usecases.rs: -------------------------------------------------------------------------------- 1 | use super::presenters::CommentPresenter; 2 | use super::repositories::CommentRepository; 3 | use crate::app::features::user::entities::User; 4 | use crate::error::AppError; 5 | use actix_web::HttpResponse; 6 | use std::sync::Arc; 7 | use uuid::Uuid; 8 | 9 | #[derive(Clone)] 10 | pub struct CommentUsecase { 11 | comment_repository: Arc, 12 | comment_presenter: Arc, 13 | } 14 | 15 | impl CommentUsecase { 16 | pub fn new( 17 | comment_repository: Arc, 18 | comment_presenter: Arc, 19 | ) -> Self { 20 | Self { 21 | comment_repository, 22 | comment_presenter, 23 | } 24 | } 25 | 26 | pub fn fetch_comments(&self, user: &Option) -> Result { 27 | let result = self.comment_repository.fetch_comments(user)?; 28 | let res = self.comment_presenter.to_multi_json(result); 29 | Ok(res) 30 | } 31 | 32 | pub fn create_comment( 33 | &self, 34 | body: String, 35 | article_title_slug: String, 36 | author: User, 37 | ) -> Result { 38 | let result = self 39 | .comment_repository 40 | .create_comment(body, article_title_slug, author)?; 41 | let res = self.comment_presenter.to_single_json(result); 42 | Ok(res) 43 | } 44 | 45 | pub fn delete_comment( 46 | &self, 47 | article_title_slug: &str, 48 | comment_id: Uuid, 49 | author_id: Uuid, 50 | ) -> Result { 51 | let _ = self 52 | .comment_repository 53 | .delete_comment(article_title_slug, comment_id, author_id); 54 | let res = self.comment_presenter.to_http_res(); 55 | Ok(res) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/features/user/presenters.rs: -------------------------------------------------------------------------------- 1 | use crate::{app::features::user::entities::User, error::AppError}; 2 | use actix_web::HttpResponse; 3 | use serde::{Deserialize, Serialize}; 4 | use std::convert::From; 5 | 6 | #[derive(Deserialize, Serialize, Debug, Clone)] 7 | pub struct UserResponse { 8 | pub user: AuthUser, 9 | } 10 | 11 | impl From<(User, String)> for UserResponse { 12 | fn from((user, token): (User, String)) -> Self { 13 | // REF: https://gothinkster.github.io/realworld/docs/specs/backend-specs/api-response-format/#users-for-authentication 14 | Self { 15 | user: AuthUser { 16 | email: user.email, 17 | token, 18 | username: user.username, 19 | bio: user.bio, 20 | image: user.image, 21 | }, 22 | } 23 | } 24 | } 25 | 26 | #[derive(Deserialize, Serialize, Debug, Clone)] 27 | pub struct AuthUser { 28 | pub email: String, 29 | pub token: String, 30 | pub username: String, 31 | pub bio: Option, 32 | pub image: Option, 33 | } 34 | 35 | pub trait UserPresenter: Send + Sync + 'static { 36 | fn to_json(&self, user: User, token: String) -> HttpResponse; 37 | fn to_auth_middleware(&self, maybe_uesr: Result) -> Result; 38 | } 39 | 40 | #[derive(Clone)] 41 | pub struct UserPresenterImpl {} 42 | impl UserPresenterImpl { 43 | pub fn new() -> Self { 44 | Self {} 45 | } 46 | } 47 | impl UserPresenter for UserPresenterImpl { 48 | fn to_json(&self, user: User, token: String) -> HttpResponse { 49 | let res_model = UserResponse::from((user, token)); 50 | HttpResponse::Ok().json(res_model) 51 | } 52 | 53 | fn to_auth_middleware(&self, maybe_user: Result) -> Result { 54 | maybe_user.map_err(|_err| "Cannot find auth user") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/features/profile/usecases.rs: -------------------------------------------------------------------------------- 1 | use super::presenters::ProfilePresenter; 2 | use super::repositories::ProfileRepository; 3 | use crate::app::features::user::entities::User; 4 | use crate::app::features::user::repositories::UserRepository; 5 | use crate::error::AppError; 6 | use actix_web::HttpResponse; 7 | use std::sync::Arc; 8 | 9 | #[derive(Clone)] 10 | pub struct ProfileUsecase { 11 | user_repository: Arc, 12 | profile_repository: Arc, 13 | presenter: Arc, 14 | } 15 | 16 | impl ProfileUsecase { 17 | pub fn new( 18 | profile_repository: Arc, 19 | user_repository: Arc, 20 | presenter: Arc, 21 | ) -> Self { 22 | Self { 23 | profile_repository, 24 | user_repository, 25 | presenter, 26 | } 27 | } 28 | 29 | pub fn fetch_profile_by_name( 30 | &self, 31 | current_user: &User, 32 | username: &str, 33 | ) -> Result { 34 | let profile = self 35 | .profile_repository 36 | .fetch_profile_by_name(current_user, username)?; 37 | Ok(self.presenter.to_json(profile)) 38 | } 39 | 40 | pub fn follow_user( 41 | &self, 42 | current_user: &User, 43 | target_username: &str, 44 | ) -> Result { 45 | let profile = self 46 | .user_repository 47 | .follow_user(current_user, target_username)?; 48 | Ok(self.presenter.to_json(profile)) 49 | } 50 | 51 | pub fn unfollow_user( 52 | &self, 53 | current_user: &User, 54 | target_username: &str, 55 | ) -> Result { 56 | let profile = self 57 | .user_repository 58 | .unfollow_user(current_user, target_username)?; 59 | Ok(self.presenter.to_json(profile)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/schema.rs: -------------------------------------------------------------------------------- 1 | // @generated automatically by Diesel CLI. 2 | 3 | diesel::table! { 4 | articles (id) { 5 | id -> Uuid, 6 | author_id -> Uuid, 7 | slug -> Text, 8 | title -> Text, 9 | description -> Text, 10 | body -> Text, 11 | created_at -> Timestamp, 12 | updated_at -> Timestamp, 13 | } 14 | } 15 | 16 | diesel::table! { 17 | comments (id) { 18 | id -> Uuid, 19 | article_id -> Uuid, 20 | author_id -> Uuid, 21 | body -> Text, 22 | create_at -> Timestamp, 23 | updated_at -> Timestamp, 24 | } 25 | } 26 | 27 | diesel::table! { 28 | favorites (id) { 29 | id -> Uuid, 30 | article_id -> Uuid, 31 | user_id -> Uuid, 32 | created_at -> Timestamp, 33 | updated_at -> Timestamp, 34 | } 35 | } 36 | 37 | diesel::table! { 38 | follows (follower_id, followee_id) { 39 | followee_id -> Uuid, 40 | follower_id -> Uuid, 41 | created_at -> Timestamp, 42 | updated_at -> Timestamp, 43 | } 44 | } 45 | 46 | diesel::table! { 47 | tags (id) { 48 | id -> Uuid, 49 | article_id -> Uuid, 50 | name -> Text, 51 | created_at -> Timestamp, 52 | updated_at -> Timestamp, 53 | } 54 | } 55 | 56 | diesel::table! { 57 | users (id) { 58 | id -> Uuid, 59 | email -> Text, 60 | username -> Text, 61 | password -> Text, 62 | bio -> Nullable, 63 | image -> Nullable, 64 | created_at -> Timestamp, 65 | updated_at -> Timestamp, 66 | } 67 | } 68 | 69 | diesel::joinable!(articles -> users (author_id)); 70 | diesel::joinable!(comments -> articles (article_id)); 71 | diesel::joinable!(comments -> users (author_id)); 72 | diesel::joinable!(favorites -> articles (article_id)); 73 | diesel::joinable!(favorites -> users (user_id)); 74 | diesel::joinable!(tags -> articles (article_id)); 75 | 76 | diesel::allow_tables_to_appear_in_same_query!( 77 | articles, 78 | comments, 79 | favorites, 80 | follows, 81 | tags, 82 | users, 83 | ); 84 | -------------------------------------------------------------------------------- /src/app/features/user/usecases.rs: -------------------------------------------------------------------------------- 1 | use super::entities::{UpdateUser, User}; 2 | use super::presenters::UserPresenter; 3 | use super::repositories::UserRepository; 4 | use crate::error::AppError; 5 | use actix_web::HttpResponse; 6 | use std::sync::Arc; 7 | use uuid::Uuid; 8 | 9 | #[derive(Clone)] 10 | pub struct UserUsecase { 11 | user_repository: Arc, 12 | user_presenter: Arc, 13 | } 14 | 15 | impl UserUsecase { 16 | pub fn new( 17 | user_repository: Arc, 18 | user_presenter: Arc, 19 | ) -> Self { 20 | Self { 21 | user_repository, 22 | user_presenter, 23 | } 24 | } 25 | 26 | pub fn signin(&self, email: &str, password: &str) -> Result { 27 | let (user, token) = self.user_repository.signin(email, password)?; 28 | let res = self.user_presenter.to_json(user, token); 29 | Ok(res) 30 | } 31 | 32 | pub fn signup( 33 | &self, 34 | email: &str, 35 | username: &str, 36 | password: &str, 37 | ) -> Result { 38 | let (user, token) = self.user_repository.signup(email, username, password)?; 39 | let res = self.user_presenter.to_json(user, token); 40 | Ok(res) 41 | } 42 | 43 | pub fn get_token(&self, current_user: &User) -> Result { 44 | let token = current_user.generate_token()?; 45 | let res = self.user_presenter.to_json(current_user.clone(), token); 46 | Ok(res) 47 | } 48 | 49 | pub fn update_user( 50 | &self, 51 | user_id: Uuid, 52 | changeset: UpdateUser, 53 | ) -> Result { 54 | let (new_user, token) = self.user_repository.update(user_id, changeset)?; 55 | let res = self.user_presenter.to_json(new_user, token); 56 | Ok(res) 57 | } 58 | 59 | pub fn find_auth_user(&self, user_id: Uuid) -> Result { 60 | let maybe_user = self.user_repository.find(user_id); 61 | self.user_presenter.to_auth_middleware(maybe_user) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/features/favorite/usecases.rs: -------------------------------------------------------------------------------- 1 | use super::presenters::FavoritePresenter; 2 | use super::repositories::FavoriteRepository; 3 | use crate::app::features::article::repositories::{ArticleRepository, FetchArticleRepositoryInput}; 4 | use crate::app::features::user::entities::User; 5 | use crate::error::AppError; 6 | use actix_web::HttpResponse; 7 | use std::sync::Arc; 8 | 9 | #[derive(Clone)] 10 | pub struct FavoriteUsecase { 11 | favorite_repository: Arc, 12 | favorite_presenter: Arc, 13 | article_repository: Arc, 14 | } 15 | 16 | impl FavoriteUsecase { 17 | pub fn new( 18 | favorite_repository: Arc, 19 | favorite_presenter: Arc, 20 | article_repository: Arc, 21 | ) -> Self { 22 | Self { 23 | favorite_repository, 24 | favorite_presenter, 25 | article_repository, 26 | } 27 | } 28 | 29 | pub fn favorite_article( 30 | &self, 31 | user: User, 32 | article_title_slug: String, 33 | ) -> Result { 34 | let article = self 35 | .favorite_repository 36 | .favorite_article(user.clone(), article_title_slug)?; 37 | 38 | let result = self 39 | .article_repository 40 | .fetch_article(&FetchArticleRepositoryInput { 41 | article_id: article.id, 42 | current_user: user, 43 | })?; 44 | let res = self.favorite_presenter.to_single_json(result); 45 | Ok(res) 46 | } 47 | 48 | pub fn unfavorite_article( 49 | &self, 50 | user: User, 51 | article_title_slug: String, 52 | ) -> Result { 53 | let article = self 54 | .favorite_repository 55 | .unfavorite_article(user.clone(), article_title_slug)?; 56 | 57 | let result = self 58 | .article_repository 59 | .fetch_article(&FetchArticleRepositoryInput { 60 | article_id: article.id, 61 | current_user: user, 62 | })?; 63 | 64 | let res = self.favorite_presenter.to_single_json(result); 65 | Ok(res) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/features/favorite/repositories.rs: -------------------------------------------------------------------------------- 1 | use super::entities::{CreateFavorite, DeleteFavorite, Favorite}; 2 | use crate::app::features::article::entities::{Article, FetchBySlugAndAuthorId}; 3 | use crate::app::features::user::entities::User; 4 | use crate::error::AppError; 5 | use crate::utils::db::DbPool; 6 | 7 | pub trait FavoriteRepository: Send + Sync + 'static { 8 | fn favorite_article(&self, user: User, article_title_slug: String) 9 | -> Result; 10 | fn unfavorite_article( 11 | &self, 12 | user: User, 13 | article_title_slug: String, 14 | ) -> Result; 15 | } 16 | 17 | #[derive(Clone)] 18 | pub struct FavoriteRepositoryImpl { 19 | pool: DbPool, 20 | } 21 | 22 | impl FavoriteRepositoryImpl { 23 | pub fn new(pool: DbPool) -> Self { 24 | Self { pool } 25 | } 26 | } 27 | impl FavoriteRepository for FavoriteRepositoryImpl { 28 | fn favorite_article( 29 | &self, 30 | user: User, 31 | article_title_slug: String, 32 | ) -> Result { 33 | let conn = &mut self.pool.get()?; 34 | 35 | let article = Article::fetch_by_slug_and_author_id( 36 | conn, 37 | &FetchBySlugAndAuthorId { 38 | slug: article_title_slug, 39 | author_id: user.id, 40 | }, 41 | )?; 42 | Favorite::create( 43 | conn, 44 | &CreateFavorite { 45 | user_id: user.id, 46 | article_id: article.id, 47 | }, 48 | )?; 49 | 50 | Ok(article) 51 | } 52 | 53 | fn unfavorite_article( 54 | &self, 55 | user: User, 56 | article_title_slug: String, 57 | ) -> Result { 58 | let conn = &mut self.pool.get()?; 59 | let article = Article::fetch_by_slug_and_author_id( 60 | conn, 61 | &FetchBySlugAndAuthorId { 62 | slug: article_title_slug, 63 | author_id: user.id, 64 | }, 65 | )?; 66 | Favorite::delete( 67 | conn, 68 | &DeleteFavorite { 69 | user_id: user.id, 70 | article_id: article.id, 71 | }, 72 | )?; 73 | Ok(article) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/app/features/follow/entities.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::user::entities::User; 2 | use crate::error::AppError; 3 | use crate::schema::follows; 4 | use chrono::NaiveDateTime; 5 | use diesel::dsl::Eq; 6 | use diesel::pg::PgConnection; 7 | use diesel::prelude::*; 8 | use serde::{Deserialize, Serialize}; 9 | use uuid::Uuid; 10 | 11 | #[derive(Queryable, Associations, Clone, Serialize, Deserialize)] 12 | #[diesel(belongs_to(User, foreign_key = followee_id, foreign_key = follower_id))] 13 | #[diesel(table_name = follows)] 14 | pub struct Follow { 15 | pub followee_id: Uuid, 16 | pub follower_id: Uuid, 17 | pub created_at: NaiveDateTime, 18 | pub updated_at: NaiveDateTime, 19 | } 20 | 21 | type WithFollowee = Eq; 22 | type WithFollower = Eq; 23 | 24 | impl Follow { 25 | pub fn with_followee(followee_id: &Uuid) -> WithFollowee<&Uuid> { 26 | follows::followee_id.eq(followee_id) 27 | } 28 | 29 | pub fn with_follower(follower_id: &Uuid) -> WithFollower<&Uuid> { 30 | follows::follower_id.eq(follower_id) 31 | } 32 | } 33 | 34 | impl Follow { 35 | pub fn create(conn: &mut PgConnection, params: &CreateFollow) -> Result<(), AppError> { 36 | diesel::insert_into(follows::table) 37 | .values(params) 38 | .execute(conn)?; 39 | Ok(()) 40 | } 41 | 42 | pub fn delete(conn: &mut PgConnection, params: &DeleteFollow) -> Result<(), AppError> { 43 | let t = follows::table 44 | .filter(Follow::with_followee(¶ms.followee_id)) 45 | .filter(Follow::with_follower(¶ms.follower_id)); 46 | diesel::delete(t).execute(conn)?; 47 | Ok(()) 48 | } 49 | 50 | pub fn fetch_folowee_ids_by_follower_id( 51 | conn: &mut PgConnection, 52 | follower_id: &Uuid, 53 | ) -> Result, AppError> { 54 | let t = follows::table 55 | .filter(Follow::with_follower(follower_id)) 56 | .select(follows::followee_id); 57 | let result = t.get_results::(conn)?; 58 | Ok(result) 59 | } 60 | } 61 | 62 | #[derive(Insertable)] 63 | #[diesel(table_name = follows)] 64 | pub struct CreateFollow { 65 | pub follower_id: Uuid, 66 | pub followee_id: Uuid, 67 | } 68 | 69 | pub struct DeleteFollow { 70 | pub follower_id: Uuid, 71 | pub followee_id: Uuid, 72 | } 73 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "conduit" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["Shun Namiki a.k.a Nash "] 6 | license = "MIT" 7 | repository = "https://github.com/snamiki1212/realworld-rust-actix-web" 8 | readme = "README.md" 9 | description = "Realworld Application with Rust / actix-web / diesel." 10 | 11 | [dependencies] 12 | 13 | # Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust 14 | actix-web = { version = "4.3" } 15 | 16 | # A safe, extensible ORM and Query Builder for PostgreSQL, SQLite, and MySQL 17 | diesel = { version = "2.1.0", features = [ 18 | "r2d2", 19 | "postgres", 20 | "chrono", 21 | "uuid", 22 | "serde_json", 23 | ] } 24 | 25 | # Create and decode JWTs in a strongly typed way. 26 | jsonwebtoken = { version = "8.3" } 27 | 28 | # A generic serialization/deserialization framework 29 | serde = { version = "1.0", features = ["derive"] } 30 | 31 | # A JSON serialization file format 32 | serde_json = { version = "1.0" } 33 | 34 | # A `dotenv` implementation for Rust 35 | dotenv = { version = "0.15" } 36 | 37 | # Date and time library for Rust 38 | chrono = { version = "0.4", features = ["serde"] } 39 | 40 | # Easily hash and verify passwords using bcrypt 41 | bcrypt = { version = "0.14.0" } 42 | 43 | # A lightweight logging facade for Rust 44 | log = { version = "0.4.17" } 45 | 46 | # A logging implementation for `log` which is configured via an environment variable. 47 | env_logger = { version = "0.10.0" } 48 | 49 | # Flexible concrete Error type built on std::error::Error 50 | anyhow = { version = "1.0" } 51 | 52 | # derive(Error) 53 | thiserror = { version = "1.0" } 54 | 55 | # An implementation of futures and streams featuring zero allocations, composability, and itera… 56 | futures = { version = "0.3" } 57 | 58 | # Service trait and combinators for representing asynchronous request/response operati… 59 | # actix-service = { version = "2" } 60 | 61 | # Cross-Origin Resource Sharing (CORS) controls for Actix Web 62 | actix-cors = { version = "0.6.4" } 63 | 64 | # Convert strings into any case 65 | convert_case = { version = "0.6.0" } 66 | 67 | # A library to generate and parse UUIDs. 68 | # Compatible version is here: https://github.com/diesel-rs/diesel/blob/master/diesel/Cargo.toml#L26 69 | # uuid = { version = "0.8", features = ["serde", "v4"] } 70 | [dependencies.uuid] 71 | version = "1.3.3" 72 | features = ["serde", "v4"] 73 | -------------------------------------------------------------------------------- /src/app/features/comment/entities.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::article::entities::Article; 2 | use crate::app::features::user::entities::User; 3 | use crate::error::AppError; 4 | use crate::schema::comments; 5 | use chrono::NaiveDateTime; 6 | use diesel::dsl::Eq; 7 | use diesel::pg::PgConnection; 8 | use diesel::prelude::*; 9 | use serde::{Deserialize, Serialize}; 10 | use uuid::Uuid; 11 | 12 | #[derive(Identifiable, Deserialize, Serialize, Queryable, Associations, Debug, Clone)] 13 | #[diesel(belongs_to(User, foreign_key = author_id))] 14 | #[diesel(belongs_to(Article, foreign_key = article_id))] 15 | #[diesel(table_name = comments)] 16 | pub struct Comment { 17 | pub id: Uuid, 18 | pub article_id: Uuid, 19 | pub author_id: Uuid, 20 | pub body: String, 21 | pub created_at: NaiveDateTime, 22 | pub updated_at: NaiveDateTime, 23 | } 24 | 25 | type WithId = Eq; 26 | type WithAuthor = Eq; 27 | 28 | impl Comment { 29 | fn with_id(id: &Uuid) -> WithId<&Uuid> { 30 | comments::id.eq(id) 31 | } 32 | fn with_author(author_id: &Uuid) -> WithAuthor<&Uuid> { 33 | comments::author_id.eq(author_id) 34 | } 35 | } 36 | 37 | impl Comment { 38 | pub fn create(conn: &mut PgConnection, record: &CreateComment) -> Result { 39 | let new_comment = diesel::insert_into(comments::table) 40 | .values(record) 41 | .get_result::(conn)?; 42 | Ok(new_comment) 43 | } 44 | 45 | pub fn delete( 46 | conn: &mut PgConnection, 47 | (comment_id, author_id, slug): (&Uuid, &Uuid, &str), 48 | ) -> Result<(), AppError> { 49 | let subquery = { 50 | use crate::schema::articles; 51 | articles::table 52 | .filter(articles::slug.eq(slug)) 53 | .filter(articles::author_id.eq(author_id)) 54 | .select(articles::id) 55 | }; 56 | 57 | let query = comments::table 58 | .filter(Self::with_id(comment_id)) 59 | .filter(Self::with_author(author_id)) 60 | .filter(comments::article_id.eq_any(subquery)); 61 | 62 | diesel::delete(query).execute(conn)?; 63 | Ok(()) 64 | } 65 | } 66 | 67 | #[derive(Insertable, Clone)] 68 | #[diesel(table_name = comments)] 69 | pub struct CreateComment { 70 | pub body: String, 71 | pub author_id: Uuid, 72 | pub article_id: Uuid, 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | header 2 | 3 | badge 4 | 5 | # Overview 6 | 7 | Realworld App using `Rust`, `actix-web`, and `diesel`. 8 | 9 | ## Getting Started 10 | 11 | ```zsh 12 | # ready 13 | $ sh ./scripts/copy-env.sh 14 | 15 | # start 16 | $ docker compose up -d 17 | 18 | # healthcheck 19 | $ curl http://localhost:8080/api/healthcheck 20 | # => OK 21 | ``` 22 | 23 | ```sh 24 | # Check app can connect to DB 25 | $ curl http://localhost:8080/api/tags 26 | # => {"tags":[]} 27 | 28 | # Check app can insert data into DB 29 | curl -X POST http://localhost:8080/api/users -d '{"user": {"email": "a@a.a", "username": "a", "password": "a" }}' -H "Content-Type: application/json" 30 | ``` 31 | 32 | ## E2E Test 33 | 34 | Running E2E tests using [POSTMAN scripts](https://github.com/gothinkster/realworld/tree/main/api) on CI 35 | 36 | ```zsh 37 | # run e2e 38 | $ APIURL=http://localhost:8080/api zsh e2e/run-api-tests.sh 39 | ``` 40 | 41 | ## Tech Stacks 42 | 43 | - Rust Edition 2021 44 | - ActixWeb 4.x 45 | - Diesel 2.x 46 | 47 | ## Architecture 48 | 49 | - Clean Architecture 50 | - DI container using Constructor Injection with dynamic dispatch ([src/utils/di.rs](https://github.com/snamiki1212/realworld-v1-rust-actix-web-diesel/blob/main/src/utils/di.rs)) 51 | 52 | ```mermaid 53 | flowchart TD 54 | Client(("Client")) 55 | Route["Middleware + Route

/src/app/drivers/{middlewares, route}"] 56 | Controller["Controller

/src/app/features/[feature]/controllers.rs"] 57 | Presenter["Presenter

/src/app/features/[feature]/presenters.rs"] 58 | Usecase["Usecase

/src/app/features/[feature]/usecases.rs"] 59 | Repository["Repository

/src/app/features/[feature]/repositories.rs"] 60 | Entity["Entity

/src/app/features/[feature]/entities.rs"] 61 | DB[(Database)] 62 | 63 | %% Top to Bottom 64 | Client --Request--> Route 65 | Route --> Controller 66 | Controller --> Usecase 67 | Usecase --> Repository 68 | Repository --> Entity 69 | Entity --> DB 70 | 71 | %% Bottom to Top 72 | DB -.-> Entity 73 | Entity -.-> Repository 74 | Repository -.-> Usecase 75 | Usecase -.-> Presenter 76 | Presenter -.Response.-> Client 77 | ``` 78 | 79 | ## LICENSE 80 | 81 | MIT 82 | -------------------------------------------------------------------------------- /src/app/features/favorite/entities.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::article::entities::Article; 2 | use crate::app::features::user::entities::User; 3 | use crate::error::AppError; 4 | use crate::schema::favorites; 5 | use chrono::NaiveDateTime; 6 | use diesel::dsl::Eq; 7 | use diesel::*; 8 | use serde::{Deserialize, Serialize}; 9 | use uuid::Uuid; 10 | 11 | #[derive(Serialize, Deserialize, Queryable, Identifiable, Associations, Clone, Debug)] 12 | #[diesel(belongs_to(Article, foreign_key = article_id))] 13 | #[diesel(belongs_to(User, foreign_key = user_id))] 14 | #[diesel(table_name = favorites)] 15 | pub struct Favorite { 16 | pub id: Uuid, 17 | pub article_id: Uuid, 18 | pub user_id: Uuid, 19 | pub created_at: NaiveDateTime, 20 | pub updated_at: NaiveDateTime, 21 | } 22 | 23 | type WithUserId = Eq; 24 | type WithArticleId = Eq; 25 | 26 | impl Favorite { 27 | pub fn with_user_id(user_id: &Uuid) -> WithUserId<&Uuid> { 28 | favorites::user_id.eq_all(user_id) 29 | } 30 | 31 | pub fn with_article_id(article_id: &Uuid) -> WithArticleId<&Uuid> { 32 | favorites::article_id.eq_all(article_id) 33 | } 34 | } 35 | 36 | impl Favorite { 37 | pub fn create(conn: &mut PgConnection, record: &CreateFavorite) -> Result { 38 | let item = diesel::insert_into(favorites::table) 39 | .values(record) 40 | .execute(conn)?; 41 | Ok(item) 42 | } 43 | 44 | pub fn delete( 45 | conn: &mut PgConnection, 46 | DeleteFavorite { 47 | user_id, 48 | article_id, 49 | }: &DeleteFavorite, 50 | ) -> Result { 51 | let t = favorites::table 52 | .filter(Self::with_user_id(user_id)) 53 | .filter(Self::with_article_id(article_id)); 54 | let item = diesel::delete(t).execute(conn)?; 55 | Ok(item) 56 | } 57 | 58 | pub fn fetch_favorited_article_ids_by_username( 59 | conn: &mut PgConnection, 60 | username: &str, 61 | ) -> Result, AppError> { 62 | use crate::schema::users; 63 | let t = favorites::table 64 | .inner_join(users::table) 65 | .filter(User::with_username(username)) 66 | .select(favorites::article_id); 67 | let ids = t.load::(conn)?; 68 | Ok(ids) 69 | } 70 | } 71 | 72 | #[derive(Insertable)] 73 | #[diesel(table_name = favorites)] 74 | pub struct CreateFavorite { 75 | pub user_id: Uuid, 76 | pub article_id: Uuid, 77 | } 78 | 79 | pub struct DeleteFavorite { 80 | pub user_id: Uuid, 81 | pub article_id: Uuid, 82 | } 83 | 84 | #[derive(Clone)] 85 | pub struct FavoriteInfo { 86 | pub is_favorited: bool, 87 | pub favorites_count: i64, 88 | } 89 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | clippy_check: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Ready env file 18 | run: sh ./scripts/copy-env.sh 19 | - name: Install Clippy 20 | run: rustup component add clippy 21 | - name: Set Toolchain 22 | uses: dtolnay/rust-toolchain@stable 23 | with: 24 | components: rustfmt, clippy 25 | # - name: Rust format 26 | # run: cargo fmt --all -- --check 27 | - name: Run lint 28 | run: cargo clippy -- -D warnings 29 | 30 | test_unit: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Check out repo 34 | uses: actions/checkout@v2 35 | - name: Ready env file 36 | run: sh ./scripts/copy-env.sh 37 | - name: Build 38 | run: cargo build --verbose 39 | - name: Run tests 40 | run: cargo test --verbose 41 | 42 | test_e2e: 43 | runs-on: ubuntu-latest 44 | services: 45 | postgres: 46 | image: postgres:latest 47 | env: 48 | POSTGRES_DB: realworld-rust-actix-web 49 | POSTGRES_PASSWORD: postgres 50 | POSTGRES_USER: postgres 51 | ports: 52 | - 5432:5432 53 | steps: 54 | - name: Check out repo 55 | uses: actions/checkout@v2 56 | - name: Ready env file 57 | run: sh ./scripts/copy-env.sh 58 | - name: Build 59 | run: cargo build --verbose 60 | - name: Install diesel CLI 61 | run: cargo install diesel_cli 62 | - name: Run server on background 63 | run: | 64 | cargo run & 65 | echo "waiting for server..." 66 | ./scripts/wait-for-it.sh 0.0.0.0:8080 --timeout=300 --strict -- echo "Waked up container" 67 | echo "setup diesel..." 68 | diesel setup 69 | echo "check health check..." 70 | curl http://0.0.0.0:8080/api/healthcheck \ 71 | --max-time 60 \ 72 | --verbose \ 73 | --retry 5 \ 74 | --retry-delay 0 \ 75 | --retry-connrefused 76 | echo "running e2e..." 77 | APIURL=http://localhost:8080/api sh ./e2e/run-api-tests.sh 78 | # - name: Wait for waking server 79 | # run: ./wait-for-it.sh localhost:8080 --timeout=300 --strict -- echo "Waked up container" 80 | # - name: Health check 81 | # run: 82 | # - name: Ready for npx 83 | # uses: actions/setup-node@v2 84 | # with: 85 | # node-version: "14" 86 | # - name: Run e2e test 87 | # run: APIURL=http://localhost:8080/api sh ./e2e/run-api-tests.sh 88 | -------------------------------------------------------------------------------- /src/app/features/tag/entities.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::article::entities::Article; 2 | use crate::error::AppError; 3 | use crate::schema::tags; 4 | use chrono::NaiveDateTime; 5 | use diesel::backend::Backend; 6 | use diesel::dsl::{AsSelect, Eq, Filter, Select}; 7 | use diesel::pg::PgConnection; 8 | use diesel::Insertable; 9 | use diesel::*; 10 | use serde::{Deserialize, Serialize}; 11 | use uuid::Uuid; 12 | 13 | #[derive( 14 | Identifiable, 15 | Selectable, 16 | Queryable, 17 | Debug, 18 | Serialize, 19 | Deserialize, 20 | Clone, 21 | Associations, 22 | QueryableByName, 23 | )] 24 | #[diesel(belongs_to(Article, foreign_key = article_id))] 25 | #[diesel(table_name = tags)] 26 | pub struct Tag { 27 | pub id: Uuid, 28 | pub article_id: Uuid, 29 | pub name: String, 30 | pub created_at: NaiveDateTime, 31 | pub updated_at: NaiveDateTime, 32 | } 33 | 34 | // Tags 35 | type All = Select>; 36 | type WithName = Eq; 37 | type ByName = Filter, WithName>; 38 | type WithArticleId = Eq; 39 | type ByArticleId = Filter, WithArticleId>; 40 | 41 | impl Tag { 42 | fn all() -> All 43 | where 44 | DB: Backend, 45 | { 46 | tags::table.select(Tag::as_select()) 47 | } 48 | 49 | fn with_name(name: &str) -> WithName<&str> { 50 | tags::name.eq(name) 51 | } 52 | 53 | fn by_name(name: &str) -> ByName<&str, DB> 54 | where 55 | DB: Backend, 56 | { 57 | Self::all().filter(Self::with_name(name)) 58 | } 59 | 60 | fn with_article_id(article_id: &Uuid) -> WithArticleId<&Uuid> { 61 | tags::article_id.eq(article_id) 62 | } 63 | 64 | fn by_article_id(article_id: &Uuid) -> ByArticleId<&Uuid, DB> 65 | where 66 | DB: Backend, 67 | { 68 | Self::all().filter(Self::with_article_id(article_id)) 69 | } 70 | 71 | pub fn fetch_by_article_id( 72 | conn: &mut PgConnection, 73 | article_id: &Uuid, 74 | ) -> Result, AppError> { 75 | let t = Self::by_article_id(article_id); 76 | let list = t.get_results::(conn)?; 77 | Ok(list) 78 | } 79 | 80 | pub fn fetch(conn: &mut PgConnection) -> Result, AppError> { 81 | let list = tags::table.load::(conn)?; 82 | Ok(list) 83 | } 84 | 85 | pub fn fetch_article_ids_by_name( 86 | conn: &mut PgConnection, 87 | tag_name: &str, 88 | ) -> Result, AppError> { 89 | let t = Self::by_name(tag_name); 90 | let article_ids = t 91 | .load::(conn)? 92 | .iter() 93 | .map(|tag| tag.article_id) 94 | .collect(); 95 | Ok(article_ids) 96 | } 97 | 98 | pub fn create_list( 99 | conn: &mut PgConnection, 100 | records: Vec, 101 | ) -> Result, AppError> { 102 | let tags_list = diesel::insert_into(tags::table) 103 | .values(records) 104 | .get_results::(conn)?; 105 | Ok(tags_list) 106 | } 107 | } 108 | 109 | #[derive(Insertable)] 110 | #[diesel(table_name = tags)] 111 | pub struct CreateTag<'a> { 112 | pub name: &'a str, 113 | pub article_id: &'a Uuid, 114 | } 115 | -------------------------------------------------------------------------------- /src/app/features/comment/repositories.rs: -------------------------------------------------------------------------------- 1 | use super::entities::{Comment, CreateComment}; 2 | use crate::{ 3 | app::features::{ 4 | article::entities::{Article, FetchBySlugAndAuthorId}, 5 | profile::entities::Profile, 6 | user::entities::User, 7 | }, 8 | error::AppError, 9 | utils::db::DbPool, 10 | }; 11 | use uuid::Uuid; 12 | 13 | pub trait CommentRepository: Send + Sync + 'static { 14 | fn fetch_comments( 15 | &self, 16 | current_user: &Option, 17 | ) -> Result, AppError>; 18 | 19 | fn create_comment( 20 | &self, 21 | body: String, 22 | article_title_slug: String, 23 | author: User, 24 | ) -> Result<(Comment, Profile), AppError>; 25 | 26 | fn delete_comment( 27 | &self, 28 | article_title_slug: &str, 29 | comment_id: Uuid, 30 | author_id: Uuid, 31 | ) -> Result<(), AppError>; 32 | } 33 | 34 | #[derive(Clone)] 35 | pub struct CommentRepositoryImpl { 36 | pool: DbPool, 37 | } 38 | 39 | impl CommentRepositoryImpl { 40 | pub fn new(pool: DbPool) -> Self { 41 | Self { pool } 42 | } 43 | } 44 | impl CommentRepository for CommentRepositoryImpl { 45 | fn fetch_comments( 46 | &self, 47 | current_user: &Option, 48 | ) -> Result, AppError> { 49 | let conn = &mut self.pool.get()?; 50 | 51 | let comments = { 52 | use crate::schema::comments; 53 | use crate::schema::users; 54 | use diesel::prelude::*; 55 | comments::table 56 | .inner_join(users::table) 57 | .get_results::<(Comment, User)>(conn)? 58 | }; 59 | 60 | let comments = comments 61 | .iter() 62 | .map(|(comment, user)| { 63 | // TODO: avoid N+1. Write one query to fetch all data somehow. 64 | let profile = user.to_profile(conn, current_user); 65 | 66 | // TODO: avoid copy 67 | (comment.to_owned(), profile) 68 | }) 69 | .collect::>(); 70 | 71 | Ok(comments) 72 | } 73 | 74 | fn create_comment( 75 | &self, 76 | body: String, 77 | article_title_slug: String, 78 | author: User, 79 | ) -> Result<(Comment, Profile), AppError> { 80 | let conn = &mut self.pool.get()?; 81 | 82 | let article = Article::fetch_by_slug_and_author_id( 83 | conn, 84 | &FetchBySlugAndAuthorId { 85 | slug: article_title_slug, 86 | author_id: author.id, 87 | }, 88 | )?; 89 | let comment = Comment::create( 90 | conn, 91 | &CreateComment { 92 | body, 93 | author_id: author.id, 94 | article_id: article.id.to_owned(), 95 | }, 96 | )?; 97 | let profile = author.fetch_profile(conn, &author.id)?; 98 | Ok((comment, profile)) 99 | } 100 | 101 | fn delete_comment( 102 | &self, 103 | article_title_slug: &str, 104 | comment_id: Uuid, 105 | author_id: Uuid, 106 | ) -> Result<(), AppError> { 107 | let conn = &mut self.pool.get()?; 108 | let _ = Comment::delete(conn, (&comment_id, &author_id, article_title_slug)); 109 | Ok(()) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/app/features/comment/presenters.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::comment::entities::Comment; 2 | use crate::app::features::profile::entities::Profile; 3 | use crate::utils::date::Iso8601; 4 | use actix_web::HttpResponse; 5 | use serde::{Deserialize, Serialize}; 6 | use std::convert::From; 7 | use uuid::Uuid; 8 | 9 | #[derive(Serialize, Deserialize)] 10 | pub struct SingleCommentResponse { 11 | pub comment: InnerComment, 12 | } 13 | 14 | impl From<(Comment, Profile)> for SingleCommentResponse { 15 | fn from((comment, profile): (Comment, Profile)) -> Self { 16 | Self { 17 | comment: InnerComment { 18 | id: comment.id, 19 | body: comment.body, 20 | author: InnerAuthor { 21 | username: profile.username, 22 | bio: profile.bio, 23 | image: profile.image, 24 | following: profile.following, 25 | }, 26 | created_at: Iso8601(comment.created_at), 27 | updated_at: Iso8601(comment.updated_at), 28 | }, 29 | } 30 | } 31 | } 32 | 33 | #[derive(Serialize, Deserialize)] 34 | pub struct MultipleCommentsResponse { 35 | pub comments: Vec, 36 | } 37 | 38 | impl From> for MultipleCommentsResponse { 39 | fn from(list: Vec<(Comment, Profile)>) -> Self { 40 | Self { 41 | comments: list 42 | .into_iter() 43 | .map(|item| { 44 | let (comment, profile) = item; 45 | InnerComment { 46 | id: comment.id, 47 | created_at: Iso8601(comment.created_at), 48 | updated_at: Iso8601(comment.updated_at), 49 | body: comment.body, 50 | author: InnerAuthor { 51 | username: profile.username, 52 | bio: profile.bio, 53 | image: profile.image, 54 | following: profile.following, 55 | }, 56 | } 57 | }) 58 | .collect(), 59 | } 60 | } 61 | } 62 | 63 | #[derive(Serialize, Deserialize)] 64 | #[serde(rename_all = "camelCase")] 65 | pub struct InnerComment { 66 | pub id: Uuid, 67 | pub created_at: Iso8601, 68 | pub updated_at: Iso8601, 69 | pub body: String, 70 | pub author: InnerAuthor, 71 | } 72 | 73 | #[derive(Serialize, Deserialize)] 74 | pub struct InnerAuthor { 75 | pub username: String, 76 | pub bio: Option, 77 | pub image: Option, 78 | pub following: bool, 79 | } 80 | 81 | pub trait CommentPresenter: Send + Sync + 'static { 82 | fn to_http_res(&self) -> HttpResponse; 83 | fn to_single_json(&self, item: (Comment, Profile)) -> HttpResponse; 84 | fn to_multi_json(&self, list: Vec<(Comment, Profile)>) -> HttpResponse; 85 | } 86 | 87 | #[derive(Clone)] 88 | pub struct CommentPresenterImpl {} 89 | impl CommentPresenterImpl { 90 | pub fn new() -> Self { 91 | Self {} 92 | } 93 | } 94 | impl CommentPresenter for CommentPresenterImpl { 95 | fn to_http_res(&self) -> HttpResponse { 96 | HttpResponse::Ok().json("OK") 97 | } 98 | 99 | fn to_multi_json(&self, list: Vec<(Comment, Profile)>) -> HttpResponse { 100 | let res = MultipleCommentsResponse::from(list); 101 | HttpResponse::Ok().json(res) 102 | } 103 | 104 | fn to_single_json(&self, item: (Comment, Profile)) -> HttpResponse { 105 | let res = SingleCommentResponse::from(item); 106 | HttpResponse::Ok().json(res) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/app/drivers/routes.rs: -------------------------------------------------------------------------------- 1 | use crate::app; 2 | use actix_web::web; 3 | use actix_web::web::{delete, get, post, put}; 4 | 5 | pub fn api(cfg: &mut web::ServiceConfig) { 6 | cfg.service( 7 | web::scope("/api") 8 | .service( 9 | web::scope("/healthcheck") 10 | .route("", get().to(app::features::healthcheck::controllers::index)), 11 | ) 12 | .service( 13 | web::scope("/tags").route("", get().to(app::features::tag::controllers::index)), 14 | ) 15 | .service( 16 | web::scope("/users") 17 | .route( 18 | "/login", 19 | post().to(app::features::user::controllers::signin), 20 | ) 21 | .route("", post().to(app::features::user::controllers::signup)), 22 | ) 23 | .service( 24 | web::scope("/user") 25 | .route("", get().to(app::features::user::controllers::me)) 26 | .route("", put().to(app::features::user::controllers::update)), 27 | ) 28 | .service( 29 | web::scope("/profiles") 30 | .route( 31 | "/{username}", 32 | get().to(app::features::profile::controllers::show), 33 | ) 34 | .route( 35 | "/{username}/follow", 36 | post().to(app::features::profile::controllers::follow), 37 | ) 38 | .route( 39 | "/{username}/follow", 40 | delete().to(app::features::profile::controllers::unfollow), 41 | ), 42 | ) 43 | .service( 44 | web::scope("/articles") 45 | .route("/feed", get().to(app::features::article::controllers::feed)) 46 | .route("", get().to(app::features::article::controllers::index)) 47 | .route("", post().to(app::features::article::controllers::create)) 48 | .service( 49 | web::scope("/{article_title_slug}") 50 | .route("", get().to(app::features::article::controllers::show)) 51 | .route("", put().to(app::features::article::controllers::update)) 52 | .route("", delete().to(app::features::article::controllers::delete)) 53 | .service( 54 | web::scope("/favorite") 55 | .route( 56 | "", 57 | post().to(app::features::favorite::controllers::favorite), 58 | ) 59 | .route( 60 | "", 61 | delete() 62 | .to(app::features::favorite::controllers::unfavorite), 63 | ), 64 | ) 65 | .service( 66 | web::scope("/comments") 67 | .route("", get().to(app::features::comment::controllers::index)) 68 | .route( 69 | "", 70 | post().to(app::features::comment::controllers::create), 71 | ) 72 | .route( 73 | "/{comment_id}", 74 | delete().to(app::features::comment::controllers::delete), 75 | ), 76 | ), 77 | ), 78 | ), 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/app/features/user/repositories.rs: -------------------------------------------------------------------------------- 1 | use super::entities::UpdateUser; 2 | use crate::app::features::follow::entities::{CreateFollow, DeleteFollow, Follow}; 3 | use crate::app::features::profile::entities::Profile; 4 | use crate::app::features::user::entities::User; 5 | use crate::error::AppError; 6 | use crate::utils::db::DbPool; 7 | use uuid::Uuid; 8 | 9 | type Token = String; 10 | 11 | pub trait UserRepository: Send + Sync + 'static { 12 | fn signin(&self, email: &str, naive_password: &str) -> Result<(User, Token), AppError>; 13 | fn signup( 14 | &self, 15 | email: &str, 16 | username: &str, 17 | naive_password: &str, 18 | ) -> Result<(User, Token), AppError>; 19 | fn follow_user(&self, current_user: &User, target_username: &str) -> Result; 20 | fn unfollow_user( 21 | &self, 22 | current_user: &User, 23 | target_username: &str, 24 | ) -> Result; 25 | fn update(&self, user_id: Uuid, changeset: UpdateUser) -> Result<(User, Token), AppError>; 26 | fn find(&self, user_id: Uuid) -> Result; 27 | } 28 | 29 | #[derive(Clone)] 30 | pub struct UserRepositoryImpl { 31 | pool: DbPool, 32 | } 33 | 34 | impl UserRepositoryImpl { 35 | pub fn new(pool: DbPool) -> Self { 36 | Self { pool } 37 | } 38 | } 39 | 40 | impl UserRepository for UserRepositoryImpl { 41 | fn signin(&self, email: &str, naive_password: &str) -> Result<(User, Token), AppError> { 42 | let conn = &mut self.pool.get()?; 43 | User::signin(conn, email, naive_password) 44 | } 45 | 46 | fn signup( 47 | &self, 48 | email: &str, 49 | username: &str, 50 | naive_password: &str, 51 | ) -> Result<(User, Token), AppError> { 52 | let conn = &mut self.pool.get()?; 53 | User::signup(conn, email, username, naive_password) 54 | } 55 | 56 | fn follow_user(&self, current_user: &User, target_username: &str) -> Result { 57 | let conn = &mut self.pool.get()?; 58 | let t = User::by_username(target_username); 59 | 60 | let followee = { 61 | use diesel::prelude::*; 62 | t.first::(conn)? 63 | }; 64 | 65 | Follow::create( 66 | conn, 67 | &CreateFollow { 68 | follower_id: current_user.id, 69 | followee_id: followee.id, 70 | }, 71 | )?; 72 | 73 | Ok(Profile { 74 | username: current_user.username.clone(), 75 | bio: current_user.bio.clone(), 76 | image: current_user.image.clone(), 77 | following: true, 78 | }) 79 | } 80 | 81 | fn unfollow_user( 82 | &self, 83 | current_user: &User, 84 | target_username: &str, 85 | ) -> Result { 86 | let conn = &mut self.pool.get()?; 87 | let t = User::by_username(target_username); 88 | let followee = { 89 | use diesel::prelude::*; 90 | t.first::(conn)? 91 | }; 92 | 93 | Follow::delete( 94 | conn, 95 | &DeleteFollow { 96 | followee_id: followee.id, 97 | follower_id: current_user.id, 98 | }, 99 | )?; 100 | 101 | Ok(Profile { 102 | username: current_user.username.clone(), 103 | bio: current_user.bio.clone(), 104 | image: current_user.image.clone(), 105 | following: false, 106 | }) 107 | } 108 | 109 | fn update(&self, user_id: Uuid, changeset: UpdateUser) -> Result<(User, Token), AppError> { 110 | let conn = &mut self.pool.get()?; 111 | let new_user = User::update(conn, user_id, changeset)?; 112 | let token = &new_user.generate_token()?; 113 | Ok((new_user, token.clone())) 114 | } 115 | 116 | fn find(&self, user_id: Uuid) -> Result { 117 | let conn = &mut self.pool.get()?; 118 | let user = User::find(conn, user_id)?; 119 | Ok(user) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{http::StatusCode, HttpResponse}; 2 | use bcrypt::BcryptError; 3 | use diesel::r2d2::{Error as R2D2Error, PoolError}; 4 | use diesel::result::{DatabaseErrorKind, Error as DieselError}; 5 | use jsonwebtoken::errors::{Error as JwtError, ErrorKind as JwtErrorKind}; 6 | use serde_json::json; 7 | use serde_json::Value as JsonValue; 8 | use std::convert::From; 9 | use thiserror::Error; 10 | use uuid::Error as UuidError; 11 | 12 | #[derive(Error, Debug)] 13 | pub enum AppError { 14 | // 401 15 | #[error("Unauthorized: {}", _0)] 16 | Unauthorized(JsonValue), 17 | 18 | // 403 19 | #[error("Forbidden: {}", _0)] 20 | Forbidden(JsonValue), 21 | 22 | // 404 23 | #[error("Not Found: {}", _0)] 24 | NotFound(JsonValue), 25 | 26 | // 422 27 | #[error("Unprocessable Entity: {}", _0)] 28 | UnprocessableEntity(JsonValue), 29 | 30 | // 500 31 | #[error("Internal Server Error")] 32 | InternalServerError, 33 | } 34 | 35 | impl actix_web::error::ResponseError for AppError { 36 | fn error_response(&self) -> HttpResponse { 37 | match self { 38 | AppError::Unauthorized(ref msg) => HttpResponse::Unauthorized().json(msg), 39 | AppError::Forbidden(ref msg) => HttpResponse::Forbidden().json(msg), 40 | AppError::NotFound(ref msg) => HttpResponse::NotFound().json(msg), 41 | AppError::UnprocessableEntity(ref msg) => HttpResponse::UnprocessableEntity().json(msg), 42 | AppError::InternalServerError => { 43 | HttpResponse::InternalServerError().json("Internal Server Error") 44 | } 45 | } 46 | } 47 | fn status_code(&self) -> StatusCode { 48 | match *self { 49 | AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, 50 | AppError::Forbidden(_) => StatusCode::FORBIDDEN, 51 | AppError::NotFound(_) => StatusCode::NOT_FOUND, 52 | AppError::UnprocessableEntity(_) => StatusCode::UNPROCESSABLE_ENTITY, 53 | AppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, 54 | } 55 | } 56 | } 57 | 58 | impl From for AppError { 59 | fn from(_err: PoolError) -> Self { 60 | AppError::InternalServerError 61 | } 62 | } 63 | 64 | impl From for AppError { 65 | fn from(_err: BcryptError) -> Self { 66 | AppError::InternalServerError 67 | } 68 | } 69 | 70 | impl From for AppError { 71 | fn from(err: JwtError) -> Self { 72 | match err.kind() { 73 | JwtErrorKind::InvalidToken => AppError::Unauthorized(json!({ 74 | "error": "Token is invalid" 75 | })), 76 | JwtErrorKind::InvalidIssuer => AppError::Unauthorized(json!({ 77 | "error": "Issuer is invalid", 78 | })), 79 | _ => AppError::Unauthorized(json!({ 80 | "error": "An issue was found with the token provided", 81 | })), 82 | } 83 | } 84 | } 85 | 86 | impl From for AppError { 87 | fn from(_err: R2D2Error) -> Self { 88 | AppError::InternalServerError 89 | } 90 | } 91 | 92 | impl From for AppError { 93 | fn from(err: DieselError) -> Self { 94 | match err { 95 | DieselError::DatabaseError(kind, info) => { 96 | if let DatabaseErrorKind::UniqueViolation = kind { 97 | let message = info.details().unwrap_or_else(|| info.message()).to_string(); 98 | AppError::UnprocessableEntity(json!({ "error": message })) 99 | } else { 100 | AppError::InternalServerError 101 | } 102 | } 103 | DieselError::NotFound => { 104 | AppError::NotFound(json!({ "error": "requested record was not found" })) 105 | } 106 | _ => AppError::InternalServerError, 107 | } 108 | } 109 | } 110 | 111 | impl From for AppError { 112 | fn from(_err: UuidError) -> Self { 113 | AppError::NotFound(json!({"error":"Uuid is invalid."})) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/app/features/article/controllers.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | requests, 3 | usecases::{ 4 | CreateArticleUsecaseInput, DeleteArticleUsecaseInput, FetchArticlesUsecaseInput, 5 | UpdateArticleUsecaseInput, 6 | }, 7 | }; 8 | use crate::app::drivers::middlewares::auth; 9 | use crate::app::drivers::middlewares::state::AppState; 10 | use crate::utils::api::ApiResponse; 11 | use actix_web::{web, HttpRequest}; 12 | use serde::Deserialize; 13 | 14 | type ArticleTitleSlug = String; 15 | 16 | #[derive(Deserialize)] 17 | pub struct ArticlesListQueryParameter { 18 | tag: Option, 19 | author: Option, 20 | favorited: Option, 21 | limit: Option, 22 | offset: Option, 23 | } 24 | 25 | pub async fn index( 26 | state: web::Data, 27 | params: web::Query, 28 | ) -> ApiResponse { 29 | let offset = std::cmp::min(params.offset.to_owned().unwrap_or(0), 100); 30 | let limit = params.limit.unwrap_or(20); 31 | state 32 | .di_container 33 | .article_usecase 34 | .fetch_articles(FetchArticlesUsecaseInput { 35 | tag: params.tag.clone(), 36 | author: params.author.clone(), 37 | favorited: params.favorited.clone(), 38 | offset, 39 | limit, 40 | }) 41 | } 42 | 43 | #[derive(Deserialize)] 44 | pub struct FeedQueryParameter { 45 | limit: Option, 46 | offset: Option, 47 | } 48 | 49 | pub async fn feed( 50 | state: web::Data, 51 | req: HttpRequest, 52 | params: web::Query, 53 | ) -> ApiResponse { 54 | let current_user = auth::get_current_user(&req)?; 55 | let offset = std::cmp::min(params.offset.to_owned().unwrap_or(0), 100); 56 | let limit = params.limit.unwrap_or(20); 57 | state 58 | .di_container 59 | .article_usecase 60 | .fetch_following_articles(current_user, offset, limit) 61 | } 62 | 63 | pub async fn show(state: web::Data, path: web::Path) -> ApiResponse { 64 | let article_title_slug = path.into_inner(); 65 | state 66 | .di_container 67 | .article_usecase 68 | .fetch_article_by_slug(article_title_slug) 69 | } 70 | 71 | pub async fn create( 72 | state: web::Data, 73 | req: HttpRequest, 74 | form: web::Json, 75 | ) -> ApiResponse { 76 | let current_user = auth::get_current_user(&req)?; 77 | state 78 | .di_container 79 | .article_usecase 80 | .create_article(CreateArticleUsecaseInput { 81 | title: form.article.title.clone(), 82 | description: form.article.description.clone(), 83 | body: form.article.body.clone(), 84 | tag_name_list: form.article.tag_list.to_owned(), 85 | current_user, 86 | }) 87 | } 88 | 89 | pub async fn update( 90 | state: web::Data, 91 | req: HttpRequest, 92 | path: web::Path, 93 | form: web::Json, 94 | ) -> ApiResponse { 95 | let current_user = auth::get_current_user(&req)?; 96 | let article_title_slug = path.into_inner(); 97 | let title = form.article.title.clone(); 98 | let description = form.article.description.clone(); 99 | let body = form.article.body.clone(); 100 | state 101 | .di_container 102 | .article_usecase 103 | .update_article(UpdateArticleUsecaseInput { 104 | current_user, 105 | article_title_slug, 106 | title, 107 | description, 108 | body, 109 | }) 110 | } 111 | 112 | pub async fn delete( 113 | state: web::Data, 114 | req: HttpRequest, 115 | path: web::Path, 116 | ) -> ApiResponse { 117 | let current_user = auth::get_current_user(&req)?; 118 | let article_title_slug = path.into_inner(); 119 | state 120 | .di_container 121 | .article_usecase 122 | .delete_article(DeleteArticleUsecaseInput { 123 | author_id: current_user.id, 124 | slug: article_title_slug, 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /src/app/features/article/usecases.rs: -------------------------------------------------------------------------------- 1 | use super::entities::Article; 2 | use super::presenters::ArticlePresenter; 3 | use super::repositories::{ 4 | ArticleRepository, CreateArticleRepositoryInput, DeleteArticleRepositoryInput, 5 | FetchArticlesRepositoryInput, FetchFollowingArticlesRepositoryInput, 6 | UpdateArticleRepositoryInput, 7 | }; 8 | use crate::app::features::user::entities::User; 9 | use crate::error::AppError; 10 | use actix_web::HttpResponse; 11 | use std::sync::Arc; 12 | use uuid::Uuid; 13 | 14 | #[derive(Clone)] 15 | pub struct ArticleUsecase { 16 | article_repository: Arc, 17 | article_presenter: Arc, 18 | } 19 | 20 | impl ArticleUsecase { 21 | pub fn new( 22 | article_repository: Arc, 23 | article_presenter: Arc, 24 | ) -> Self { 25 | Self { 26 | article_repository, 27 | article_presenter, 28 | } 29 | } 30 | 31 | pub fn fetch_articles( 32 | &self, 33 | params: FetchArticlesUsecaseInput, 34 | ) -> Result { 35 | let (list, count) = 36 | self.article_repository 37 | .fetch_articles(FetchArticlesRepositoryInput { 38 | tag: params.tag.clone(), 39 | author: params.author.clone(), 40 | favorited: params.favorited.clone(), 41 | offset: params.offset, 42 | limit: params.limit, 43 | })?; 44 | let res = self.article_presenter.to_multi_json(list, count); 45 | Ok(res) 46 | } 47 | 48 | pub fn fetch_article_by_slug( 49 | &self, 50 | article_title_slug: String, 51 | ) -> Result { 52 | let result = self 53 | .article_repository 54 | .fetch_article_by_slug(article_title_slug)?; 55 | let res = self.article_presenter.to_single_json(result); 56 | Ok(res) 57 | } 58 | 59 | pub fn fetch_following_articles( 60 | &self, 61 | user: User, 62 | offset: i64, 63 | limit: i64, 64 | ) -> Result { 65 | let (list, count) = self.article_repository.fetch_following_articles( 66 | &FetchFollowingArticlesRepositoryInput { 67 | current_user: user, 68 | offset, 69 | limit, 70 | }, 71 | )?; 72 | let res = self.article_presenter.to_multi_json(list, count); 73 | Ok(res) 74 | } 75 | 76 | pub fn create_article( 77 | &self, 78 | params: CreateArticleUsecaseInput, 79 | ) -> Result { 80 | let slug = Article::convert_title_to_slug(¶ms.title); 81 | let result = self 82 | .article_repository 83 | .create_article(CreateArticleRepositoryInput { 84 | body: params.body, 85 | current_user: params.current_user, 86 | description: params.description, 87 | tag_name_list: params.tag_name_list, 88 | title: params.title, 89 | slug, 90 | })?; 91 | let res = self.article_presenter.to_single_json(result); 92 | Ok(res) 93 | } 94 | 95 | pub fn delete_article( 96 | &self, 97 | input: DeleteArticleUsecaseInput, 98 | ) -> Result { 99 | self.article_repository 100 | .delete_article(DeleteArticleRepositoryInput { 101 | slug: input.slug, 102 | author_id: input.author_id, 103 | })?; 104 | let res = self.article_presenter.to_http_res(); 105 | Ok(res) 106 | } 107 | 108 | pub fn update_article( 109 | &self, 110 | input: UpdateArticleUsecaseInput, 111 | ) -> Result { 112 | let article_slug = &input 113 | .title 114 | .as_ref() 115 | .map(|_title| Article::convert_title_to_slug(_title)); 116 | let slug = article_slug.to_owned(); 117 | let result = self 118 | .article_repository 119 | .update_article(UpdateArticleRepositoryInput { 120 | current_user: input.current_user, 121 | article_title_slug: input.article_title_slug, 122 | slug, 123 | title: input.title, 124 | description: input.description, 125 | body: input.body, 126 | })?; 127 | let res = self.article_presenter.to_single_json(result); 128 | Ok(res) 129 | } 130 | } 131 | 132 | pub struct CreateArticleUsecaseInput { 133 | pub title: String, 134 | pub description: String, 135 | pub body: String, 136 | pub tag_name_list: Option>, 137 | pub current_user: User, 138 | } 139 | 140 | pub struct DeleteArticleUsecaseInput { 141 | pub slug: String, 142 | pub author_id: Uuid, 143 | } 144 | 145 | pub struct UpdateArticleUsecaseInput { 146 | pub current_user: User, 147 | pub article_title_slug: String, 148 | pub title: Option, 149 | pub description: Option, 150 | pub body: Option, 151 | } 152 | 153 | pub struct FetchArticlesUsecaseInput { 154 | pub tag: Option, 155 | pub author: Option, 156 | pub favorited: Option, 157 | pub offset: i64, 158 | pub limit: i64, 159 | } 160 | -------------------------------------------------------------------------------- /src/app/features/article/presenters.rs: -------------------------------------------------------------------------------- 1 | use super::{entities::Article, repositories::ArticlesList}; 2 | use crate::app::features::favorite::entities::FavoriteInfo; 3 | use crate::app::features::profile::entities::Profile; 4 | use crate::app::features::tag::entities::Tag; 5 | use crate::utils::date::Iso8601; 6 | use actix_web::HttpResponse; 7 | use serde::{Deserialize, Serialize}; 8 | use std::convert::From; 9 | 10 | type ArticleCount = i64; 11 | 12 | #[derive(Deserialize, Serialize)] 13 | pub struct SingleArticleResponse { 14 | pub article: ArticleContent, 15 | } 16 | 17 | impl From<(Article, Profile, FavoriteInfo, Vec)> for SingleArticleResponse { 18 | fn from( 19 | (article, profile, favorite_info, tag_list): (Article, Profile, FavoriteInfo, Vec), 20 | ) -> Self { 21 | Self { 22 | article: ArticleContent { 23 | slug: article.slug, 24 | title: article.title, 25 | description: article.description, 26 | body: article.body, 27 | tag_list: tag_list 28 | .iter() 29 | .map(move |tag| tag.name.to_owned()) 30 | .collect(), 31 | created_at: Iso8601(article.created_at), 32 | updated_at: Iso8601(article.updated_at), 33 | favorited: favorite_info.is_favorited.to_owned(), 34 | favorites_count: favorite_info.favorites_count.to_owned(), 35 | author: AuthorContent { 36 | username: profile.username, 37 | bio: profile.bio, 38 | image: profile.image, 39 | following: profile.following, 40 | }, 41 | }, 42 | } 43 | } 44 | } 45 | 46 | #[derive(Deserialize, Serialize)] 47 | #[serde(rename_all = "camelCase")] 48 | pub struct MultipleArticlesResponse { 49 | pub articles: Vec, 50 | pub articles_count: ArticleCount, 51 | } 52 | 53 | type ArticlesCount = i64; 54 | type Inner = ((Article, Profile, FavoriteInfo), Vec); 55 | type Item = (ArticlesList, ArticlesCount); 56 | impl From for MultipleArticlesResponse { 57 | fn from((list, articles_count): (Vec, ArticleCount)) -> Self { 58 | let articles = list 59 | .iter() 60 | .map(|((article, profile, favorite_info), tags_list)| { 61 | ArticleContent::from(( 62 | article.to_owned(), 63 | profile.to_owned(), 64 | favorite_info.to_owned(), 65 | tags_list.to_owned(), 66 | )) 67 | }) 68 | .collect(); 69 | Self { 70 | articles_count, 71 | articles, 72 | } 73 | } 74 | } 75 | 76 | #[derive(Deserialize, Serialize)] 77 | #[serde(rename_all = "camelCase")] 78 | pub struct ArticleContent { 79 | pub slug: String, 80 | pub title: String, 81 | pub description: String, 82 | pub body: String, 83 | pub tag_list: Vec, 84 | pub created_at: Iso8601, 85 | pub updated_at: Iso8601, 86 | pub favorited: bool, 87 | pub favorites_count: i64, 88 | pub author: AuthorContent, 89 | } 90 | 91 | impl From<(Article, Profile, FavoriteInfo, Vec)> for ArticleContent { 92 | fn from( 93 | (article, profile, favorite_info, tag_list): (Article, Profile, FavoriteInfo, Vec), 94 | ) -> Self { 95 | Self { 96 | slug: article.slug, 97 | title: article.title, 98 | description: article.description, 99 | body: article.body, 100 | tag_list: tag_list.iter().map(move |tag| tag.name.clone()).collect(), 101 | created_at: Iso8601(article.created_at), 102 | updated_at: Iso8601(article.updated_at), 103 | favorited: favorite_info.is_favorited.to_owned(), 104 | favorites_count: favorite_info.favorites_count.to_owned(), 105 | author: AuthorContent { 106 | username: profile.username, 107 | bio: profile.bio, 108 | image: profile.image, 109 | following: profile.following, 110 | }, 111 | } 112 | } 113 | } 114 | 115 | #[derive(Deserialize, Serialize)] 116 | pub struct AuthorContent { 117 | pub username: String, 118 | pub bio: Option, 119 | pub image: Option, 120 | pub following: bool, 121 | } 122 | 123 | pub trait ArticlePresenter: Send + Sync + 'static { 124 | fn to_multi_json(&self, list: ArticlesList, count: i64) -> HttpResponse; 125 | fn to_single_json(&self, item: (Article, Profile, FavoriteInfo, Vec)) -> HttpResponse; 126 | fn to_http_res(&self) -> HttpResponse; 127 | } 128 | 129 | #[derive(Clone)] 130 | pub struct ArticlePresenterImpl {} 131 | impl ArticlePresenterImpl { 132 | pub fn new() -> Self { 133 | Self {} 134 | } 135 | } 136 | impl ArticlePresenter for ArticlePresenterImpl { 137 | fn to_multi_json(&self, list: ArticlesList, count: i64) -> HttpResponse { 138 | let res = MultipleArticlesResponse::from((list, count)); 139 | HttpResponse::Ok().json(res) 140 | } 141 | fn to_single_json(&self, item: (Article, Profile, FavoriteInfo, Vec)) -> HttpResponse { 142 | let res = SingleArticleResponse::from(item); 143 | HttpResponse::Ok().json(res) 144 | } 145 | fn to_http_res(&self) -> HttpResponse { 146 | HttpResponse::Ok().json(()) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/utils/di.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::article::presenters::ArticlePresenterImpl; 2 | use crate::app::features::article::repositories::ArticleRepositoryImpl; 3 | use crate::app::features::article::usecases::ArticleUsecase; 4 | use crate::app::features::comment::presenters::CommentPresenterImpl; 5 | use crate::app::features::comment::repositories::CommentRepositoryImpl; 6 | use crate::app::features::comment::usecases::CommentUsecase; 7 | use crate::app::features::favorite::presenters::FavoritePresenterImpl; 8 | use crate::app::features::favorite::repositories::FavoriteRepositoryImpl; 9 | use crate::app::features::favorite::usecases::FavoriteUsecase; 10 | use crate::app::features::profile::presenters::ProfilePresenterImpl; 11 | use crate::app::features::profile::repositories::ProfileRepositoryImpl; 12 | use crate::app::features::profile::usecases::ProfileUsecase; 13 | use crate::app::features::tag::presenters::TagPresenterImpl; 14 | use crate::app::features::tag::repositories::TagRepositoryImpl; 15 | use crate::app::features::tag::usecases::TagUsecase; 16 | use crate::app::features::user::presenters::UserPresenterImpl; 17 | use crate::app::features::user::repositories::UserRepositoryImpl; 18 | use crate::app::features::user::usecases::UserUsecase; 19 | use std::sync::Arc; 20 | 21 | use crate::utils::db::DbPool; 22 | 23 | #[derive(Clone)] 24 | pub struct DiContainer { 25 | /** 26 | * User 27 | */ 28 | pub user_repository: UserRepositoryImpl, 29 | pub user_usecase: UserUsecase, 30 | pub user_presenter: UserPresenterImpl, 31 | 32 | /** 33 | * Profile 34 | */ 35 | pub profile_repository: ProfileRepositoryImpl, 36 | pub profile_presenter: ProfilePresenterImpl, 37 | pub profile_usecase: ProfileUsecase, 38 | 39 | /** 40 | * Favorite 41 | */ 42 | pub favorite_repository: FavoriteRepositoryImpl, 43 | pub favorite_presenter: FavoritePresenterImpl, 44 | pub favorite_usecase: FavoriteUsecase, 45 | 46 | /** 47 | * Article 48 | */ 49 | pub article_repository: ArticleRepositoryImpl, 50 | pub article_presenter: ArticlePresenterImpl, 51 | pub article_usecase: ArticleUsecase, 52 | 53 | /** 54 | * Tag 55 | */ 56 | pub tag_repository: TagRepositoryImpl, 57 | pub tag_presenter: TagPresenterImpl, 58 | pub tag_usecase: TagUsecase, 59 | 60 | /** 61 | * Comment 62 | */ 63 | pub comment_repository: CommentRepositoryImpl, 64 | pub comment_presenter: CommentPresenterImpl, 65 | pub comment_usecase: CommentUsecase, 66 | } 67 | 68 | impl DiContainer { 69 | pub fn new(pool: &DbPool) -> Self { 70 | // Repository 71 | let user_repository = UserRepositoryImpl::new(pool.clone()); 72 | let profile_repository = ProfileRepositoryImpl::new(pool.clone()); 73 | let favorite_repository = FavoriteRepositoryImpl::new(pool.clone()); 74 | let article_repository = ArticleRepositoryImpl::new(pool.clone()); 75 | let tag_repository = TagRepositoryImpl::new(pool.clone()); 76 | let comment_repository = CommentRepositoryImpl::new(pool.clone()); 77 | 78 | // Presenter 79 | let user_presenter = UserPresenterImpl::new(); 80 | let profile_presenter = ProfilePresenterImpl::new(); 81 | let favorite_presenter = FavoritePresenterImpl::new(); 82 | let article_presenter = ArticlePresenterImpl::new(); 83 | let tag_presenter = TagPresenterImpl::new(); 84 | let comment_presenter = CommentPresenterImpl::new(); 85 | 86 | // Usecase 87 | let user_usecase = UserUsecase::new( 88 | Arc::new(user_repository.clone()), 89 | Arc::new(user_presenter.clone()), 90 | ); 91 | let profile_usecase = ProfileUsecase::new( 92 | Arc::new(profile_repository.clone()), 93 | Arc::new(user_repository.clone()), 94 | Arc::new(profile_presenter.clone()), 95 | ); 96 | let favorite_usecase = FavoriteUsecase::new( 97 | Arc::new(favorite_repository.clone()), 98 | Arc::new(favorite_presenter.clone()), 99 | Arc::new(article_repository.clone()), 100 | ); 101 | let article_usecase = ArticleUsecase::new( 102 | Arc::new(article_repository.clone()), 103 | Arc::new(article_presenter.clone()), 104 | ); 105 | let tag_usecase = TagUsecase::new( 106 | Arc::new(tag_repository.clone()), 107 | Arc::new(tag_presenter.clone()), 108 | ); 109 | let comment_usecase = CommentUsecase::new( 110 | Arc::new(comment_repository.clone()), 111 | Arc::new(comment_presenter.clone()), 112 | ); 113 | 114 | Self { 115 | // User 116 | user_repository, 117 | user_usecase, 118 | user_presenter, 119 | 120 | // Profile 121 | profile_presenter, 122 | profile_repository, 123 | profile_usecase, 124 | 125 | // Favorite 126 | favorite_repository, 127 | favorite_presenter, 128 | favorite_usecase, 129 | 130 | // Article 131 | article_repository, 132 | article_presenter, 133 | article_usecase, 134 | 135 | // Tag 136 | tag_repository, 137 | tag_presenter, 138 | tag_usecase, 139 | 140 | // Comment 141 | comment_repository, 142 | comment_presenter, 143 | comment_usecase, 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/app/features/article/entities.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::favorite::entities::Favorite; 2 | use crate::app::features::user::entities::User; 3 | use crate::error::AppError; 4 | use crate::schema::articles; 5 | use crate::utils::converter; 6 | use chrono::NaiveDateTime; 7 | use diesel::dsl::Eq; 8 | use diesel::pg::PgConnection; 9 | use diesel::prelude::*; 10 | use diesel::Insertable; 11 | use serde::{Deserialize, Serialize}; 12 | use uuid::Uuid; 13 | 14 | #[derive(Identifiable, Queryable, Debug, Serialize, Deserialize, Associations, Clone)] 15 | #[diesel(belongs_to(User, foreign_key = author_id))] 16 | #[diesel(table_name = articles)] 17 | pub struct Article { 18 | pub id: Uuid, 19 | pub author_id: Uuid, 20 | pub slug: String, 21 | pub title: String, 22 | pub description: String, 23 | pub body: String, 24 | pub created_at: NaiveDateTime, 25 | pub updated_at: NaiveDateTime, 26 | } 27 | 28 | type WithAuthorId = Eq; 29 | type WithSlug = Eq; 30 | type WithId = Eq; 31 | 32 | impl Article { 33 | fn with_author_id(author_id: &Uuid) -> WithAuthorId<&Uuid> { 34 | articles::author_id.eq(author_id) 35 | } 36 | 37 | fn with_slug(slug: &str) -> WithSlug<&str> { 38 | articles::slug.eq(slug) 39 | } 40 | 41 | fn with_id(id: &Uuid) -> WithId<&Uuid> { 42 | articles::id.eq(id) 43 | } 44 | } 45 | 46 | impl Article { 47 | pub fn create(conn: &mut PgConnection, record: &CreateArticle) -> Result { 48 | let article = diesel::insert_into(articles::table) 49 | .values(record) 50 | .get_result::
(conn)?; 51 | 52 | Ok(article) 53 | } 54 | 55 | pub fn update( 56 | conn: &mut PgConnection, 57 | article_title_slug: &str, 58 | author_id: &Uuid, 59 | record: &UpdateArticle, 60 | ) -> Result { 61 | let t = articles::table 62 | .filter(Self::with_slug(article_title_slug)) 63 | .filter(Self::with_author_id(author_id)); 64 | let article = diesel::update(t).set(record).get_result::
(conn)?; 65 | Ok(article) 66 | } 67 | 68 | pub fn convert_title_to_slug(title: &str) -> String { 69 | converter::to_kebab(title) 70 | } 71 | 72 | pub fn fetch_by_slug_and_author_id( 73 | conn: &mut PgConnection, 74 | params: &FetchBySlugAndAuthorId, 75 | ) -> Result { 76 | let t = articles::table 77 | .filter(Self::with_slug(¶ms.slug)) 78 | .filter(Self::with_author_id(¶ms.author_id)); 79 | let item = t.first::(conn)?; 80 | Ok(item) 81 | } 82 | 83 | pub fn fetch_by_slug_with_author( 84 | conn: &mut PgConnection, 85 | slug: &str, 86 | ) -> Result<(Self, User), AppError> { 87 | use crate::schema::users; 88 | let t = articles::table 89 | .inner_join(users::table) 90 | .filter(Self::with_slug(slug)); 91 | let result = t.get_result::<(Self, User)>(conn)?; 92 | Ok(result) 93 | } 94 | 95 | pub fn fetch_ids_by_author_name( 96 | conn: &mut PgConnection, 97 | name: &str, 98 | ) -> Result, AppError> { 99 | use crate::schema::users; 100 | let t = users::table 101 | .inner_join(articles::table) 102 | .filter(User::with_username(name)) 103 | .select(articles::id); 104 | let ids = t.load::(conn)?; 105 | Ok(ids) 106 | } 107 | 108 | pub fn find_with_author(conn: &mut PgConnection, id: &Uuid) -> Result<(Self, User), AppError> { 109 | use crate::schema::users; 110 | let t = articles::table 111 | .inner_join(users::table) 112 | .filter(Self::with_id(id)); 113 | let result = t.get_result::<(Article, User)>(conn)?; 114 | Ok(result) 115 | } 116 | 117 | pub fn delete(conn: &mut PgConnection, params: &DeleteArticle) -> Result<(), AppError> { 118 | let t = articles::table 119 | .filter(Self::with_slug(¶ms.slug)) 120 | .filter(Self::with_author_id(¶ms.author_id)); 121 | diesel::delete(t).execute(conn)?; 122 | // NOTE: references tag rows are deleted automatically by DELETE CASCADE 123 | 124 | Ok(()) 125 | } 126 | } 127 | 128 | impl Article { 129 | pub fn is_favorited_by_user_id( 130 | &self, 131 | conn: &mut PgConnection, 132 | user_id: &Uuid, 133 | ) -> Result { 134 | use crate::schema::favorites; 135 | let t = favorites::table 136 | .select(diesel::dsl::count(favorites::id)) 137 | .filter(Favorite::with_article_id(&self.id)) 138 | .filter(Favorite::with_user_id(user_id)); 139 | let count = t.first::(conn)?; 140 | Ok(count >= 1) 141 | } 142 | 143 | pub fn fetch_favorites_count(&self, conn: &mut PgConnection) -> Result { 144 | use crate::schema::favorites; 145 | let t = favorites::table 146 | .filter(Favorite::with_article_id(&self.id)) 147 | .select(diesel::dsl::count(favorites::created_at)); 148 | let favorites_count = t.first::(conn)?; 149 | Ok(favorites_count) 150 | } 151 | } 152 | 153 | #[derive(Insertable, Clone)] 154 | #[diesel(table_name = articles)] 155 | pub struct CreateArticle { 156 | pub author_id: Uuid, 157 | pub slug: String, 158 | pub title: String, 159 | pub description: String, 160 | pub body: String, 161 | } 162 | 163 | #[derive(AsChangeset)] 164 | #[diesel(table_name = articles)] 165 | pub struct UpdateArticle { 166 | pub slug: Option, 167 | pub title: Option, 168 | pub description: Option, 169 | pub body: Option, 170 | } 171 | 172 | pub struct FetchBySlugAndAuthorId { 173 | pub slug: String, 174 | pub author_id: Uuid, 175 | } 176 | 177 | pub struct DeleteArticle { 178 | pub slug: String, 179 | pub author_id: Uuid, 180 | } 181 | -------------------------------------------------------------------------------- /scripts/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | # REF: https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh 4 | 5 | WAITFORIT_cmdname=${0##*/} 6 | 7 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 8 | 9 | usage() 10 | { 11 | cat << USAGE >&2 12 | Usage: 13 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 14 | -h HOST | --host=HOST Host or IP under test 15 | -p PORT | --port=PORT TCP port under test 16 | Alternatively, you specify the host and port as host:port 17 | -s | --strict Only execute subcommand if the test succeeds 18 | -q | --quiet Don't output any status messages 19 | -t TIMEOUT | --timeout=TIMEOUT 20 | Timeout in seconds, zero for no timeout 21 | -- COMMAND ARGS Execute command with args after the test finishes 22 | USAGE 23 | exit 1 24 | } 25 | 26 | wait_for() 27 | { 28 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 29 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 30 | else 31 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 32 | fi 33 | WAITFORIT_start_ts=$(date +%s) 34 | while : 35 | do 36 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 37 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 38 | WAITFORIT_result=$? 39 | else 40 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 41 | WAITFORIT_result=$? 42 | fi 43 | if [[ $WAITFORIT_result -eq 0 ]]; then 44 | WAITFORIT_end_ts=$(date +%s) 45 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 46 | break 47 | fi 48 | sleep 1 49 | done 50 | return $WAITFORIT_result 51 | } 52 | 53 | wait_for_wrapper() 54 | { 55 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 56 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 57 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 58 | else 59 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 60 | fi 61 | WAITFORIT_PID=$! 62 | trap "kill -INT -$WAITFORIT_PID" INT 63 | wait $WAITFORIT_PID 64 | WAITFORIT_RESULT=$? 65 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 66 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 67 | fi 68 | return $WAITFORIT_RESULT 69 | } 70 | 71 | # process arguments 72 | while [[ $# -gt 0 ]] 73 | do 74 | case "$1" in 75 | *:* ) 76 | WAITFORIT_hostport=(${1//:/ }) 77 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 78 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 79 | shift 1 80 | ;; 81 | --child) 82 | WAITFORIT_CHILD=1 83 | shift 1 84 | ;; 85 | -q | --quiet) 86 | WAITFORIT_QUIET=1 87 | shift 1 88 | ;; 89 | -s | --strict) 90 | WAITFORIT_STRICT=1 91 | shift 1 92 | ;; 93 | -h) 94 | WAITFORIT_HOST="$2" 95 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 96 | shift 2 97 | ;; 98 | --host=*) 99 | WAITFORIT_HOST="${1#*=}" 100 | shift 1 101 | ;; 102 | -p) 103 | WAITFORIT_PORT="$2" 104 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 105 | shift 2 106 | ;; 107 | --port=*) 108 | WAITFORIT_PORT="${1#*=}" 109 | shift 1 110 | ;; 111 | -t) 112 | WAITFORIT_TIMEOUT="$2" 113 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 114 | shift 2 115 | ;; 116 | --timeout=*) 117 | WAITFORIT_TIMEOUT="${1#*=}" 118 | shift 1 119 | ;; 120 | --) 121 | shift 122 | WAITFORIT_CLI=("$@") 123 | break 124 | ;; 125 | --help) 126 | usage 127 | ;; 128 | *) 129 | echoerr "Unknown argument: $1" 130 | usage 131 | ;; 132 | esac 133 | done 134 | 135 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 136 | echoerr "Error: you need to provide a host and port to test." 137 | usage 138 | fi 139 | 140 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 141 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 142 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 143 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 144 | 145 | # Check to see if timeout is from busybox? 146 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 147 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 148 | 149 | WAITFORIT_BUSYTIMEFLAG="" 150 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 151 | WAITFORIT_ISBUSY=1 152 | # Check if busybox timeout uses -t flag 153 | # (recent Alpine versions don't support -t anymore) 154 | if timeout &>/dev/stdout | grep -q -e '-t '; then 155 | WAITFORIT_BUSYTIMEFLAG="-t" 156 | fi 157 | else 158 | WAITFORIT_ISBUSY=0 159 | fi 160 | 161 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 162 | wait_for 163 | WAITFORIT_RESULT=$? 164 | exit $WAITFORIT_RESULT 165 | else 166 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 167 | wait_for_wrapper 168 | WAITFORIT_RESULT=$? 169 | else 170 | wait_for 171 | WAITFORIT_RESULT=$? 172 | fi 173 | fi 174 | 175 | if [[ $WAITFORIT_CLI != "" ]]; then 176 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 177 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 178 | exit $WAITFORIT_RESULT 179 | fi 180 | exec "${WAITFORIT_CLI[@]}" 181 | else 182 | exit $WAITFORIT_RESULT 183 | fi -------------------------------------------------------------------------------- /src/app/features/user/entities.rs: -------------------------------------------------------------------------------- 1 | use crate::app::features::favorite::entities::Favorite; 2 | use crate::app::features::follow::entities::Follow; 3 | use crate::app::features::profile::entities::Profile; 4 | use crate::error::AppError; 5 | use crate::schema::users; 6 | use crate::utils::{hasher, token}; 7 | use chrono::prelude::*; 8 | use chrono::NaiveDateTime; 9 | use diesel::backend::Backend; 10 | use diesel::dsl::{AsSelect, Eq, Filter, Select}; 11 | use diesel::pg::PgConnection; 12 | use diesel::prelude::*; 13 | use serde::{Deserialize, Serialize}; 14 | use uuid::Uuid; 15 | 16 | #[derive(Identifiable, Queryable, Selectable, Serialize, Deserialize, Debug, Clone)] 17 | #[diesel(table_name = users)] 18 | pub struct User { 19 | pub id: Uuid, 20 | pub email: String, 21 | pub username: String, 22 | pub password: String, 23 | pub bio: Option, 24 | pub image: Option, 25 | pub created_at: NaiveDateTime, 26 | pub updated_at: NaiveDateTime, 27 | } 28 | 29 | type Token = String; 30 | 31 | type All = Select>; 32 | type WithUsername = Eq; 33 | type WithEmail = Eq; 34 | type ByUsername = Filter, WithUsername>; 35 | type ByEmail = Filter, WithEmail>; 36 | 37 | impl User { 38 | fn all() -> All 39 | where 40 | DB: Backend, 41 | { 42 | users::table.select(User::as_select()) 43 | } 44 | 45 | pub fn with_username(username: &str) -> WithUsername<&str> { 46 | users::username.eq(username) 47 | } 48 | 49 | pub fn by_username(username: &str) -> ByUsername 50 | where 51 | DB: Backend, 52 | { 53 | Self::all().filter(Self::with_username(username)) 54 | } 55 | 56 | fn with_email(email: &str) -> WithEmail<&str> { 57 | users::email.eq(email) 58 | } 59 | 60 | fn by_email(email: &str) -> ByEmail 61 | where 62 | DB: Backend, 63 | { 64 | Self::all().filter(Self::with_email(email)) 65 | } 66 | } 67 | 68 | impl User { 69 | pub fn signup<'a>( 70 | conn: &mut PgConnection, 71 | email: &'a str, 72 | username: &'a str, 73 | naive_password: &'a str, 74 | ) -> Result<(User, Token), AppError> { 75 | use diesel::prelude::*; 76 | let hashed_password = hasher::hash_password(naive_password)?; 77 | 78 | let record = SignupUser { 79 | email, 80 | username, 81 | password: &hashed_password, 82 | }; 83 | 84 | let user = diesel::insert_into(users::table) 85 | .values(&record) 86 | .get_result::(conn)?; 87 | 88 | let token = user.generate_token()?; 89 | Ok((user, token)) 90 | } 91 | 92 | pub fn signin( 93 | conn: &mut PgConnection, 94 | email: &str, 95 | naive_password: &str, 96 | ) -> Result<(User, Token), AppError> { 97 | let t = Self::by_email(email).limit(1); 98 | let user = t.first::(conn)?; 99 | hasher::verify(naive_password, &user.password)?; 100 | let token = user.generate_token()?; 101 | Ok((user, token)) 102 | } 103 | 104 | pub fn find(conn: &mut PgConnection, id: Uuid) -> Result { 105 | let t = users::table.find(id); 106 | let user = t.first(conn)?; 107 | Ok(user) 108 | } 109 | 110 | pub fn update( 111 | conn: &mut PgConnection, 112 | user_id: Uuid, 113 | changeset: UpdateUser, 114 | ) -> Result { 115 | let target = users::table.find(user_id); 116 | let user = diesel::update(target) 117 | .set(changeset) 118 | .get_result::(conn)?; 119 | Ok(user) 120 | } 121 | 122 | pub fn find_by_username(conn: &mut PgConnection, username: &str) -> Result { 123 | let t = Self::by_username(username).limit(1); 124 | let user = t.first::(conn)?; 125 | Ok(user) 126 | } 127 | 128 | pub fn is_following(&self, conn: &mut PgConnection, followee_id: &Uuid) -> bool { 129 | use crate::schema::follows; 130 | let t = follows::table 131 | .filter(Follow::with_followee(followee_id)) 132 | .filter(Follow::with_follower(&self.id)); 133 | let follow = t.get_result::(conn); 134 | follow.is_ok() 135 | } 136 | } 137 | 138 | impl User { 139 | pub fn generate_token(&self) -> Result { 140 | let now = Utc::now().timestamp_nanos() / 1_000_000_000; // nanosecond -> second 141 | let token = token::generate(self.id, now)?; 142 | Ok(token) 143 | } 144 | 145 | pub fn fetch_favorited_article_ids( 146 | &self, 147 | conn: &mut PgConnection, 148 | ) -> Result, AppError> { 149 | use crate::schema::favorites; 150 | let t = favorites::table 151 | .filter(Favorite::with_user_id(&self.id)) 152 | .select(favorites::article_id); 153 | let favorited_article_ids = t.get_results::(conn)?; 154 | Ok(favorited_article_ids) 155 | } 156 | 157 | pub fn fetch_profile( 158 | &self, 159 | conn: &mut PgConnection, 160 | folowee_id: &Uuid, 161 | ) -> Result { 162 | let is_following = &self.is_following(conn, folowee_id); 163 | let profile = Profile { 164 | username: self.username.to_owned(), 165 | bio: self.bio.to_owned(), 166 | image: self.image.to_owned(), 167 | following: is_following.to_owned(), 168 | }; 169 | Ok(profile) 170 | } 171 | 172 | pub fn to_profile(&self, conn: &mut PgConnection, current_user: &Option) -> Profile { 173 | let user = self; 174 | let following = match current_user { 175 | Some(current_user) => current_user.is_following(conn, &user.id), 176 | None => false, 177 | }; 178 | 179 | Profile { 180 | username: user.username.to_owned(), 181 | bio: user.bio.to_owned(), 182 | image: user.image.to_owned(), 183 | following, 184 | } 185 | } 186 | } 187 | 188 | #[derive(Insertable, Debug, Deserialize)] 189 | #[diesel(table_name = users)] 190 | pub struct SignupUser<'a> { 191 | pub email: &'a str, 192 | pub username: &'a str, 193 | pub password: &'a str, 194 | } 195 | 196 | #[derive(AsChangeset, Debug, Deserialize, Clone)] 197 | #[diesel(table_name = users)] 198 | pub struct UpdateUser { 199 | pub email: Option, 200 | pub username: Option, 201 | pub password: Option, 202 | pub image: Option, 203 | pub bio: Option, 204 | } 205 | -------------------------------------------------------------------------------- /src/app/drivers/middlewares/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::app::drivers::middlewares::state::AppState; 2 | use crate::app::features::user::entities::User; 3 | use crate::constants; 4 | use crate::error::AppError; 5 | use crate::utils::token; 6 | use actix_web::HttpMessage; 7 | use actix_web::{ 8 | body::EitherBody, 9 | dev::{Service, ServiceRequest, ServiceResponse, Transform}, 10 | http::Method, 11 | web::Data, 12 | Error, HttpRequest, HttpResponse, 13 | }; 14 | use futures::future::{ok, Ready}; 15 | use futures::Future; 16 | use serde_json::json; 17 | use std::pin::Pin; 18 | use uuid::Uuid; 19 | 20 | // There are two steps in middleware processing. 21 | // 1. Middleware initialization, middleware factory gets called with 22 | // next service in chain as parameter. 23 | // 2. Middleware's call method gets called with normal request. 24 | pub struct Authentication; 25 | 26 | // Middleware factory is `Transform` trait from actix-service crate 27 | // `S` - type of the next service 28 | // `B` - type of response's body 29 | impl Transform for Authentication 30 | where 31 | S: Service, Error = Error>, 32 | S::Future: 'static, 33 | B: 'static, 34 | { 35 | type Response = ServiceResponse>; 36 | type Error = Error; 37 | type InitError = (); 38 | type Transform = AuthenticationMiddleware; 39 | type Future = Ready>; 40 | 41 | fn new_transform(&self, service: S) -> Self::Future { 42 | ok(AuthenticationMiddleware { service }) 43 | } 44 | } 45 | 46 | pub struct AuthenticationMiddleware { 47 | service: S, 48 | } 49 | 50 | impl Service for AuthenticationMiddleware 51 | where 52 | S: Service, Error = Error>, 53 | S::Future: 'static, 54 | B: 'static, 55 | { 56 | type Response = ServiceResponse>; 57 | type Error = Error; 58 | type Future = Pin>>>; 59 | 60 | actix_web::dev::forward_ready!(service); 61 | 62 | fn call(&self, mut req: ServiceRequest) -> Self::Future { 63 | let is_verified = if should_skip_auth(&req) { 64 | true 65 | } else { 66 | set_auth_user(&mut req) 67 | }; 68 | if is_verified { 69 | let fut = self.service.call(req); 70 | Box::pin(async move { 71 | let res = fut.await?.map_into_left_body(); 72 | Ok(res) 73 | }) 74 | } else { 75 | Box::pin(async move { 76 | let (req, _res) = req.into_parts(); 77 | let res = HttpResponse::Unauthorized().finish().map_into_right_body(); 78 | let srv = ServiceResponse::new(req, res); 79 | Ok(srv) 80 | }) 81 | } 82 | } 83 | } 84 | 85 | fn should_skip_auth(req: &ServiceRequest) -> bool { 86 | let method = req.method(); 87 | if Method::OPTIONS == *method { 88 | return true; 89 | } 90 | 91 | SKIP_AUTH_ROUTES 92 | .iter() 93 | .any(|route| route.matches_path_and_method(req.path(), req.method())) 94 | } 95 | 96 | const TOKEN_IDENTIFIER: &str = "Token"; 97 | 98 | fn set_auth_user(req: &mut ServiceRequest) -> bool { 99 | match fetch_user(req) { 100 | Ok(user) => { 101 | req.extensions_mut().insert(user); 102 | true 103 | } 104 | Err(err_msg) => { 105 | info!("Cannot fetch user {}", err_msg); 106 | false 107 | } 108 | } 109 | } 110 | 111 | fn fetch_user(req: &ServiceRequest) -> Result { 112 | let user_id = get_user_id_from_header(req)?; 113 | req.app_data::>() 114 | .ok_or("Cannot get app state.") 115 | .and_then(|state| state.di_container.user_usecase.find_auth_user(user_id)) 116 | } 117 | 118 | fn get_user_id_from_header(req: &ServiceRequest) -> Result { 119 | req.headers() 120 | .get(constants::AUTHORIZATION) 121 | .ok_or("Cannot find authrization key-value in req header") 122 | .and_then(|auth_header| auth_header.to_str().map_err(|_err| "Cannot stringify")) 123 | .and_then(|auth_str| { 124 | if auth_str.starts_with(TOKEN_IDENTIFIER) { 125 | Ok(auth_str) 126 | } else { 127 | Err("Invalid token convention") 128 | } 129 | }) 130 | .map(|auth_str| auth_str[6..auth_str.len()].trim()) 131 | .and_then(|token| token::decode(token).map_err(|_err| "Cannot decode token.")) 132 | .map(|token| token.claims.user_id) 133 | } 134 | 135 | pub fn get_current_user(req: &HttpRequest) -> Result { 136 | req.extensions() 137 | .get::() 138 | .map(|user| user.to_owned()) 139 | .ok_or_else(|| { 140 | AppError::Unauthorized(json!({"error": "Unauthrized user. Need auth token on header."})) 141 | }) 142 | } 143 | 144 | struct SkipAuthRoute { 145 | path: &'static str, 146 | method: Method, 147 | } 148 | 149 | impl SkipAuthRoute { 150 | fn matches_path_and_method(&self, path: &str, method: &Method) -> bool { 151 | self.matches_path(path) && self.matches_method(method) 152 | } 153 | 154 | fn matches_path(&self, path: &str) -> bool { 155 | let expect_path = self.path.split('/').collect::>(); 156 | let this_path = path.split('/').collect::>(); 157 | if expect_path.len() != this_path.len() { 158 | return false; 159 | }; 160 | let path_set = expect_path.iter().zip(this_path.iter()); 161 | for (expect_path, this_path) in path_set { 162 | if SkipAuthRoute::is_slug_path(expect_path) { 163 | continue; 164 | } 165 | if expect_path != this_path { 166 | return false; 167 | } 168 | } 169 | true 170 | } 171 | 172 | fn matches_method(&self, method: &Method) -> bool { 173 | self.method == method 174 | } 175 | 176 | fn is_slug_path(text: &str) -> bool { 177 | let first = text.chars().next().unwrap_or(' '); 178 | let last = text.chars().last().unwrap_or(' '); 179 | first == '{' && last == '}' 180 | } 181 | } 182 | 183 | #[cfg(test)] 184 | mod tests { 185 | use super::*; 186 | use actix_web::http::Method; 187 | #[test] 188 | fn is_match_path_and_method_test() { 189 | let route = SkipAuthRoute { 190 | path: "/api/healthcheck", 191 | method: Method::GET, 192 | }; 193 | assert!(route.matches_path_and_method("/api/healthcheck", &Method::GET)); 194 | 195 | let route = SkipAuthRoute { 196 | path: "/api/{this-is-slug}/healthcheck", 197 | method: Method::POST, 198 | }; 199 | assert!(route.matches_path_and_method("/api/1234/healthcheck", &Method::POST)); 200 | } 201 | } 202 | 203 | const SKIP_AUTH_ROUTES: [SkipAuthRoute; 6] = [ 204 | SkipAuthRoute { 205 | path: "/api/healthcheck", 206 | method: Method::GET, 207 | }, 208 | SkipAuthRoute { 209 | path: "/api/tags", 210 | method: Method::GET, 211 | }, 212 | SkipAuthRoute { 213 | path: "/api/users", 214 | method: Method::POST, 215 | }, 216 | SkipAuthRoute { 217 | path: "/api/users/login", 218 | method: Method::POST, 219 | }, 220 | SkipAuthRoute { 221 | path: "/api/articles", 222 | method: Method::GET, 223 | }, 224 | SkipAuthRoute { 225 | path: "/api/articles/{article_title_slug}/comments", 226 | method: Method::GET, 227 | }, 228 | ]; 229 | -------------------------------------------------------------------------------- /src/app/features/article/repositories.rs: -------------------------------------------------------------------------------- 1 | use super::entities::{Article, CreateArticle, DeleteArticle, UpdateArticle}; 2 | use crate::app::features::favorite::entities::FavoriteInfo; 3 | use crate::app::features::profile::entities::Profile; 4 | use crate::app::features::tag::entities::{CreateTag, Tag}; 5 | use crate::app::features::user::entities::User; 6 | use crate::error::AppError; 7 | use crate::utils::db::DbPool; 8 | use diesel::PgConnection; 9 | use uuid::Uuid; 10 | 11 | pub trait ArticleRepository: Send + Sync + 'static { 12 | fn fetch_articles( 13 | &self, 14 | params: FetchArticlesRepositoryInput, 15 | ) -> Result<(ArticlesList, ArticlesCount), AppError>; 16 | 17 | fn fetch_article_by_slug( 18 | &self, 19 | article_title_slug: String, 20 | ) -> Result; 21 | 22 | fn create_article( 23 | &self, 24 | params: CreateArticleRepositoryInput, 25 | ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError>; 26 | 27 | fn delete_article(&self, input: DeleteArticleRepositoryInput) -> Result<(), AppError>; 28 | 29 | fn update_article( 30 | &self, 31 | input: UpdateArticleRepositoryInput, 32 | ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError>; 33 | 34 | fn fetch_article( 35 | &self, 36 | input: &FetchArticleRepositoryInput, 37 | ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError>; 38 | 39 | fn fetch_following_articles( 40 | &self, 41 | params: &FetchFollowingArticlesRepositoryInput, 42 | ) -> Result<(ArticlesList, ArticlesCount), AppError>; 43 | } 44 | #[derive(Clone)] 45 | pub struct ArticleRepositoryImpl { 46 | pool: DbPool, 47 | } 48 | 49 | impl ArticleRepositoryImpl { 50 | pub fn new(pool: DbPool) -> Self { 51 | Self { pool } 52 | } 53 | 54 | fn create_tag_list( 55 | conn: &mut PgConnection, 56 | tag_name_list: &Option>, 57 | article_id: &Uuid, 58 | ) -> Result, AppError> { 59 | let list = tag_name_list 60 | .as_ref() 61 | .map(|tag_name_list| { 62 | let records = tag_name_list 63 | .iter() 64 | .map(|name| CreateTag { name, article_id }) 65 | .collect(); 66 | Tag::create_list(conn, records) 67 | }) 68 | .unwrap_or_else(|| Ok(vec![])); 69 | list 70 | } 71 | } 72 | 73 | impl ArticleRepository for ArticleRepositoryImpl { 74 | fn fetch_articles( 75 | &self, 76 | params: FetchArticlesRepositoryInput, 77 | ) -> Result<(ArticlesList, ArticlesCount), AppError> { 78 | use crate::app::features::favorite::entities::Favorite; 79 | use crate::schema::{articles, tags, users}; 80 | use diesel::prelude::*; 81 | // ==== 82 | let conn = &mut self.pool.get()?; 83 | 84 | let query = { 85 | let mut query = articles::table.inner_join(users::table).into_boxed(); 86 | 87 | if let Some(tag_name) = ¶ms.tag { 88 | let ids = Tag::fetch_article_ids_by_name(conn, tag_name) 89 | .expect("could not fetch tagged article ids."); // TODO: us e ? or error handling 90 | query = query.filter(articles::id.eq_any(ids)); 91 | } 92 | 93 | if let Some(author_name) = ¶ms.author { 94 | let ids = Article::fetch_ids_by_author_name(conn, author_name) 95 | .expect("could not fetch authors id."); // TODO: use ? or error handling 96 | query = query.filter(articles::id.eq_any(ids)); 97 | } 98 | 99 | if let Some(username) = ¶ms.favorited { 100 | let ids = Favorite::fetch_favorited_article_ids_by_username(conn, username) 101 | .expect("could not fetch favorited articles id."); // TODO: use ? or error handling 102 | 103 | query = query.filter(articles::id.eq_any(ids)); 104 | } 105 | 106 | query 107 | }; 108 | let articles_count = query 109 | .select(diesel::dsl::count(articles::id)) 110 | .first::(conn)?; 111 | 112 | let result = { 113 | let query = { 114 | let mut query = articles::table.inner_join(users::table).into_boxed(); 115 | 116 | if let Some(tag_name) = ¶ms.tag { 117 | let ids = Tag::fetch_article_ids_by_name(conn, tag_name) 118 | .expect("could not fetch tagged article ids."); // TODO: use ? or error handling 119 | query = query.filter(articles::id.eq_any(ids)); 120 | } 121 | 122 | if let Some(author_name) = ¶ms.author { 123 | let ids = Article::fetch_ids_by_author_name(conn, author_name) 124 | .expect("could not fetch authors id."); // TODO: use ? or error handling 125 | query = query.filter(articles::id.eq_any(ids)); 126 | } 127 | 128 | if let Some(username) = ¶ms.favorited { 129 | let ids = Favorite::fetch_favorited_article_ids_by_username(conn, username) 130 | .expect("could not fetch favorited articles id."); // TODO: use ? or error handling 131 | 132 | query = query.filter(articles::id.eq_any(ids)); 133 | } 134 | 135 | query 136 | }; 137 | let article_and_user_list = 138 | query 139 | .offset(params.offset) 140 | .limit(params.limit) 141 | .load::<(Article, User)>(conn)?; 142 | 143 | let tags_list = { 144 | let articles_list = article_and_user_list 145 | .clone() 146 | .into_iter() 147 | .map(|(article, _)| article) 148 | .collect::>(); 149 | let tags_list = Tag::belonging_to(&articles_list) 150 | .order(tags::name.asc()) 151 | .load::(conn)?; 152 | let tags_list: Vec> = tags_list.grouped_by(&articles_list); 153 | tags_list 154 | }; 155 | 156 | let favorites_count_list = { 157 | let list: Result, _> = article_and_user_list 158 | .clone() 159 | .into_iter() 160 | .map(|(article, _)| article.fetch_favorites_count(conn)) 161 | .collect(); 162 | 163 | list? 164 | }; 165 | 166 | article_and_user_list 167 | .into_iter() 168 | .zip(favorites_count_list) 169 | .map(|((article, user), favorites_count)| { 170 | ( 171 | article, 172 | Profile { 173 | username: user.username, 174 | bio: user.bio, 175 | image: user.image, 176 | following: false, // NOTE: because not authz 177 | }, 178 | FavoriteInfo { 179 | is_favorited: false, // NOTE: because not authz 180 | favorites_count, 181 | }, 182 | ) 183 | }) 184 | .zip(tags_list) 185 | .collect::>() 186 | }; 187 | 188 | Ok((result, articles_count)) 189 | } 190 | 191 | fn fetch_article_by_slug( 192 | &self, 193 | article_title_slug: String, 194 | ) -> Result { 195 | let conn = &mut self.pool.get()?; 196 | 197 | let (article, author) = Article::fetch_by_slug_with_author(conn, &article_title_slug)?; 198 | 199 | let profile = author.fetch_profile(conn, &author.id)?; 200 | 201 | let tags_list = { 202 | use diesel::prelude::*; 203 | Tag::belonging_to(&article).load::(conn)? 204 | }; 205 | 206 | let favorite_info = { 207 | let is_favorited = article.is_favorited_by_user_id(conn, &author.id)?; 208 | let favorites_count = article.fetch_favorites_count(conn)?; 209 | FavoriteInfo { 210 | is_favorited, 211 | favorites_count, 212 | } 213 | }; 214 | 215 | Ok((article, profile, favorite_info, tags_list)) 216 | } 217 | 218 | fn create_article( 219 | &self, 220 | params: CreateArticleRepositoryInput, 221 | ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { 222 | let conn = &mut self.pool.get()?; 223 | 224 | let article = Article::create( 225 | conn, 226 | &CreateArticle { 227 | author_id: params.current_user.id, 228 | slug: params.slug.clone(), 229 | title: params.title.clone(), 230 | description: params.description.clone(), 231 | body: params.body.clone(), 232 | }, 233 | )?; 234 | 235 | let tag_list = Self::create_tag_list(conn, ¶ms.tag_name_list, &article.id)?; 236 | 237 | let profile = params 238 | .current_user 239 | .fetch_profile(conn, &article.author_id)?; 240 | 241 | let favorite_info = { 242 | let is_favorited = article.is_favorited_by_user_id(conn, ¶ms.current_user.id)?; 243 | let favorites_count = article.fetch_favorites_count(conn)?; 244 | FavoriteInfo { 245 | is_favorited, 246 | favorites_count, 247 | } 248 | }; 249 | 250 | Ok((article, profile, favorite_info, tag_list)) 251 | } 252 | 253 | fn delete_article(&self, input: DeleteArticleRepositoryInput) -> Result<(), AppError> { 254 | let conn = &mut self.pool.get()?; 255 | Article::delete( 256 | conn, 257 | &DeleteArticle { 258 | slug: input.slug, 259 | author_id: input.author_id, 260 | }, 261 | ) 262 | } 263 | 264 | fn update_article( 265 | &self, 266 | input: UpdateArticleRepositoryInput, 267 | ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { 268 | let conn = &mut self.pool.get()?; 269 | 270 | let article = Article::update( 271 | conn, 272 | &input.article_title_slug, 273 | &input.current_user.id, 274 | &UpdateArticle { 275 | slug: input.slug.to_owned(), 276 | title: input.title.to_owned(), 277 | description: input.description.to_owned(), 278 | body: input.body.to_owned(), 279 | }, 280 | )?; 281 | 282 | let tag_list = Tag::fetch_by_article_id(conn, &article.id)?; 283 | 284 | let profile = input.current_user.fetch_profile(conn, &article.author_id)?; 285 | 286 | let favorite_info = { 287 | let is_favorited = article.is_favorited_by_user_id(conn, &input.current_user.id)?; 288 | let favorites_count = article.fetch_favorites_count(conn)?; 289 | FavoriteInfo { 290 | is_favorited, 291 | favorites_count, 292 | } 293 | }; 294 | 295 | Ok((article, profile, favorite_info, tag_list)) 296 | } 297 | 298 | fn fetch_article( 299 | &self, 300 | input: &FetchArticleRepositoryInput, 301 | ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { 302 | let conn = &mut self.pool.get()?; 303 | let (article, author) = Article::find_with_author(conn, &input.article_id)?; 304 | 305 | let profile = input.current_user.fetch_profile(conn, &author.id)?; 306 | 307 | let favorite_info = { 308 | let is_favorited = article.is_favorited_by_user_id(conn, &input.current_user.id)?; 309 | let favorites_count = article.fetch_favorites_count(conn)?; 310 | FavoriteInfo { 311 | is_favorited, 312 | favorites_count, 313 | } 314 | }; 315 | 316 | let tags_list = { 317 | use diesel::prelude::*; 318 | Tag::belonging_to(&article).load::(conn)? 319 | }; 320 | 321 | Ok((article, profile, favorite_info, tags_list)) 322 | } 323 | 324 | fn fetch_following_articles( 325 | &self, 326 | params: &FetchFollowingArticlesRepositoryInput, 327 | ) -> Result<(ArticlesList, ArticlesCount), AppError> { 328 | use crate::app::features::follow::entities::Follow; 329 | use crate::schema::articles::dsl::*; 330 | use crate::schema::follows; 331 | use crate::schema::{articles, users}; 332 | use diesel::prelude::*; 333 | 334 | let conn = &mut self.pool.get()?; 335 | 336 | let create_query = { 337 | let ids = Follow::fetch_folowee_ids_by_follower_id(conn, ¶ms.current_user.id)?; 338 | articles.filter(articles::author_id.eq_any(ids)) 339 | }; 340 | 341 | let articles_list = { 342 | let article_and_user_list = create_query 343 | .to_owned() 344 | .inner_join(users::table) 345 | .limit(params.limit) 346 | .offset(params.offset) 347 | .order(articles::created_at.desc()) 348 | .get_results::<(Article, User)>(conn)?; 349 | 350 | let tags_list = { 351 | let articles_list = article_and_user_list 352 | .clone() // TODO: avoid clone 353 | .into_iter() 354 | .map(|(article, _)| article) 355 | .collect::>(); 356 | 357 | let tags_list = Tag::belonging_to(&articles_list).load::(conn)?; 358 | let tags_list: Vec> = tags_list.grouped_by(&articles_list); 359 | tags_list 360 | }; 361 | 362 | let follows_list = { 363 | let user_ids_list = article_and_user_list 364 | .clone() // TODO: avoid clone 365 | .into_iter() 366 | .map(|(_, user)| user.id) 367 | .collect::>(); 368 | 369 | let list = follows::table 370 | .filter(Follow::with_follower(¶ms.current_user.id)) 371 | .filter(follows::followee_id.eq_any(user_ids_list)) 372 | .get_results::(conn)?; 373 | 374 | list.into_iter() 375 | }; 376 | 377 | let favorites_count_list = { 378 | let list: Result, _> = article_and_user_list 379 | .clone() 380 | .into_iter() 381 | .map(|(article, _)| article.fetch_favorites_count(conn)) 382 | .collect(); 383 | 384 | list? 385 | }; 386 | 387 | let favorited_article_ids = params.current_user.fetch_favorited_article_ids(conn)?; 388 | let is_favorited_by_me = |article: &Article| { 389 | favorited_article_ids 390 | .iter() 391 | .copied() 392 | .any(|_id| _id == article.id) 393 | }; 394 | 395 | article_and_user_list 396 | .into_iter() 397 | .zip(favorites_count_list) 398 | .map(|((article, user), favorites_count)| { 399 | let following = follows_list.clone().any(|item| item.followee_id == user.id); 400 | let is_favorited = is_favorited_by_me(&article); 401 | ( 402 | article, 403 | Profile { 404 | username: user.username, 405 | bio: user.bio, 406 | image: user.image, 407 | following: following.to_owned(), 408 | }, 409 | FavoriteInfo { 410 | is_favorited, 411 | favorites_count, 412 | }, 413 | ) 414 | }) 415 | .zip(tags_list) 416 | .collect::>() 417 | }; 418 | 419 | let articles_count = create_query 420 | .select(diesel::dsl::count(articles::id)) 421 | .first::(conn)?; 422 | 423 | Ok((articles_list, articles_count)) 424 | } 425 | } 426 | 427 | pub struct CreateArticleRepositoryInput { 428 | pub slug: String, 429 | pub title: String, 430 | pub description: String, 431 | pub body: String, 432 | pub tag_name_list: Option>, 433 | pub current_user: User, 434 | } 435 | 436 | pub struct DeleteArticleRepositoryInput { 437 | pub slug: String, 438 | pub author_id: Uuid, 439 | } 440 | 441 | pub struct UpdateArticleRepositoryInput { 442 | pub current_user: User, 443 | pub article_title_slug: String, 444 | pub slug: Option, 445 | pub title: Option, 446 | pub description: Option, 447 | pub body: Option, 448 | } 449 | 450 | pub type FetchArticleBySlugOutput = (Article, Profile, FavoriteInfo, Vec); 451 | 452 | pub struct FetchArticlesRepositoryInput { 453 | pub tag: Option, 454 | pub author: Option, 455 | pub favorited: Option, 456 | pub offset: i64, 457 | pub limit: i64, 458 | } 459 | 460 | pub struct FetchArticleRepositoryInput { 461 | pub article_id: Uuid, 462 | pub current_user: User, 463 | } 464 | 465 | pub struct FetchFollowingArticlesRepositoryInput { 466 | pub current_user: User, 467 | pub offset: i64, 468 | pub limit: i64, 469 | } 470 | 471 | type ArticlesCount = i64; 472 | type ArticlesListInner = (Article, Profile, FavoriteInfo); 473 | pub type ArticlesList = Vec<(ArticlesListInner, Vec)>; 474 | -------------------------------------------------------------------------------- /e2e/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: Conduit API 4 | description: Conduit API 5 | contact: 6 | name: RealWorld 7 | url: https://realworld.io 8 | license: 9 | name: MIT License 10 | url: https://opensource.org/licenses/MIT 11 | version: 1.0.0 12 | servers: 13 | - url: /api 14 | paths: 15 | /users/login: 16 | post: 17 | tags: 18 | - User and Authentication 19 | summary: Existing user login 20 | description: Login for existing user 21 | operationId: Login 22 | requestBody: 23 | description: Credentials to use 24 | content: 25 | application/json: 26 | schema: 27 | $ref: "#/components/schemas/LoginUserRequest" 28 | required: true 29 | responses: 30 | 200: 31 | description: OK 32 | content: 33 | application/json: 34 | schema: 35 | $ref: "#/components/schemas/UserResponse" 36 | 401: 37 | description: Unauthorized 38 | content: {} 39 | 422: 40 | description: Unexpected error 41 | content: 42 | application/json: 43 | schema: 44 | $ref: "#/components/schemas/GenericErrorModel" 45 | x-codegen-request-body-name: body 46 | /users: 47 | post: 48 | tags: 49 | - User and Authentication 50 | summary: Register a new user 51 | description: Register a new user 52 | operationId: CreateUser 53 | requestBody: 54 | description: Details of the new user to register 55 | content: 56 | application/json: 57 | schema: 58 | $ref: "#/components/schemas/NewUserRequest" 59 | required: true 60 | responses: 61 | 201: 62 | description: OK 63 | content: 64 | application/json: 65 | schema: 66 | $ref: "#/components/schemas/UserResponse" 67 | 422: 68 | description: Unexpected error 69 | content: 70 | application/json: 71 | schema: 72 | $ref: "#/components/schemas/GenericErrorModel" 73 | x-codegen-request-body-name: body 74 | /user: 75 | get: 76 | tags: 77 | - User and Authentication 78 | summary: Get current user 79 | description: Gets the currently logged-in user 80 | operationId: GetCurrentUser 81 | responses: 82 | 200: 83 | description: OK 84 | content: 85 | application/json: 86 | schema: 87 | $ref: "#/components/schemas/UserResponse" 88 | 401: 89 | description: Unauthorized 90 | content: {} 91 | 422: 92 | description: Unexpected error 93 | content: 94 | application/json: 95 | schema: 96 | $ref: "#/components/schemas/GenericErrorModel" 97 | security: 98 | - Token: [] 99 | put: 100 | tags: 101 | - User and Authentication 102 | summary: Update current user 103 | description: Updated user information for current user 104 | operationId: UpdateCurrentUser 105 | requestBody: 106 | description: User details to update. At least **one** field is required. 107 | content: 108 | application/json: 109 | schema: 110 | $ref: "#/components/schemas/UpdateUserRequest" 111 | required: true 112 | responses: 113 | 200: 114 | description: OK 115 | content: 116 | application/json: 117 | schema: 118 | $ref: "#/components/schemas/UserResponse" 119 | 401: 120 | description: Unauthorized 121 | content: {} 122 | 422: 123 | description: Unexpected error 124 | content: 125 | application/json: 126 | schema: 127 | $ref: "#/components/schemas/GenericErrorModel" 128 | security: 129 | - Token: [] 130 | x-codegen-request-body-name: body 131 | /profiles/{username}: 132 | get: 133 | tags: 134 | - Profile 135 | summary: Get a profile 136 | description: Get a profile of a user of the system. Auth is optional 137 | operationId: GetProfileByUsername 138 | parameters: 139 | - name: username 140 | in: path 141 | description: Username of the profile to get 142 | required: true 143 | schema: 144 | type: string 145 | responses: 146 | 200: 147 | description: OK 148 | content: 149 | application/json: 150 | schema: 151 | $ref: "#/components/schemas/ProfileResponse" 152 | 401: 153 | description: Unauthorized 154 | content: {} 155 | 422: 156 | description: Unexpected error 157 | content: 158 | application/json: 159 | schema: 160 | $ref: "#/components/schemas/GenericErrorModel" 161 | /profiles/{username}/follow: 162 | post: 163 | tags: 164 | - Profile 165 | summary: Follow a user 166 | description: Follow a user by username 167 | operationId: FollowUserByUsername 168 | parameters: 169 | - name: username 170 | in: path 171 | description: Username of the profile you want to follow 172 | required: true 173 | schema: 174 | type: string 175 | responses: 176 | 200: 177 | description: OK 178 | content: 179 | application/json: 180 | schema: 181 | $ref: "#/components/schemas/ProfileResponse" 182 | 401: 183 | description: Unauthorized 184 | content: {} 185 | 422: 186 | description: Unexpected error 187 | content: 188 | application/json: 189 | schema: 190 | $ref: "#/components/schemas/GenericErrorModel" 191 | security: 192 | - Token: [] 193 | delete: 194 | tags: 195 | - Profile 196 | summary: Unfollow a user 197 | description: Unfollow a user by username 198 | operationId: UnfollowUserByUsername 199 | parameters: 200 | - name: username 201 | in: path 202 | description: Username of the profile you want to unfollow 203 | required: true 204 | schema: 205 | type: string 206 | responses: 207 | 200: 208 | description: OK 209 | content: 210 | application/json: 211 | schema: 212 | $ref: "#/components/schemas/ProfileResponse" 213 | 401: 214 | description: Unauthorized 215 | content: {} 216 | 422: 217 | description: Unexpected error 218 | content: 219 | application/json: 220 | schema: 221 | $ref: "#/components/schemas/GenericErrorModel" 222 | security: 223 | - Token: [] 224 | /articles/feed: 225 | get: 226 | tags: 227 | - Articles 228 | summary: Get recent articles from users you follow 229 | description: 230 | Get most recent articles from users you follow. Use query parameters 231 | to limit. Auth is required 232 | operationId: GetArticlesFeed 233 | parameters: 234 | - name: limit 235 | in: query 236 | description: Limit number of articles returned (default is 20) 237 | schema: 238 | type: integer 239 | default: 20 240 | - name: offset 241 | in: query 242 | description: Offset/skip number of articles (default is 0) 243 | schema: 244 | type: integer 245 | default: 0 246 | responses: 247 | 200: 248 | description: OK 249 | content: 250 | application/json: 251 | schema: 252 | $ref: "#/components/schemas/MultipleArticlesResponse" 253 | 401: 254 | description: Unauthorized 255 | content: {} 256 | 422: 257 | description: Unexpected error 258 | content: 259 | application/json: 260 | schema: 261 | $ref: "#/components/schemas/GenericErrorModel" 262 | security: 263 | - Token: [] 264 | /articles: 265 | get: 266 | tags: 267 | - Articles 268 | summary: Get recent articles globally 269 | description: 270 | Get most recent articles globally. Use query parameters to filter 271 | results. Auth is optional 272 | operationId: GetArticles 273 | parameters: 274 | - name: tag 275 | in: query 276 | description: Filter by tag 277 | schema: 278 | type: string 279 | - name: author 280 | in: query 281 | description: Filter by author (username) 282 | schema: 283 | type: string 284 | - name: favorited 285 | in: query 286 | description: Filter by favorites of a user (username) 287 | schema: 288 | type: string 289 | - name: limit 290 | in: query 291 | description: Limit number of articles returned (default is 20) 292 | schema: 293 | type: integer 294 | default: 20 295 | - name: offset 296 | in: query 297 | description: Offset/skip number of articles (default is 0) 298 | schema: 299 | type: integer 300 | default: 0 301 | responses: 302 | 200: 303 | description: OK 304 | content: 305 | application/json: 306 | schema: 307 | $ref: "#/components/schemas/MultipleArticlesResponse" 308 | 401: 309 | description: Unauthorized 310 | content: {} 311 | 422: 312 | description: Unexpected error 313 | content: 314 | application/json: 315 | schema: 316 | $ref: "#/components/schemas/GenericErrorModel" 317 | post: 318 | tags: 319 | - Articles 320 | summary: Create an article 321 | description: Create an article. Auth is required 322 | operationId: CreateArticle 323 | requestBody: 324 | description: Article to create 325 | content: 326 | application/json: 327 | schema: 328 | $ref: "#/components/schemas/NewArticleRequest" 329 | required: true 330 | responses: 331 | 201: 332 | description: OK 333 | content: 334 | application/json: 335 | schema: 336 | $ref: "#/components/schemas/SingleArticleResponse" 337 | 401: 338 | description: Unauthorized 339 | content: {} 340 | 422: 341 | description: Unexpected error 342 | content: 343 | application/json: 344 | schema: 345 | $ref: "#/components/schemas/GenericErrorModel" 346 | security: 347 | - Token: [] 348 | x-codegen-request-body-name: article 349 | /articles/{slug}: 350 | get: 351 | tags: 352 | - Articles 353 | summary: Get an article 354 | description: Get an article. Auth not required 355 | operationId: GetArticle 356 | parameters: 357 | - name: slug 358 | in: path 359 | description: Slug of the article to get 360 | required: true 361 | schema: 362 | type: string 363 | responses: 364 | 200: 365 | description: OK 366 | content: 367 | application/json: 368 | schema: 369 | $ref: "#/components/schemas/SingleArticleResponse" 370 | 422: 371 | description: Unexpected error 372 | content: 373 | application/json: 374 | schema: 375 | $ref: "#/components/schemas/GenericErrorModel" 376 | put: 377 | tags: 378 | - Articles 379 | summary: Update an article 380 | description: Update an article. Auth is required 381 | operationId: UpdateArticle 382 | parameters: 383 | - name: slug 384 | in: path 385 | description: Slug of the article to update 386 | required: true 387 | schema: 388 | type: string 389 | requestBody: 390 | description: Article to update 391 | content: 392 | application/json: 393 | schema: 394 | $ref: "#/components/schemas/UpdateArticleRequest" 395 | required: true 396 | responses: 397 | 200: 398 | description: OK 399 | content: 400 | application/json: 401 | schema: 402 | $ref: "#/components/schemas/SingleArticleResponse" 403 | 401: 404 | description: Unauthorized 405 | content: {} 406 | 422: 407 | description: Unexpected error 408 | content: 409 | application/json: 410 | schema: 411 | $ref: "#/components/schemas/GenericErrorModel" 412 | security: 413 | - Token: [] 414 | x-codegen-request-body-name: article 415 | delete: 416 | tags: 417 | - Articles 418 | summary: Delete an article 419 | description: Delete an article. Auth is required 420 | operationId: DeleteArticle 421 | parameters: 422 | - name: slug 423 | in: path 424 | description: Slug of the article to delete 425 | required: true 426 | schema: 427 | type: string 428 | responses: 429 | 200: 430 | description: OK 431 | content: {} 432 | 401: 433 | description: Unauthorized 434 | content: {} 435 | 422: 436 | description: Unexpected error 437 | content: 438 | application/json: 439 | schema: 440 | $ref: "#/components/schemas/GenericErrorModel" 441 | security: 442 | - Token: [] 443 | /articles/{slug}/comments: 444 | get: 445 | tags: 446 | - Comments 447 | summary: Get comments for an article 448 | description: Get the comments for an article. Auth is optional 449 | operationId: GetArticleComments 450 | parameters: 451 | - name: slug 452 | in: path 453 | description: Slug of the article that you want to get comments for 454 | required: true 455 | schema: 456 | type: string 457 | responses: 458 | 200: 459 | description: OK 460 | content: 461 | application/json: 462 | schema: 463 | $ref: "#/components/schemas/MultipleCommentsResponse" 464 | 401: 465 | description: Unauthorized 466 | content: {} 467 | 422: 468 | description: Unexpected error 469 | content: 470 | application/json: 471 | schema: 472 | $ref: "#/components/schemas/GenericErrorModel" 473 | post: 474 | tags: 475 | - Comments 476 | summary: Create a comment for an article 477 | description: Create a comment for an article. Auth is required 478 | operationId: CreateArticleComment 479 | parameters: 480 | - name: slug 481 | in: path 482 | description: Slug of the article that you want to create a comment for 483 | required: true 484 | schema: 485 | type: string 486 | requestBody: 487 | description: Comment you want to create 488 | content: 489 | application/json: 490 | schema: 491 | $ref: "#/components/schemas/NewCommentRequest" 492 | required: true 493 | responses: 494 | 200: 495 | description: OK 496 | content: 497 | application/json: 498 | schema: 499 | $ref: "#/components/schemas/SingleCommentResponse" 500 | 401: 501 | description: Unauthorized 502 | content: {} 503 | 422: 504 | description: Unexpected error 505 | content: 506 | application/json: 507 | schema: 508 | $ref: "#/components/schemas/GenericErrorModel" 509 | security: 510 | - Token: [] 511 | x-codegen-request-body-name: comment 512 | /articles/{slug}/comments/{id}: 513 | delete: 514 | tags: 515 | - Comments 516 | summary: Delete a comment for an article 517 | description: Delete a comment for an article. Auth is required 518 | operationId: DeleteArticleComment 519 | parameters: 520 | - name: slug 521 | in: path 522 | description: Slug of the article that you want to delete a comment for 523 | required: true 524 | schema: 525 | type: string 526 | - name: id 527 | in: path 528 | description: ID of the comment you want to delete 529 | required: true 530 | schema: 531 | type: integer 532 | responses: 533 | 200: 534 | description: OK 535 | content: {} 536 | 401: 537 | description: Unauthorized 538 | content: {} 539 | 422: 540 | description: Unexpected error 541 | content: 542 | application/json: 543 | schema: 544 | $ref: "#/components/schemas/GenericErrorModel" 545 | security: 546 | - Token: [] 547 | /articles/{slug}/favorite: 548 | post: 549 | tags: 550 | - Favorites 551 | summary: Favorite an article 552 | description: Favorite an article. Auth is required 553 | operationId: CreateArticleFavorite 554 | parameters: 555 | - name: slug 556 | in: path 557 | description: Slug of the article that you want to favorite 558 | required: true 559 | schema: 560 | type: string 561 | responses: 562 | 200: 563 | description: OK 564 | content: 565 | application/json: 566 | schema: 567 | $ref: "#/components/schemas/SingleArticleResponse" 568 | 401: 569 | description: Unauthorized 570 | content: {} 571 | 422: 572 | description: Unexpected error 573 | content: 574 | application/json: 575 | schema: 576 | $ref: "#/components/schemas/GenericErrorModel" 577 | security: 578 | - Token: [] 579 | delete: 580 | tags: 581 | - Favorites 582 | summary: Unfavorite an article 583 | description: Unfavorite an article. Auth is required 584 | operationId: DeleteArticleFavorite 585 | parameters: 586 | - name: slug 587 | in: path 588 | description: Slug of the article that you want to unfavorite 589 | required: true 590 | schema: 591 | type: string 592 | responses: 593 | 200: 594 | description: OK 595 | content: 596 | application/json: 597 | schema: 598 | $ref: "#/components/schemas/SingleArticleResponse" 599 | 401: 600 | description: Unauthorized 601 | content: {} 602 | 422: 603 | description: Unexpected error 604 | content: 605 | application/json: 606 | schema: 607 | $ref: "#/components/schemas/GenericErrorModel" 608 | security: 609 | - Token: [] 610 | /tags: 611 | get: 612 | summary: Get tags 613 | description: Get tags. Auth not required 614 | responses: 615 | 200: 616 | description: OK 617 | content: 618 | application/json: 619 | schema: 620 | $ref: "#/components/schemas/TagsResponse" 621 | 422: 622 | description: Unexpected error 623 | content: 624 | application/json: 625 | schema: 626 | $ref: "#/components/schemas/GenericErrorModel" 627 | components: 628 | schemas: 629 | LoginUser: 630 | required: 631 | - email 632 | - password 633 | type: object 634 | properties: 635 | email: 636 | type: string 637 | password: 638 | type: string 639 | format: password 640 | LoginUserRequest: 641 | required: 642 | - user 643 | type: object 644 | properties: 645 | user: 646 | $ref: "#/components/schemas/LoginUser" 647 | NewUser: 648 | required: 649 | - email 650 | - password 651 | - username 652 | type: object 653 | properties: 654 | username: 655 | type: string 656 | email: 657 | type: string 658 | password: 659 | type: string 660 | format: password 661 | NewUserRequest: 662 | required: 663 | - user 664 | type: object 665 | properties: 666 | user: 667 | $ref: "#/components/schemas/NewUser" 668 | User: 669 | required: 670 | - bio 671 | - email 672 | - image 673 | - token 674 | - username 675 | type: object 676 | properties: 677 | email: 678 | type: string 679 | token: 680 | type: string 681 | username: 682 | type: string 683 | bio: 684 | type: string 685 | image: 686 | type: string 687 | UserResponse: 688 | required: 689 | - user 690 | type: object 691 | properties: 692 | user: 693 | $ref: "#/components/schemas/User" 694 | UpdateUser: 695 | type: object 696 | properties: 697 | email: 698 | type: string 699 | token: 700 | type: string 701 | username: 702 | type: string 703 | bio: 704 | type: string 705 | image: 706 | type: string 707 | UpdateUserRequest: 708 | required: 709 | - user 710 | type: object 711 | properties: 712 | user: 713 | $ref: "#/components/schemas/UpdateUser" 714 | ProfileResponse: 715 | required: 716 | - profile 717 | type: object 718 | properties: 719 | profile: 720 | $ref: "#/components/schemas/Profile" 721 | Profile: 722 | required: 723 | - bio 724 | - following 725 | - image 726 | - username 727 | type: object 728 | properties: 729 | username: 730 | type: string 731 | bio: 732 | type: string 733 | image: 734 | type: string 735 | following: 736 | type: boolean 737 | Article: 738 | required: 739 | - author 740 | - body 741 | - createdAt 742 | - description 743 | - favorited 744 | - favoritesCount 745 | - slug 746 | - tagList 747 | - title 748 | - updatedAt 749 | type: object 750 | properties: 751 | slug: 752 | type: string 753 | title: 754 | type: string 755 | description: 756 | type: string 757 | body: 758 | type: string 759 | tagList: 760 | type: array 761 | items: 762 | type: string 763 | createdAt: 764 | type: string 765 | format: date-time 766 | updatedAt: 767 | type: string 768 | format: date-time 769 | favorited: 770 | type: boolean 771 | favoritesCount: 772 | type: integer 773 | author: 774 | $ref: "#/components/schemas/Profile" 775 | SingleArticleResponse: 776 | required: 777 | - article 778 | type: object 779 | properties: 780 | article: 781 | $ref: "#/components/schemas/Article" 782 | MultipleArticlesResponse: 783 | required: 784 | - articles 785 | - articlesCount 786 | type: object 787 | properties: 788 | articles: 789 | type: array 790 | items: 791 | $ref: "#/components/schemas/Article" 792 | articlesCount: 793 | type: integer 794 | NewArticle: 795 | required: 796 | - body 797 | - description 798 | - title 799 | type: object 800 | properties: 801 | title: 802 | type: string 803 | description: 804 | type: string 805 | body: 806 | type: string 807 | tagList: 808 | type: array 809 | items: 810 | type: string 811 | NewArticleRequest: 812 | required: 813 | - article 814 | type: object 815 | properties: 816 | article: 817 | $ref: "#/components/schemas/NewArticle" 818 | UpdateArticle: 819 | type: object 820 | properties: 821 | title: 822 | type: string 823 | description: 824 | type: string 825 | body: 826 | type: string 827 | UpdateArticleRequest: 828 | required: 829 | - article 830 | type: object 831 | properties: 832 | article: 833 | $ref: "#/components/schemas/UpdateArticle" 834 | Comment: 835 | required: 836 | - author 837 | - body 838 | - createdAt 839 | - id 840 | - updatedAt 841 | type: object 842 | properties: 843 | id: 844 | type: integer 845 | createdAt: 846 | type: string 847 | format: date-time 848 | updatedAt: 849 | type: string 850 | format: date-time 851 | body: 852 | type: string 853 | author: 854 | $ref: "#/components/schemas/Profile" 855 | SingleCommentResponse: 856 | required: 857 | - comment 858 | type: object 859 | properties: 860 | comment: 861 | $ref: "#/components/schemas/Comment" 862 | MultipleCommentsResponse: 863 | required: 864 | - comments 865 | type: object 866 | properties: 867 | comments: 868 | type: array 869 | items: 870 | $ref: "#/components/schemas/Comment" 871 | NewComment: 872 | required: 873 | - body 874 | type: object 875 | properties: 876 | body: 877 | type: string 878 | NewCommentRequest: 879 | required: 880 | - comment 881 | type: object 882 | properties: 883 | comment: 884 | $ref: "#/components/schemas/NewComment" 885 | TagsResponse: 886 | required: 887 | - tags 888 | type: object 889 | properties: 890 | tags: 891 | type: array 892 | items: 893 | type: string 894 | GenericErrorModel: 895 | required: 896 | - errors 897 | type: object 898 | properties: 899 | errors: 900 | required: 901 | - body 902 | type: object 903 | properties: 904 | body: 905 | type: array 906 | items: 907 | type: string 908 | securitySchemes: 909 | Token: 910 | type: apiKey 911 | description: 912 | "For accessing the protected API resources, you must have received\ 913 | \ a a valid JWT token after registering or logging in. This JWT token must\ 914 | \ then be used for all protected resources by passing it in via the 'Authorization'\ 915 | \ header.\n\nA JWT token is generated by the API by either registering via\ 916 | \ /users or logging in via /users/login.\n\nThe following format must be in\ 917 | \ the 'Authorization' header :\n\n Token xxxxxx.yyyyyyy.zzzzzz\n \n" 918 | name: Authorization 919 | in: header 920 | --------------------------------------------------------------------------------