nodecosmos / charybdis

Rust ORM for ScyllaDB and Apache Cassandra
MIT License
108 stars 6 forks source link

Section to add: Good practices with X Framework #5

Closed DanielHe4rt closed 4 months ago

DanielHe4rt commented 5 months ago

Hey! I was wondering if it's not a good option to add a simple section explaining how Charybdis work together with other frameworks.

Since I'm doing that project using DataTransferObjects, I realized that I can just skip primitive types and go straight to Charybdis type for validation on the DTO's.

use charybdis::types::{Frozen, Set, Text};
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::models::leaderboard::Leaderboard;

#[derive(Deserialize, Debug, Validate)]
pub struct SubmissionDTO {
    pub song_id: String, # primitive
    pub player_id: Text, # charybdis type
    pub modifiers: Frozen<Set<Text>>, # charybdis type 
    pub score: i32,
    pub difficulty: String,
    pub instrument: String,
}

AFAIK they have the same behavior based on what you sent on the request, right? And also since it's been decoded with serde it should work to all web frameworks.

DanielHe4rt commented 5 months ago

BTW if this is a irrelevant thing, lemme know as well. I'm used to advocate for beginners and since I'm the beginner of the game I like to document stuff that is not so obvious for me lol

GoranBrkuljan commented 5 months ago

Not sure I understand it well, but one thing is that you maybe don't even need separate DTO object at all if your structure matches Charybdis model. You could use Charybdis model directly and do all validations and everything directly on it. E.g. you can use serde to alter fields name when serializing/deserializing, assign default values and so on #[serde(default, rename = "camelCase")] You could use #[validate] on model fields for validation, and best part is that partial_<model> rule will generate same model that will automatically inherit those fields attributes in case you need to do operation on subset of model fields. I think in general it would be best practice to stick to charybdis model as much as possible as it can dramatically improve development speed.

If you still need separate DTO object, then there might not be need for charybdis type as those fields are mostly scylla aliases for their rust equivalent. The thing about them is that automatic migration tool expects those types when running migration.

DanielHe4rt commented 5 months ago

How I suppose to add an attribute like "id" after parsing it? I mean, there's other attributes that I'll have to generate as well and thinking about security I don't want to let the user send it on the payload.

Even setting as default it would be overwritten if I sent it on the payload? That's the main concern from my side.

GoranBrkuljan commented 5 months ago

Easiest solution would be just to add mut model in your actix action param and in body you do model.id = Uuid::new_v4().

You can also impl Callbacks for assignment of default values e.g.

impl Callbacks for MyModel {
    type Error = MyAppError;

    async fn before_insert(&mut self, _session: &charybdis::CachingSession) -> Result<(), MyAppError> {
        let now = chrono::Utc::now();

        self.id = Uuid::new_v4();
        self.created_at = Some(now);
        self.updated_at = Some(now);

        Ok(())
    }
}

I am used on this approach, as I use before_<operation> for assigning default values and validation, while after_<operation> for running something in async tokio::spawn(async move { ... }) that doesn't need to block main thread.

DanielHe4rt commented 5 months ago

The model you mean it would be the one that is related to Charybdis Model? Or the "DTO"?

The "from_request" is how I usually implement in OOP languages like PHP, but as fair I understood it should be something like this:

#[charybdis_model(
table_name = submissions,
partition_keys = [id],
clustering_keys = [played_at],
global_secondary_indexes = [],
local_secondary_indexes = [],
table_options = "
      CLUSTERING ORDER BY (played_at DESC)
    ",
)]
#[derive(Serialize, Deserialize, Default, Clone, Validate)]
pub struct Submission {
    pub id: Option<Uuid>,
    pub song_id: Text,
    pub player_id: Text,
    pub modifiers: Frozen<Set<Text>>,
    pub score: Int,
    pub difficulty: Text,
    pub instrument: Text,
    pub played_at: Option<Timestamp>,
}

impl Submission {
    pub async fn from_request(payload: &web::Json<SubmissionDTO>) -> Self {
        Submission {
            id: Some(Uuid::new_v4()),
            song_id: payload.song_id.to_string(),
            player_id: payload.player_id.to_string(),
            difficulty: payload.player_id.to_string(),
            instrument: payload.instrument.to_string(),
            modifiers: payload.modifiers.to_owned(),
            score: payload.score.to_owned(),
            played_at: Some(chrono::Utc::now()),
            ..Default::default()
        }
    }
}

impl Callbacks for Submission {
    type Error = FuckThatError;

    async fn before_insert(&mut self, _session: &charybdis::CachingSession) -> Result<(), FuckThatError> {
        let now = chrono::Utc::now();

        self.id = Some(Uuid::new_v4());
        self.played_at = Some(now);
        Ok(())
    }
}
GoranBrkuljan commented 5 months ago

The model you mean it would be the one that is related to Charybdis Model? Or the "DTO"?

Charybdis model.

That's the thing, I usually use Option<T> as type for non PK fields that are optional or not present in request and then you don't need SubmissionDTO at all as you can map request to model directly. Alternatively if you want default values, you could do #[serde(default)] in your model fields that are not provided and it will have default values, but idea is that you don't need intermediate object (SubmissionDTO in this case) as fields are mapped 1:1. Instead, you can do web::Json<Submission> directly in your actix action. And ofc, you wouldn't need that from_request part.

DanielHe4rt commented 5 months ago

I see. In my case I have the same payload, but for different models so I had to use a DTO for both.

#[post("/submissions")]
async fn post_submission(
    data: web::Data<AppState>,
    payload: web::Json<SubmissionDTO>,
) -> Result<impl Responder, FuckThatError> {
    let validated = payload.validate();

    let response = match validated {
        Ok(_) => {
            Leaderboard::from_request(&payload)
                .await
                .insert(&data.database)
                .await?;

            let submission = Submission::from_request(&payload).await;
            let _ = submission.insert(&data.database).await;
            HttpResponse::Ok().json(json!(SubmissionResponse::created(submission)))
        }
        Err(err) => HttpResponse::BadRequest().json(json!(err)),
    };

    Ok(response)
}