feat(v1): {Post,User}.FromRequest and some new fields

This commit is contained in:
Franek 2025-04-27 19:52:02 +02:00
parent 905baba00a
commit 4a6e38c9ab
6 changed files with 170 additions and 45 deletions

View File

@ -2,7 +2,11 @@ CREATE TABLE posts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL, title VARCHAR NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
author_id INTEGER NOT NULL author_id INTEGER NOT NULL,
tags TEXT[] NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_updated_at INTEGER
); );
CREATE INDEX idx_author_id ON posts(author_id); CREATE INDEX idx_author_id ON posts(author_id);

View File

@ -1,12 +1,22 @@
use serde::{Serialize, Deserialize}; use actix_web::{web::Data, FromRequest};
use serde::{Deserialize, Serialize};
use chrono::NaiveDateTime;
use diesel::prelude::*; use diesel::prelude::*;
use std::pin::Pin;
use crate::{schema::posts::dsl::{id as schema_post_id, posts}, State};
#[derive(Queryable, Serialize, Deserialize)] #[derive(Queryable, Serialize, Deserialize)]
pub struct Post { pub struct Post {
pub id: i32, pub id: i32,
pub title: String, pub title: String,
pub content: String, pub content: String,
pub author_id: i32 pub author_id: i32,
pub tags: Vec<Option<String>>,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
pub last_updated_by: Option<i32>
} }
#[derive(Insertable, Serialize, Deserialize)] #[derive(Insertable, Serialize, Deserialize)]
@ -14,5 +24,62 @@ pub struct Post {
pub struct NewPost { pub struct NewPost {
pub title: String, pub title: String,
pub content: String, pub content: String,
pub author_id: i32 pub author_id: i32,
pub tags: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, AsChangeset)]
#[diesel(table_name = crate::schema::posts)]
pub struct EditPost {
pub title: String,
pub content: String,
pub tags: Option<Vec<String>>,
}
impl FromRequest for Post {
type Error = actix_web::error::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(
req: &actix_web::HttpRequest,
_: &mut actix_web::dev::Payload,
) -> Self::Future {
let req = req.clone();
Box::pin(async move {
let state = req.app_data::<Data<State>>();
if state.is_none() {
return Err(actix_web::error::ErrorInternalServerError(
"cannot retrieve app state, therefore can not retrieve post data",
));
}
let state = state.unwrap();
let mut conn = match state.db.get() {
Ok(conn) => conn,
Err(..) => Err(actix_web::error::ErrorInternalServerError(
"cannot retrieve database connection from the pool",
))?,
};
let post_id = actix_web::web::Path::<i32>::extract(&req).await;
match post_id {
Ok(id) => {
let post = posts
.filter(schema_post_id.eq(id.into_inner()))
.first::<Post>(&mut conn)
.optional();
match post {
Ok(Some(post)) => Ok(post),
Ok(None) => Err(actix_web::error::ErrorBadRequest(
"post with this ID is not found",
)),
Err(why) => Err(actix_web::error::ErrorInternalServerError(why)),
}
}
Err(why) => Err(actix_web::error::ErrorInternalServerError(why)),
}
})
}
} }

View File

@ -3,7 +3,7 @@ use serde::{Serialize, Deserialize};
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use diesel::prelude::*; use diesel::prelude::*;
#[derive(Queryable, Serialize, Deserialize)] #[derive(Queryable, Serialize, Deserialize, Debug)]
pub struct User { pub struct User {
pub id: i32, pub id: i32,
pub username: String, pub username: String,

View File

@ -6,6 +6,10 @@ diesel::table! {
title -> Varchar, title -> Varchar,
content -> Text, content -> Text,
author_id -> Int4, author_id -> Int4,
tags -> Array<Nullable<Text>>,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
last_updated_by -> Nullable<Int4>,
} }
} }

View File

@ -1,15 +1,18 @@
use actix_web::{ use actix_web::{
error::{ErrorBadRequest, ErrorInternalServerError}, dev::Payload,
web::Data, HttpRequest, dev::Payload, error::{ErrorBadRequest, ErrorInternalServerError, ErrorNotFound},
FromRequest, Error web::Data,
Error, FromRequest, HttpRequest,
}; };
use jsonwebtoken::{Header, EncodingKey, DecodingKey, Validation};
use serde::{Serialize, Deserialize};
use std::future::{self, ready};
use chrono::Utc; use chrono::Utc;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::future::{self, ready};
use crate::State; use crate::schema::users::dsl::{id as schema_user_id, users};
use crate::{database::models::user::User, State};
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
static WEEK: i64 = 60 * 60 * 24 * 7; static WEEK: i64 = 60 * 60 * 24 * 7;
@ -31,8 +34,9 @@ impl Claims {
jsonwebtoken::encode( jsonwebtoken::encode(
&Header::default(), &Header::default(),
&payload, &payload,
&EncodingKey::from_secret(secret.as_ref()) &EncodingKey::from_secret(secret.as_ref()),
).unwrap() )
.unwrap()
} }
} }
@ -40,6 +44,23 @@ impl Claims {
pub struct AuthorizedUser { pub struct AuthorizedUser {
pub token: String, pub token: String,
pub user_id: i32, pub user_id: i32,
pub user: User,
}
fn get_user(state: &Data<State>, user_id: i32) -> Option<User> {
let mut conn = match state.db.get() {
Ok(conn) => conn,
Err(..) => None?,
};
let user = users
.filter(schema_user_id.eq(user_id))
.first::<User>(&mut conn);
match user {
Ok(user) => Some(user),
Err(..) => None,
}
} }
impl FromRequest for AuthorizedUser { impl FromRequest for AuthorizedUser {
@ -50,11 +71,11 @@ impl FromRequest for AuthorizedUser {
let state = req.app_data::<Data<State>>(); let state = req.app_data::<Data<State>>();
let header = req.headers().get("Authorization"); let header = req.headers().get("Authorization");
if header.is_none() { if header.is_none() {
return future::ready(Err(ErrorBadRequest("no Authorization header specified"))) return future::ready(Err(ErrorBadRequest("no Authorization header specified")));
} }
if state.is_none() { if state.is_none() {
return future::ready(Err(ErrorInternalServerError("cannot retrieve JWT token"))) return future::ready(Err(ErrorInternalServerError("cannot retrieve JWT token")));
} }
let state = state.unwrap(); let state = state.unwrap();
@ -69,7 +90,7 @@ impl FromRequest for AuthorizedUser {
let claims = jsonwebtoken::decode::<Claims>( let claims = jsonwebtoken::decode::<Claims>(
parts[1], parts[1],
&DecodingKey::from_secret(state.config.jwt_secret.as_ref()), &DecodingKey::from_secret(state.config.jwt_secret.as_ref()),
&Validation::default() &Validation::default(),
); );
if claims.is_err() { if claims.is_err() {
@ -77,12 +98,22 @@ impl FromRequest for AuthorizedUser {
} }
let claims = claims.unwrap().claims; let claims = claims.unwrap().claims;
let authorized_user = AuthorizedUser { let user = get_user(state, claims.user_id);
user_id: claims.user_id,
token: v.to_string()
};
ready(Ok(authorized_user)) match user {
Some(user) => {
let authorized_user = AuthorizedUser {
user_id: claims.user_id,
token: v.to_string(),
user,
};
ready(Ok(authorized_user))
}
None => ready(Err(ErrorNotFound(
"invalid login credentials, user not found",
))),
}
} }
Err(e) => ready(Err(ErrorBadRequest(e))), Err(e) => ready(Err(ErrorBadRequest(e))),
} }

View File

@ -1,19 +1,17 @@
use actix_web::{ use actix_web::{
get, get,
http::StatusCode, http::StatusCode,
put, patch, put,
web::{scope, Data, Json, ServiceConfig}, web::{scope, Data, Json, ServiceConfig},
HttpResponse, Responder, HttpResponse, Responder,
}; };
use diesel::{insert_into, ExpressionMethods, QueryDsl, RunQueryDsl}; use diesel::{insert_into, ExpressionMethods, QueryDsl, RunQueryDsl};
use crate::schema::posts::dsl::*;
use crate::schema::users::dsl::{id as user_id, users};
use crate::utils::response::failure; use crate::utils::response::failure;
use crate::State; use crate::State;
use crate::{database::models::post::EditPost, schema::posts::dsl as posts_dsl};
use crate::{ use crate::{
database::models::post::{NewPost, Post}, database::models::post::{NewPost, Post},
database::models::user::User,
utils::jwt::AuthorizedUser, utils::jwt::AuthorizedUser,
}; };
@ -27,7 +25,7 @@ async fn get(state: Data<State>) -> impl Responder {
} }
}; };
match posts.load::<Post>(&mut conn) { match posts_dsl::posts.load::<Post>(&mut conn) {
Ok(data) => HttpResponse::Ok().json(data), Ok(data) => HttpResponse::Ok().json(data),
Err(why) => HttpResponse::InternalServerError().body(why.to_string()), Err(why) => HttpResponse::InternalServerError().body(why.to_string()),
} }
@ -37,7 +35,7 @@ async fn get(state: Data<State>) -> impl Responder {
async fn create_post( async fn create_post(
state: Data<State>, state: Data<State>,
auth: AuthorizedUser, auth: AuthorizedUser,
mut post: Json<NewPost>, post: Json<NewPost>,
) -> impl Responder { ) -> impl Responder {
let mut conn = match state.db.get() { let mut conn = match state.db.get() {
Ok(conn) => conn, Ok(conn) => conn,
@ -47,18 +45,39 @@ async fn create_post(
} }
}; };
let user = users match insert_into(posts_dsl::posts)
.filter(user_id.eq(auth.user_id)) .values((post.0, posts_dsl::author_id.eq(auth.user_id)))
.first::<User>(&mut conn); .execute(&mut conn)
{
if user.is_err() { Ok(_) => HttpResponse::new(StatusCode::NO_CONTENT),
return HttpResponse::InternalServerError() Err(why) => HttpResponse::InternalServerError().json(failure(why.to_string())),
.json(failure("cannot retrieve user from JWT token. sorry."));
} else {
post.author_id = user.unwrap().id;
} }
}
match insert_into(posts).values(post.0).execute(&mut conn) { #[patch("/{id}")]
async fn update_post(
state: Data<State>,
auth: AuthorizedUser,
post: Post,
new_post: Json<EditPost>,
) -> impl Responder {
let mut conn = match state.db.get() {
Ok(conn) => conn,
Err(why) => {
return HttpResponse::InternalServerError()
.body(format!("database connection error: {}", why))
}
};
// TODO: Security, anti-abuse checks
match diesel::update(posts_dsl::posts.filter(posts_dsl::id.eq(post.id)))
.set((
new_post.0,
posts_dsl::updated_at.eq(diesel::dsl::now),
posts_dsl::last_updated_by.eq(auth.user_id),
))
.execute(&mut conn)
{
Ok(_) => HttpResponse::new(StatusCode::NO_CONTENT), Ok(_) => HttpResponse::new(StatusCode::NO_CONTENT),
Err(why) => HttpResponse::InternalServerError().json(failure(why.to_string())), Err(why) => HttpResponse::InternalServerError().json(failure(why.to_string())),
} }