Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "299f9d2637947c7e7a54c496300d6df746f14f00"
},
"path_in_vcs": "rrgen"
}
+1135
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
name = "rrgen"
version = "0.5.6"
authors = ["Dotan Nahum <dotan@rng0.io>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "A microframework for declarative code generation and injection"
documentation = "https://docs.rs/rrgen"
readme = "README.md"
keywords = [
"codegen",
"template",
]
license = "Apache-2.0"
repository = "https://github.com/jondot/rrgen"
[lib]
name = "rrgen"
path = "src/lib.rs"
[[test]]
name = "gen_test"
path = "tests/gen_test.rs"
[dependencies.cruet]
version = "0.13.3"
[dependencies.fs-err]
version = "2.10.0"
[dependencies.glob]
version = "0.3.1"
[dependencies.heck]
version = "0.4.1"
[dependencies.regex]
version = "1"
[dependencies.serde]
version = "1"
features = ["derive"]
[dependencies.serde_json]
version = "1"
[dependencies.serde_regex]
version = "1.1.0"
[dependencies.serde_yaml]
version = "0.9"
[dependencies.tera]
version = "1"
[dependencies.thiserror]
version = "1"
[dev-dependencies.dir-diff]
version = "0.3.2"
[dev-dependencies.fs_extra]
version = "1.3.0"
[dev-dependencies.insta]
version = "1"
[dev-dependencies.tree-fs]
version = "0.2.1"
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "rrgen"
version = "0.5.6"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
description = "A microframework for declarative code generation and injection"
authors = ["Dotan Nahum <dotan@rng0.io>"]
documentation = "https://docs.rs/rrgen"
repository = "https://github.com/jondot/rrgen"
keywords = ["codegen", "template"]
license = "Apache-2.0"
readme = "../README.md"
[dependencies]
tera = "1"
heck = "0.4.1"
cruet = "0.13.3"
thiserror = "1"
serde_json = "1"
serde_yaml = "0.9"
serde = { version = "1", features = ["derive"] }
regex = "1"
serde_regex = "1.1.0"
fs-err = "2.10.0"
glob = "0.3.1"
[dev-dependencies]
insta = "1"
fs_extra = "1.3.0"
dir-diff = "0.3.2"
tree-fs = "0.2.1"
+72
View File
@@ -0,0 +1,72 @@
# rrgen
A microframework for declarative code generation and injection.
## Getting started
Templates use `Tera` as a templating language (similar to liquid), and use a special metadata/body separation with _frontmatter_.
The first part of the template instructs what the template should do, and which `injections` it should perform.
The second part is the actual target file that's being generated.
Example template `controller.t`:
```rust
---
to: tests/fixtures/realistic/generated/controllers/{{name | snake_case }}.rs
injections:
- into: tests/fixtures/realistic/generated/controllers/mod.rs
append: true
content: "pub mod {{ name | snake_case }};"
- into: tests/fixtures/realistic/generated/app.rs
after: "AppRoutes::"
content: " .add_route(controllers::{{ name | snake_case }}::routes())"
---
#![allow(clippy::unused_async)]
use axum::{extract::State, routing::get};
use rustyrails::{
app::AppContext,
controller::{format, Routes},
Result,
};
pub async fn echo(req_body: String) -> String {
req_body
}
pub async fn hello(State(ctx): State<AppContext>) -> Result<String> {
// do something with context (database, etc)
format::text("hello")
}
pub fn routes() -> Routes {
Routes::new()
.prefix("{{ name | snake_case }}")
.add("/", get(hello))
.add("/echo", get(echo))
}
```
Rendering a template will create one or more files, potentially inject into files, and is done like so:
```rust
use std::fs;
use rrgen::Rgen;
use serde_json::json;
let rrgen = RRgen::default();
let vars = json!({"name": "post"});
rrgen.generate(
&fs::read_to_string("tests/fixtures/test1/template.t").unwrap(),
&vars,
)
.unwrap();
```
`vars` will be variables that are exposed both for the _frontmatter_ part and the _body_ part.
+324
View File
@@ -0,0 +1,324 @@
use std::path::{Path, PathBuf};
use regex::Regex;
use serde::Deserialize;
use tera::{Context, Tera};
mod tera_filters;
pub trait FsDriver {
/// Write a file
///
/// # Errors
///
/// This function will return an error if it fails
fn write_file(&self, path: &Path, content: &str) -> Result<()>;
/// Read a file
///
/// # Errors
///
/// This function will return an error if it fails
fn read_file(&self, path: &Path) -> Result<String>;
fn exists(&self, path: &Path) -> bool;
}
pub struct RealFsDriver {}
impl FsDriver for RealFsDriver {
fn write_file(&self, path: &Path, content: &str) -> Result<()> {
let dir = path.parent().expect("cannot get folder");
if !dir.exists() {
fs_err::create_dir_all(dir)?;
}
Ok(fs_err::write(path, content)?)
}
fn read_file(&self, path: &Path) -> Result<String> {
Ok(fs_err::read_to_string(path)?)
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
}
pub trait Printer {
fn overwrite_file(&self, file_to: &Path);
fn skip_exists(&self, file_to: &Path);
fn add_file(&self, file_to: &Path);
fn injected(&self, file_to: &Path);
}
pub struct ConsolePrinter {}
impl Printer for ConsolePrinter {
fn overwrite_file(&self, file_to: &Path) {
println!("overwritten: {file_to:?}");
}
fn add_file(&self, file_to: &Path) {
println!("added: {file_to:?}");
}
fn injected(&self, file_to: &Path) {
println!("injected: {file_to:?}");
}
fn skip_exists(&self, file_to: &Path) {
println!("skipped (exists): {file_to:?}");
}
}
#[derive(Deserialize, Debug, Default)]
struct FrontMatter {
to: String,
#[serde(default)]
skip_exists: bool,
#[serde(default)]
skip_glob: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
injections: Option<Vec<Injection>>,
}
#[derive(Deserialize, Debug, Default)]
struct Injection {
into: String,
content: String,
#[serde(with = "serde_regex")]
#[serde(default)]
skip_if: Option<Regex>,
#[serde(with = "serde_regex")]
#[serde(default)]
before: Option<Regex>,
#[serde(with = "serde_regex")]
#[serde(default)]
before_last: Option<Regex>,
#[serde(with = "serde_regex")]
#[serde(default)]
after: Option<Regex>,
#[serde(with = "serde_regex")]
#[serde(default)]
after_last: Option<Regex>,
#[serde(with = "serde_regex")]
#[serde(default)]
remove_lines: Option<Regex>,
#[serde(default)]
prepend: bool,
#[serde(default)]
append: bool,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{0}")]
Message(String),
#[error(transparent)]
Tera(#[from] tera::Error),
#[error(transparent)]
IO(#[from] std::io::Error),
#[error(transparent)]
Serde(#[from] serde_json::Error),
#[error(transparent)]
YAML(#[from] serde_yaml::Error),
#[error(transparent)]
Glob(#[from] glob::PatternError),
#[error(transparent)]
Any(Box<dyn std::error::Error + Send + Sync>),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum GenResult {
Skipped,
Generated { message: Option<String> },
}
fn parse_template(input: &str) -> Result<(FrontMatter, String)> {
// normalize line endings
let input = input.replace("\r\n", "\n");
let (fm, body) = input.split_once("---\n").ok_or_else(|| {
Error::Message("cannot split document to frontmatter and body".to_string())
})?;
let frontmatter: FrontMatter = serde_yaml::from_str(fm)?;
Ok((frontmatter, body.to_string()))
}
pub struct RRgen {
working_dir: Option<PathBuf>,
fs: Box<dyn FsDriver>,
printer: Box<dyn Printer>,
template_engine: Tera,
}
impl Default for RRgen {
fn default() -> Self {
let mut tera = Tera::default();
tera_filters::register_all(&mut tera);
Self {
working_dir: None,
fs: Box::new(RealFsDriver {}),
printer: Box::new(ConsolePrinter {}),
template_engine: tera,
}
}
}
impl RRgen {
/// Creates a new [`RRgen`] instance with the specified working directory.
///
/// # Example
/// ```rust
/// use rrgen::RRgen;
///
/// let rgen = RRgen::with_working_dir("path");
///
/// ```
#[must_use]
pub fn with_working_dir<P: AsRef<Path>>(path: P) -> Self {
Self {
working_dir: Some(path.as_ref().to_path_buf()),
..Default::default()
}
}
/// Adds a custom template engine to the generator.
///
/// ```rust
/// use rrgen::RRgen;
/// use tera::Tera;
///
/// let mut tera = Tera::default();
/// let rgen = RRgen::default().add_template_engine(tera);
///
/// ```
#[must_use]
pub fn add_template_engine(self, mut template_engine: Tera) -> Self {
tera_filters::register_all(&mut template_engine);
Self {
template_engine,
..self
}
}
/// Generate from a template contained in `input`
///
/// # Errors
///
/// This function will return an error if operation fails
pub fn generate(&self, input: &str, vars: &serde_json::Value) -> Result<GenResult> {
let mut tera: Tera = self.template_engine.clone();
let rendered = tera.render_str(input, &Context::from_serialize(vars.clone())?)?;
let (frontmatter, body) = parse_template(&rendered)?;
let path_to = if let Some(working_dir) = &self.working_dir {
working_dir.join(frontmatter.to)
} else {
PathBuf::from(&frontmatter.to)
};
if frontmatter.skip_exists && self.fs.exists(&path_to) {
self.printer.skip_exists(&path_to);
return Ok(GenResult::Skipped);
}
if let Some(skip_glob) = frontmatter.skip_glob {
if glob::glob(&skip_glob)?.count() > 0 {
self.printer.skip_exists(&path_to);
return Ok(GenResult::Skipped);
}
}
if self.fs.exists(&path_to) {
self.printer.overwrite_file(&path_to);
} else {
self.printer.add_file(&path_to);
}
// write main file
self.fs.write_file(&path_to, &body)?;
// handle injects
if let Some(injections) = frontmatter.injections {
for injection in &injections {
let injection_to = self.working_dir.as_ref().map_or_else(
|| PathBuf::from(&injection.into),
|working_dir| working_dir.join(&injection.into),
);
if !self.fs.exists(&injection_to) {
return Err(Error::Message(format!(
"cannot inject into {}: file does not exist",
injection.into,
)));
}
let file_content = self.fs.read_file(&injection_to)?;
let content = &injection.content;
if let Some(skip_if) = &injection.skip_if {
if skip_if.is_match(&file_content) {
continue;
}
}
let new_content = if injection.prepend {
format!("{content}\n{file_content}")
} else if injection.append {
format!("{file_content}\n{content}")
} else if let Some(before) = &injection.before {
let mut lines = file_content.lines().collect::<Vec<_>>();
let pos = lines.iter().position(|ln| before.is_match(ln));
if let Some(pos) = pos {
lines.insert(pos, content);
}
lines.join("\n")
} else if let Some(before_last) = &injection.before_last {
let mut lines = file_content.lines().collect::<Vec<_>>();
let pos = lines.iter().rposition(|ln| before_last.is_match(ln));
if let Some(pos) = pos {
lines.insert(pos, content);
}
lines.join("\n")
} else if let Some(after) = &injection.after {
let mut lines = file_content.lines().collect::<Vec<_>>();
let pos = lines.iter().position(|ln| after.is_match(ln));
if let Some(pos) = pos {
lines.insert(pos + 1, content);
}
lines.join("\n")
} else if let Some(after_last) = &injection.after_last {
let mut lines = file_content.lines().collect::<Vec<_>>();
let pos = lines.iter().rposition(|ln| after_last.is_match(ln));
if let Some(pos) = pos {
lines.insert(pos + 1, content);
}
lines.join("\n")
} else if let Some(remove_lines) = &injection.remove_lines {
let lines = file_content
.lines()
.filter(|line| !remove_lines.is_match(line))
.collect::<Vec<_>>();
lines.join("\n")
} else {
println!("warning: no injection made");
file_content.clone()
};
self.fs.write_file(&injection_to, &new_content)?;
self.printer.injected(&injection_to);
}
}
Ok(GenResult::Generated {
message: frontmatter.message.clone(),
})
}
}
+183
View File
@@ -0,0 +1,183 @@
use std::{collections::HashMap, hash::BuildHasher};
use cruet::string::pluralize;
use heck::{ToKebabCase, ToLowerCamelCase, ToSnakeCase, ToTitleCase, ToUpperCamelCase};
use tera::{to_value, try_get_value, Result, Tera, Value};
/// Registers all available filters for a given `Tera` instance.
pub fn register_all(tera: &mut Tera) {
tera.register_filter("pascal_case", pascal_case);
tera.register_filter("camel_case", camel_case);
tera.register_filter("kebab_case", kebab_case);
tera.register_filter("lower_case", lower_case);
tera.register_filter("snake_case", snake_case);
tera.register_filter("title_case", title_case);
tera.register_filter("upper_case", upper_case);
tera.register_filter("plural", plural);
}
pub fn plural<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("pluralize", "value", String, value);
Ok(to_value(pluralize::to_plural(&s)).unwrap())
}
/// Converts text into `PascalCase`.
///
/// # Example
///
/// ```ignore
/// use tera::{Context, Tera};
/// use tera_text_filters::camel_case;
///
/// let mut ctx = Context::new();
/// ctx.insert("i", "some text");
///
/// let mut tera = Tera::default();
/// tera.register_filter("pascal_case", pascal_case);
///
/// let i = "{{ i | camel_case }}";
/// let rendered = tera.render_str(i, &ctx).unwrap();
/// assert_eq!(rendered, "SomeText");
/// ```
pub fn pascal_case<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("camel_case", "value", String, value);
Ok(to_value(s.to_upper_camel_case()).unwrap())
}
/// Converts text into camelCase.
///
/// # Example
///
/// ```ignore
/// use tera::{Context, Tera};
/// use tera_text_filters::camel_case;
///
/// let mut ctx = Context::new();
/// ctx.insert("i", "some text");
///
/// let mut tera = Tera::default();
/// tera.register_filter("camel_case", camel_case);
///
/// let i = "{{ i | camel_case }}";
/// let rendered = tera.render_str(i, &ctx).unwrap();
/// assert_eq!(rendered, "someText");
/// ```
pub fn camel_case<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("camel_case", "value", String, value);
Ok(to_value(s.to_lower_camel_case()).unwrap())
}
/// Converts text into kebab-case.
///
/// # Example
///
/// ```ignore
/// use tera::{Context, Tera};
/// use tera_text_filters::kebab_case;
///
/// let mut ctx = Context::new();
/// ctx.insert("i", "some text");
///
/// let mut tera = Tera::default();
/// tera.register_filter("kebab_case", kebab_case);
///
/// let i = "{{ i | kebab_case }}";
/// let rendered = tera.render_str(i, &ctx).unwrap();
/// assert_eq!(rendered, "some-text");
/// ```
pub fn kebab_case<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("kebab_case", "value", String, value);
Ok(to_value(s.to_kebab_case()).unwrap())
}
/// Converts text into lowercase.
///
/// # Example
///
/// ```ignore
/// use tera::{Context, Tera};
/// use tera_text_filters::lower_case;
///
/// let mut ctx = Context::new();
/// ctx.insert("i", "soMe Text");
///
/// let mut tera = Tera::default();
/// tera.register_filter("lower_case", lower_case);
///
/// let i = "{{ i | lower_case }}";
/// let rendered = tera.render_str(i, &ctx).unwrap();
/// assert_eq!(rendered, "some text");
/// ```
pub fn lower_case<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("lower_case", "value", String, value);
Ok(to_value(s.to_lowercase()).unwrap())
}
/// Converts text into `snake_case`.
///
/// # Example
///
/// ```ignore
/// use tera::{Context, Tera};
/// use tera_text_filters::snake_case;
///
/// let mut ctx = Context::new();
/// ctx.insert("i", "soMe Text");
///
/// let mut tera = Tera::default();
/// tera.register_filter("snake_case", snake_case);
///
/// let i = "{{ i | snake_case }}";
/// let rendered = tera.render_str(i, &ctx).unwrap();
/// assert_eq!(rendered, "some_text");
/// ```
pub fn snake_case<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("snake_case", "value", String, value);
Ok(to_value(s.to_snake_case()).unwrap())
}
/// Converts text into Title Case.
///
/// # Example
///
/// ```ignore
/// use tera::{Context, Tera};
/// use tera_text_filters::title_case;
///
/// let mut ctx = Context::new();
/// ctx.insert("i", "soMe Text");
///
/// let mut tera = Tera::default();
/// tera.register_filter("title_case", title_case);
///
/// let i = "{{ i | title_case }}";
/// let rendered = tera.render_str(i, &ctx).unwrap();
/// assert_eq!(rendered, "some_text");
/// ```
pub fn title_case<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("title_case", "value", String, value);
Ok(to_value(s.to_title_case()).unwrap())
}
/// Converts text into UPPERCASE.
///
/// # Example
///
/// ```ignore
/// use tera::{Context, Tera};
/// use tera_text_filters::upper_case;
///
/// let mut ctx = Context::new();
/// ctx.insert("i", "soMe Text");
///
/// let mut tera = Tera::default();
/// tera.register_filter("upper_case", upper_case);
///
/// let i = "{{ i | upper_case }}";
/// let rendered = tera.render_str(i, &ctx).unwrap();
/// assert_eq!(rendered, "SOME TEXT");
/// ```
pub fn upper_case<S: BuildHasher>(value: &Value, _: &HashMap<String, Value, S>) -> Result<Value> {
let s = try_get_value!("upper_case", "value", String, value);
Ok(to_value(s.to_uppercase()).unwrap())
}
@@ -0,0 +1,49 @@
use std::path::Path;
use async_trait::async_trait;
use rustyrails::{
app::{AppContext, Hooks},
controller::AppRoutes,
db::{self, truncate_table},
task::Tasks,
worker::{AppWorker, Processor},
Result,
};
use sea_orm::DatabaseConnection;
use crate::{
controllers,
models::_entities::{notes, users},
tasks::user_report::UserReport,
workers::downloader::DownloadWorker,
};
pub struct App;
#[async_trait]
impl Hooks for App {
fn routes() -> AppRoutes {
AppRoutes::with_default_routes()
.add_route(controllers::notes::routes())
.add_route(controllers::auth::routes())
.add_route(controllers::user::routes())
}
fn connect_workers<'a>(p: &'a mut Processor, ctx: &'a AppContext) {
p.register(DownloadWorker::build(ctx));
}
fn register_tasks(tasks: &mut Tasks) {
tasks.register(UserReport);
}
async fn truncate(db: &DatabaseConnection) -> Result<()> {
truncate_table(db, users::Entity).await?;
truncate_table(db, notes::Entity).await?;
Ok(())
}
async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> {
db::seed::<users::ActiveModel>(db, &base.join("users.yaml").display().to_string()).await?;
Ok(())
}
}
@@ -0,0 +1,8 @@
use blo::app::App;
use migration::Migrator;
use rustyrails::cli;
#[tokio::main]
async fn main() -> eyre::Result<()> {
cli::main::<App, Migrator>().await
}
@@ -0,0 +1,62 @@
use axum::{extract::State, routing::post, Json};
use rustyrails::{
app::AppContext,
controller::{format, unauthorized, Routes},
Result,
};
use crate::{
mailers::auth::AuthMailer,
models::users::{self, LoginParams, RegisterParams},
views::auth::LoginResponse,
};
async fn register(
State(ctx): State<AppContext>,
Json(params): Json<RegisterParams>,
) -> Result<Json<()>> {
let res = users::Model::create_with_password(&ctx.db, &params).await;
let user = match res {
Ok(user) => user,
Err(err) => {
tracing::info!(
message = err.to_string(),
user_email = &params.email,
"could not register user",
);
return format::json(());
}
};
// TODO:: send website base uri
AuthMailer::send_welcome(&ctx, &user.email).await.unwrap();
format::json(())
}
async fn login(
State(ctx): State<AppContext>,
Json(params): Json<LoginParams>,
) -> Result<Json<LoginResponse>> {
let user = users::Model::find_by_email(&ctx.db, &params.email).await?;
let valid = user.verify_password(&params.password)?;
if !valid {
return unauthorized("unauthorized access");
}
let token = user
.generate_jwt(&ctx.config.auth.secret, &ctx.config.auth.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::json(LoginResponse::new(&user, &token))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("auth")
.add("/register", post(register))
.add("/login", post(login))
}
@@ -0,0 +1,3 @@
pub mod auth;
pub mod notes;
pub mod user;
@@ -0,0 +1,80 @@
#![allow(clippy::unused_async)]
use axum::{extract::State, routing::get};
use rustyrails::{
app::AppContext,
controller::{format, Routes},
errors::Error,
worker::AppWorker,
Result,
};
use sea_orm::EntityTrait;
// user imports
use crate::{
mailers::auth::AuthMailer,
models::_entities::users,
workers::downloader::{DownloadWorker, DownloadWorkerArgs},
};
/// Benchmark function for a simple hello-world endpoint.
///
/// # Errors
///
/// Errors related to formatting the response.
pub async fn bench_hello(_req_body: String) -> Result<String> {
format::text("hello")
}
/// Benchmark function for database operations.
///
/// This function is used to benchmark database operations by performing a
/// simple query to retrieve a user entity by ID. It utilizes the
/// `Entity::find_by_id` method provided by the `users` module.
///
/// # Errors
///
/// When db query fails
pub async fn bench_db(State(ctx): State<AppContext>) -> Result<()> {
let _ = users::Entity::find_by_id(1).one(&ctx.db).await?;
format::empty()
}
/// return echo message
pub async fn echo(req_body: String) -> String {
req_body
}
/// A simple endpoint for demonstrating asynchronous tasks in a web application.
///
/// # Errors
///
/// when send welcome message fails or there is an error when preform download
/// task
pub async fn hello(State(ctx): State<AppContext>) -> Result<String> {
DownloadWorker::perform_later(
&ctx,
DownloadWorkerArgs {
user_guid: "foo".to_string(),
},
)
.await
.map_err(|e| {
tracing::error!(
error = e.to_string(),
"could not perform the download worker"
);
Error::Any("could not perform the download worker ".into())
})?;
AuthMailer::send_welcome(&ctx, "foobar").await?;
format::text("hello")
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(hello))
.add("/echo", get(echo))
.add("/bench_db", get(bench_db))
.add("/bench_hello", get(bench_hello))
}
@@ -0,0 +1,20 @@
use axum::{extract::State, routing::get, Json};
use rustyrails::{
app::AppContext,
controller::{format, middleware, Routes},
Result,
};
use crate::{models::_entities::users, views::user::CurrentResponse};
async fn current(
auth: middleware::auth::Auth,
State(ctx): State<AppContext>,
) -> Result<Json<CurrentResponse>> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user))
}
pub fn routes() -> Routes {
Routes::new().prefix("user").add("/current", get(current))
}
@@ -0,0 +1,15 @@
---
- id: 1
pid: 11111111-1111-1111-1111-111111111111
email: user1@example.com
password: "$2b$12$gf4o2FShIahg/GY6YkK2wOcs8w4.lu444wP6BL3FyjX0GsxnEV6ZW"
name: user1
created_at: "2023-11-12T12:34:56.789"
updated_at: "2023-11-12T12:34:56.789"
- id: 2
pid: 22222222-2222-2222-2222-222222222222
email: user2@example.com
password: "$2b$12$gf4o2FShIahg/GY6YkK2wOcs8w4.lu444wP6BL3FyjX0GsxnEV6ZW"
name: user2
created_at: "2023-11-12T12:34:56.789"
updated_at: "2023-11-12T12:34:56.789"
@@ -0,0 +1,7 @@
pub mod app;
pub mod controllers;
pub mod mailers;
pub mod models;
pub mod tasks;
pub mod views;
pub mod workers;
@@ -0,0 +1,40 @@
// auth mailer
#![allow(non_upper_case_globals)]
use include_dir::{include_dir, Dir};
use rustyrails::{
app::AppContext,
mailer::{Args, Mailer},
Result,
};
use serde_json::json;
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
// #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets
// move on for now.
#[allow(clippy::module_name_repetitions)]
pub struct AuthMailer {}
impl Mailer for AuthMailer {}
impl AuthMailer {
/// Sending welcome email the the given user
///
/// # Errors
///
/// When email sending is failed
pub async fn send_welcome(ctx: &AppContext, _user_id: &str) -> Result<()> {
Self::mail_template(
ctx,
&welcome,
Args {
to: "foo@example.com".to_string(),
locals: json!({
"name": "joe"
}),
..Default::default()
},
)
.await?;
Ok(())
}
}
@@ -0,0 +1,8 @@
;<html>
<body>
You can
<a href="http://localhost:/reset/<%= resetToken %>">reset your password</a>
</body>
</html>
@@ -0,0 +1 @@
Your reset password link
@@ -0,0 +1,3 @@
Reset your password with this link:
http://localhost/reset/<%= resetToken %>
@@ -0,0 +1,10 @@
;<html>
<body>
You can{' '}
<a href="http://localhost:/verify/<%= verifyToken %>">
verify your account
</a>
</body>
</html>
@@ -0,0 +1 @@
Welcome <%= name %>
@@ -0,0 +1,4 @@
Welcome <%= name %>, you can now log in.
Verify your account with the link below:
http://localhost/verify/<%= verifyToken %>
@@ -0,0 +1 @@
pub mod auth;
@@ -0,0 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
pub mod prelude;
pub mod notes;
pub mod users;
@@ -0,0 +1,37 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "notes")]
pub struct Model {
pub created_at: DateTime,
pub updated_at: DateTime,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub pid: Uuid,
pub title: Option<String>,
pub content: Option<String>,
pub owner_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::OwnerId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,3 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
pub use super::{notes::Entity as Notes, users::Entity as Users};
@@ -0,0 +1,33 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
use sea_orm::entity::prelude::*;
use serde::Deserialize;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
pub created_at: DateTime,
pub updated_at: DateTime,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub pid: Uuid,
#[sea_orm(unique)]
pub email: String,
pub password: String,
pub name: String,
pub reset_token: Option<String>,
pub reset_sent_at: Option<DateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::notes::Entity")]
Notes,
}
impl Related<super::notes::Entity> for Entity {
fn to() -> RelationDef {
Relation::Notes.def()
}
}
@@ -0,0 +1,2 @@
pub mod _entities;
pub mod users;
@@ -0,0 +1,173 @@
// TODO(review): base components must be re-exported
use rustyrails::{
auth,
model::{ModelError, ModelResult},
validation,
validator::Validate,
};
use sea_orm::{
entity::prelude::*, ActiveValue, DatabaseConnection, DbErr, ModelTrait, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginParams {
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterParams {
pub email: String,
pub password: String,
pub name: String,
}
#[derive(Debug, Validate, Deserialize)]
pub struct ModelValidator {
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
pub name: String,
#[validate(custom = "validation::is_valid_email")]
pub email: String,
}
impl From<&ActiveModel> for ModelValidator {
fn from(value: &ActiveModel) -> Self {
Self {
name: value.name.as_ref().to_string(),
email: value.email.as_ref().to_string(),
}
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
{
self.validate()?;
if insert {
let mut this = self;
this.pid = ActiveValue::Set(Uuid::new_v4());
Ok(this)
} else {
Ok(self)
}
}
}
}
impl super::_entities::users::Model {
/// .
///
/// # Errors
///
/// This function will return an error if .
pub async fn notes(
&self,
db: &DatabaseConnection,
) -> Result<Vec<super::_entities::notes::Model>, DbErr> {
self.find_related(super::_entities::prelude::Notes)
.all(db)
.await
}
/// .
///
/// # Errors
///
/// .
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(users::Column::Email.eq(email))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// .
///
/// # Errors
///
/// .
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Message(e.to_string()))?;
let user = users::Entity::find()
.filter(users::Column::Pid.eq(parse_uuid))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// .
///
/// # Errors
///
/// .
pub fn verify_password(&self, password: &str) -> ModelResult<bool> {
Ok(auth::verify_password(password, &self.password)?)
}
/// .
///
/// # Errors
///
/// .
pub async fn create_with_password(
db: &DatabaseConnection,
params: &RegisterParams,
) -> ModelResult<Self> {
let txn = db.begin().await?;
if users::Entity::find()
.filter(users::Column::Email.eq(&params.email))
.one(&txn)
.await?
.is_some()
{
return Err(ModelError::EntityExists {});
}
let password_hash = auth::hash_password(&params.password)?;
let user = users::ActiveModel {
//TODO(review): there might be a 'trick' to moves between params and partial
// ActiveValue values to save this rhs-lhs coding
email: ActiveValue::set(params.email.to_string()),
password: ActiveValue::set(password_hash),
name: ActiveValue::set(params.name.to_string()),
..Default::default()
}
.insert(&txn)
.await?;
txn.commit().await?;
Ok(user)
}
/// .
///
/// # Errors
///
/// .
pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult<String> {
Ok(auth::JWT::new(secret).generate_token(expiration, self.pid.to_string())?)
}
}
impl super::_entities::users::ActiveModel {
/// .
///
/// # Errors
///
/// .
pub fn validate(&self) -> Result<(), DbErr> {
let validator: ModelValidator = self.into();
validator.validate().map_err(validation::into_db_error)
}
}
@@ -0,0 +1 @@
pub mod user_report;
@@ -0,0 +1,33 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use rustyrails::{
app::AppContext,
task::{Task, TaskInfo},
Result,
};
use sea_orm::EntityTrait;
use crate::models::_entities::users;
pub struct UserReport;
#[async_trait]
impl Task for UserReport {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "user_report".to_string(),
detail: "output a user report".to_string(),
}
}
async fn run(&self, app_context: &AppContext, vars: &BTreeMap<String, String>) -> Result<()> {
let users = users::Entity::find().all(&app_context.db).await?;
println!("args: {vars:?}");
println!("!!! user_report: listing users !!!");
println!("------------------------");
for user in &users {
println!("user: {}", user.email);
}
println!("done: {} users", users.len());
Ok(())
}
}
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use crate::models::_entities::users;
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginResponse {
pub token: String,
pub pid: String,
pub name: String,
}
impl LoginResponse {
#[must_use]
pub fn new(user: &users::Model, token: &String) -> Self {
Self {
token: token.to_string(),
pid: user.pid.to_string(),
name: user.name.clone(),
}
}
}
@@ -0,0 +1,2 @@
pub mod auth;
pub mod user;
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use crate::models::_entities::users;
#[derive(Debug, Deserialize, Serialize)]
pub struct CurrentResponse {
pub pid: String,
pub name: String,
pub email: String,
}
impl CurrentResponse {
#[must_use]
pub fn new(user: &users::Model) -> Self {
Self {
pid: user.pid.to_string(),
name: user.name.clone(),
email: user.email.clone(),
}
}
}
@@ -0,0 +1,49 @@
use std::time::Duration;
use async_trait::async_trait;
use rustyrails::{
app::AppContext,
worker::{AppWorker, Result, Worker},
};
use sea_orm::EntityTrait;
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use crate::models::users;
pub struct DownloadWorker {
pub ctx: AppContext,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct DownloadWorkerArgs {
pub user_guid: String,
}
impl AppWorker<DownloadWorkerArgs> for DownloadWorker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
}
#[async_trait]
impl Worker<DownloadWorkerArgs> for DownloadWorker {
async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> {
// TODO: Some actual work goes here...
println!("================================================");
println!("Sending payment report to user {}", args.user_guid);
sleep(Duration::from_millis(2000)).await;
let all = users::Entity::find()
.all(&self.ctx.db)
.await
.map_err(Box::from)?;
for post in &all {
let notes = post.notes(&self.ctx.db).await;
println!("post: {} {:?}", post.id, notes);
}
println!("================================================");
Ok(())
}
}
@@ -0,0 +1 @@
pub mod downloader;
@@ -0,0 +1,32 @@
to: tests/fixtures/realistic/generated/controllers/{{name | snake_case }}.rs
injections:
- into: tests/fixtures/realistic/generated/controllers/mod.rs
append: true
content: "pub mod {{ name | snake_case }};"
- into: tests/fixtures/realistic/generated/app.rs
after: "AppRoutes::"
content: " .add_route(controllers::{{ name | snake_case }}::routes())"
---
#![allow(clippy::unused_async)]
use axum::{extract::State, routing::get};
use rustyrails::{
app::AppContext,
controller::{format, Routes},
Result,
};
pub async fn echo(req_body: String) -> String {
req_body
}
pub async fn hello(State(ctx): State<AppContext>) -> Result<String> {
// do something with context (database, etc)
format::text("hello")
}
pub fn routes() -> Routes {
Routes::new()
.prefix("{{ name | snake_case }}")
.add("/", get(hello))
.add("/echo", get(echo))
}
@@ -0,0 +1,51 @@
use std::path::Path;
use async_trait::async_trait;
use rustyrails::{
app::{AppContext, Hooks},
controller::AppRoutes,
db::{self, truncate_table},
task::Tasks,
worker::{AppWorker, Processor},
Result,
};
use sea_orm::DatabaseConnection;
use crate::{
controllers,
models::_entities::{notes, users},
tasks::user_report::UserReport,
workers::downloader::DownloadWorker,
};
pub struct App;
#[async_trait]
impl Hooks for App {
fn routes() -> AppRoutes {
AppRoutes::with_default_routes()
.add_route(controllers::email_stats::routes())
.add_route(controllers::notes::routes())
.add_route(controllers::auth::routes())
.add_route(controllers::user::routes())
}
fn connect_workers<'a>(p: &'a mut Processor, ctx: &'a AppContext) {
p.register(DownloadWorker::build(ctx));
}
fn register_tasks(tasks: &mut Tasks) {
tasks.register(tasks::email_stats::EmailStats);
tasks.register(UserReport);
}
async fn truncate(db: &DatabaseConnection) -> Result<()> {
truncate_table(db, users::Entity).await?;
truncate_table(db, notes::Entity).await?;
Ok(())
}
async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> {
db::seed::<users::ActiveModel>(db, &base.join("users.yaml").display().to_string()).await?;
Ok(())
}
}
@@ -0,0 +1,8 @@
use blo::app::App;
use migration::Migrator;
use rustyrails::cli;
#[tokio::main]
async fn main() -> eyre::Result<()> {
cli::main::<App, Migrator>().await
}
@@ -0,0 +1,62 @@
use axum::{extract::State, routing::post, Json};
use rustyrails::{
app::AppContext,
controller::{format, unauthorized, Routes},
Result,
};
use crate::{
mailers::auth::AuthMailer,
models::users::{self, LoginParams, RegisterParams},
views::auth::LoginResponse,
};
async fn register(
State(ctx): State<AppContext>,
Json(params): Json<RegisterParams>,
) -> Result<Json<()>> {
let res = users::Model::create_with_password(&ctx.db, &params).await;
let user = match res {
Ok(user) => user,
Err(err) => {
tracing::info!(
message = err.to_string(),
user_email = &params.email,
"could not register user",
);
return format::json(());
}
};
// TODO:: send website base uri
AuthMailer::send_welcome(&ctx, &user.email).await.unwrap();
format::json(())
}
async fn login(
State(ctx): State<AppContext>,
Json(params): Json<LoginParams>,
) -> Result<Json<LoginResponse>> {
let user = users::Model::find_by_email(&ctx.db, &params.email).await?;
let valid = user.verify_password(&params.password)?;
if !valid {
return unauthorized("unauthorized access");
}
let token = user
.generate_jwt(&ctx.config.auth.secret, &ctx.config.auth.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::json(LoginResponse::new(&user, &token))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("auth")
.add("/register", post(register))
.add("/login", post(login))
}
@@ -0,0 +1,23 @@
#![allow(clippy::unused_async)]
use axum::{extract::State, routing::get};
use rustyrails::{
app::AppContext,
controller::{format, Routes},
Result,
};
pub async fn echo(req_body: String) -> String {
req_body
}
pub async fn hello(State(ctx): State<AppContext>) -> Result<String> {
// do something with context (database, etc)
format::text("hello")
}
pub fn routes() -> Routes {
Routes::new()
.prefix("email_stats")
.add("/", get(hello))
.add("/echo", get(echo))
}
@@ -0,0 +1,5 @@
pub mod auth;
pub mod notes;
pub mod user;
pub mod email_stats;
@@ -0,0 +1,80 @@
#![allow(clippy::unused_async)]
use axum::{extract::State, routing::get};
use rustyrails::{
app::AppContext,
controller::{format, Routes},
errors::Error,
worker::AppWorker,
Result,
};
use sea_orm::EntityTrait;
// user imports
use crate::{
mailers::auth::AuthMailer,
models::_entities::users,
workers::downloader::{DownloadWorker, DownloadWorkerArgs},
};
/// Benchmark function for a simple hello-world endpoint.
///
/// # Errors
///
/// Errors related to formatting the response.
pub async fn bench_hello(_req_body: String) -> Result<String> {
format::text("hello")
}
/// Benchmark function for database operations.
///
/// This function is used to benchmark database operations by performing a
/// simple query to retrieve a user entity by ID. It utilizes the
/// `Entity::find_by_id` method provided by the `users` module.
///
/// # Errors
///
/// When db query fails
pub async fn bench_db(State(ctx): State<AppContext>) -> Result<()> {
let _ = users::Entity::find_by_id(1).one(&ctx.db).await?;
format::empty()
}
/// return echo message
pub async fn echo(req_body: String) -> String {
req_body
}
/// A simple endpoint for demonstrating asynchronous tasks in a web application.
///
/// # Errors
///
/// when send welcome message fails or there is an error when preform download
/// task
pub async fn hello(State(ctx): State<AppContext>) -> Result<String> {
DownloadWorker::perform_later(
&ctx,
DownloadWorkerArgs {
user_guid: "foo".to_string(),
},
)
.await
.map_err(|e| {
tracing::error!(
error = e.to_string(),
"could not perform the download worker"
);
Error::Any("could not perform the download worker ".into())
})?;
AuthMailer::send_welcome(&ctx, "foobar").await?;
format::text("hello")
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(hello))
.add("/echo", get(echo))
.add("/bench_db", get(bench_db))
.add("/bench_hello", get(bench_hello))
}
@@ -0,0 +1,20 @@
use axum::{extract::State, routing::get, Json};
use rustyrails::{
app::AppContext,
controller::{format, middleware, Routes},
Result,
};
use crate::{models::_entities::users, views::user::CurrentResponse};
async fn current(
auth: middleware::auth::Auth,
State(ctx): State<AppContext>,
) -> Result<Json<CurrentResponse>> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user))
}
pub fn routes() -> Routes {
Routes::new().prefix("user").add("/current", get(current))
}
@@ -0,0 +1,15 @@
---
- id: 1
pid: 11111111-1111-1111-1111-111111111111
email: user1@example.com
password: "$2b$12$gf4o2FShIahg/GY6YkK2wOcs8w4.lu444wP6BL3FyjX0GsxnEV6ZW"
name: user1
created_at: "2023-11-12T12:34:56.789"
updated_at: "2023-11-12T12:34:56.789"
- id: 2
pid: 22222222-2222-2222-2222-222222222222
email: user2@example.com
password: "$2b$12$gf4o2FShIahg/GY6YkK2wOcs8w4.lu444wP6BL3FyjX0GsxnEV6ZW"
name: user2
created_at: "2023-11-12T12:34:56.789"
updated_at: "2023-11-12T12:34:56.789"
@@ -0,0 +1,7 @@
pub mod app;
pub mod controllers;
pub mod mailers;
pub mod models;
pub mod tasks;
pub mod views;
pub mod workers;
@@ -0,0 +1,40 @@
// auth mailer
#![allow(non_upper_case_globals)]
use include_dir::{include_dir, Dir};
use rustyrails::{
app::AppContext,
mailer::{Args, Mailer},
Result,
};
use serde_json::json;
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
// #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets
// move on for now.
#[allow(clippy::module_name_repetitions)]
pub struct AuthMailer {}
impl Mailer for AuthMailer {}
impl AuthMailer {
/// Sending welcome email the the given user
///
/// # Errors
///
/// When email sending is failed
pub async fn send_welcome(ctx: &AppContext, _user_id: &str) -> Result<()> {
Self::mail_template(
ctx,
&welcome,
Args {
to: "foo@example.com".to_string(),
locals: json!({
"name": "joe"
}),
..Default::default()
},
)
.await?;
Ok(())
}
}
@@ -0,0 +1,8 @@
;<html>
<body>
You can
<a href="http://localhost:/reset/<%= resetToken %>">reset your password</a>
</body>
</html>
@@ -0,0 +1 @@
Your reset password link
@@ -0,0 +1,3 @@
Reset your password with this link:
http://localhost/reset/<%= resetToken %>
@@ -0,0 +1,10 @@
;<html>
<body>
You can{' '}
<a href="http://localhost:/verify/<%= verifyToken %>">
verify your account
</a>
</body>
</html>
@@ -0,0 +1 @@
Welcome <%= name %>
@@ -0,0 +1,4 @@
Welcome <%= name %>, you can now log in.
Verify your account with the link below:
http://localhost/verify/<%= verifyToken %>
@@ -0,0 +1 @@
pub mod auth;
@@ -0,0 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
pub mod prelude;
pub mod notes;
pub mod users;
@@ -0,0 +1,37 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "notes")]
pub struct Model {
pub created_at: DateTime,
pub updated_at: DateTime,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub pid: Uuid,
pub title: Option<String>,
pub content: Option<String>,
pub owner_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::OwnerId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,3 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
pub use super::{notes::Entity as Notes, users::Entity as Users};
@@ -0,0 +1,33 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
use sea_orm::entity::prelude::*;
use serde::Deserialize;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
pub created_at: DateTime,
pub updated_at: DateTime,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub pid: Uuid,
#[sea_orm(unique)]
pub email: String,
pub password: String,
pub name: String,
pub reset_token: Option<String>,
pub reset_sent_at: Option<DateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::notes::Entity")]
Notes,
}
impl Related<super::notes::Entity> for Entity {
fn to() -> RelationDef {
Relation::Notes.def()
}
}
@@ -0,0 +1,2 @@
pub mod _entities;
pub mod users;
@@ -0,0 +1,173 @@
// TODO(review): base components must be re-exported
use rustyrails::{
auth,
model::{ModelError, ModelResult},
validation,
validator::Validate,
};
use sea_orm::{
entity::prelude::*, ActiveValue, DatabaseConnection, DbErr, ModelTrait, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginParams {
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterParams {
pub email: String,
pub password: String,
pub name: String,
}
#[derive(Debug, Validate, Deserialize)]
pub struct ModelValidator {
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
pub name: String,
#[validate(custom = "validation::is_valid_email")]
pub email: String,
}
impl From<&ActiveModel> for ModelValidator {
fn from(value: &ActiveModel) -> Self {
Self {
name: value.name.as_ref().to_string(),
email: value.email.as_ref().to_string(),
}
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
{
self.validate()?;
if insert {
let mut this = self;
this.pid = ActiveValue::Set(Uuid::new_v4());
Ok(this)
} else {
Ok(self)
}
}
}
}
impl super::_entities::users::Model {
/// .
///
/// # Errors
///
/// This function will return an error if .
pub async fn notes(
&self,
db: &DatabaseConnection,
) -> Result<Vec<super::_entities::notes::Model>, DbErr> {
self.find_related(super::_entities::prelude::Notes)
.all(db)
.await
}
/// .
///
/// # Errors
///
/// .
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(users::Column::Email.eq(email))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// .
///
/// # Errors
///
/// .
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Message(e.to_string()))?;
let user = users::Entity::find()
.filter(users::Column::Pid.eq(parse_uuid))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// .
///
/// # Errors
///
/// .
pub fn verify_password(&self, password: &str) -> ModelResult<bool> {
Ok(auth::verify_password(password, &self.password)?)
}
/// .
///
/// # Errors
///
/// .
pub async fn create_with_password(
db: &DatabaseConnection,
params: &RegisterParams,
) -> ModelResult<Self> {
let txn = db.begin().await?;
if users::Entity::find()
.filter(users::Column::Email.eq(&params.email))
.one(&txn)
.await?
.is_some()
{
return Err(ModelError::EntityExists {});
}
let password_hash = auth::hash_password(&params.password)?;
let user = users::ActiveModel {
//TODO(review): there might be a 'trick' to moves between params and partial
// ActiveValue values to save this rhs-lhs coding
email: ActiveValue::set(params.email.to_string()),
password: ActiveValue::set(password_hash),
name: ActiveValue::set(params.name.to_string()),
..Default::default()
}
.insert(&txn)
.await?;
txn.commit().await?;
Ok(user)
}
/// .
///
/// # Errors
///
/// .
pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult<String> {
Ok(auth::JWT::new(secret).generate_token(expiration, self.pid.to_string())?)
}
}
impl super::_entities::users::ActiveModel {
/// .
///
/// # Errors
///
/// .
pub fn validate(&self) -> Result<(), DbErr> {
let validator: ModelValidator = self.into();
validator.validate().map_err(validation::into_db_error)
}
}
@@ -0,0 +1,26 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use rustyrails::{
app::AppContext,
task::{Task, TaskInfo},
Result,
};
use sea_orm::EntityTrait;
pub struct EmailStats;
#[async_trait]
impl Task for EmailStats {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "email_stats".to_string(),
detail: "a sample task".to_string(),
}
}
async fn run(&self, _app_context: &AppContext, _vars: &BTreeMap<String, String>) -> Result<()> {
println!("hello email_stats");
Ok(())
}
}
@@ -0,0 +1,3 @@
pub mod user_report;
pub mod email_stats;
@@ -0,0 +1,33 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use rustyrails::{
app::AppContext,
task::{Task, TaskInfo},
Result,
};
use sea_orm::EntityTrait;
use crate::models::_entities::users;
pub struct UserReport;
#[async_trait]
impl Task for UserReport {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "user_report".to_string(),
detail: "output a user report".to_string(),
}
}
async fn run(&self, app_context: &AppContext, vars: &BTreeMap<String, String>) -> Result<()> {
let users = users::Entity::find().all(&app_context.db).await?;
println!("args: {vars:?}");
println!("!!! user_report: listing users !!!");
println!("------------------------");
for user in &users {
println!("user: {}", user.email);
}
println!("done: {} users", users.len());
Ok(())
}
}
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use crate::models::_entities::users;
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginResponse {
pub token: String,
pub pid: String,
pub name: String,
}
impl LoginResponse {
#[must_use]
pub fn new(user: &users::Model, token: &String) -> Self {
Self {
token: token.to_string(),
pid: user.pid.to_string(),
name: user.name.clone(),
}
}
}
@@ -0,0 +1,2 @@
pub mod auth;
pub mod user;
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use crate::models::_entities::users;
#[derive(Debug, Deserialize, Serialize)]
pub struct CurrentResponse {
pub pid: String,
pub name: String,
pub email: String,
}
impl CurrentResponse {
#[must_use]
pub fn new(user: &users::Model) -> Self {
Self {
pid: user.pid.to_string(),
name: user.name.clone(),
email: user.email.clone(),
}
}
}
@@ -0,0 +1,49 @@
use std::time::Duration;
use async_trait::async_trait;
use rustyrails::{
app::AppContext,
worker::{AppWorker, Result, Worker},
};
use sea_orm::EntityTrait;
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use crate::models::users;
pub struct DownloadWorker {
pub ctx: AppContext,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct DownloadWorkerArgs {
pub user_guid: String,
}
impl AppWorker<DownloadWorkerArgs> for DownloadWorker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
}
#[async_trait]
impl Worker<DownloadWorkerArgs> for DownloadWorker {
async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> {
// TODO: Some actual work goes here...
println!("================================================");
println!("Sending payment report to user {}", args.user_guid);
sleep(Duration::from_millis(2000)).await;
let all = users::Entity::find()
.all(&self.ctx.db)
.await
.map_err(Box::from)?;
for post in &all {
let notes = post.notes(&self.ctx.db).await;
println!("post: {} {:?}", post.id, notes);
}
println!("================================================");
Ok(())
}
}
@@ -0,0 +1 @@
pub mod downloader;
@@ -0,0 +1,35 @@
to: tests/fixtures/realistic/generated/tasks/{{name | snake_case }}.rs
injections:
- into: tests/fixtures/realistic/generated/tasks/mod.rs
append: true
content: "pub mod {{ name | snake_case }};"
- into: tests/fixtures/realistic/generated/app.rs
after: "fn register_tasks"
content: " tasks.register(tasks::{{ name | snake_case }}::{{ name | pascal_case }});"
---
use std::collections::BTreeMap;
use async_trait::async_trait;
use rustyrails::{
app::AppContext,
task::{Task, TaskInfo},
Result,
};
use sea_orm::EntityTrait;
pub struct {{ name | pascal_case }};
#[async_trait]
impl Task for {{ name | pascal_case }} {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "{{ name }}".to_string(),
detail: "a sample task".to_string(),
}
}
async fn run(&self, _app_context: &AppContext, _vars: &BTreeMap<String, String>) -> Result<()> {
println!("hello {{ name }}");
Ok(())
}
}
@@ -0,0 +1,6 @@
this is a mod file
and this is another line
pub class Foobar {
blah
}
@@ -0,0 +1,9 @@
this is a mod file
and this is another line
Hello {
// should not be modified
}
pub class Foobar {
blah
}
@@ -0,0 +1,2 @@
this is a mod file
and this is another line
@@ -0,0 +1,6 @@
this is a mod file
and this is another line
pub class Foobar {
blah
}
@@ -0,0 +1,13 @@
this is a mod file
and this is another line
vec![
// should not be modified
]
pub class Foobar {
blah
vec![
1,
2,
]
}
@@ -0,0 +1,2 @@
this is a mod file
and this is another line
@@ -0,0 +1,5 @@
this is a mod file
Delete this line
foo
Delete this line
bar
@@ -0,0 +1,3 @@
this file should be skipped
yep.
@@ -0,0 +1,7 @@
this is a mod file
and this is another line
pub class Foobar {
field: integer
blah
}
@@ -0,0 +1,10 @@
this is a mod file
and this is another line
Hello {
// should not be modified
}
pub class Foobar {
field: integer
blah
}
@@ -0,0 +1,4 @@
this is a mod file
and this is another line
this was appended
@@ -0,0 +1,7 @@
this is a mod file
and this is another line
// doc comment
pub class Foobar {
blah
}
@@ -0,0 +1,14 @@
this is a mod file
and this is another line
vec![
// should not be modified
]
pub class Foobar {
blah
vec![
1,
2,
before-last
]
}
@@ -0,0 +1,4 @@
hello, this is the file body.
variable: Post
@@ -0,0 +1,3 @@
this was prepended
this is a mod file
and this is another line
@@ -0,0 +1,3 @@
this is a mod file
foo
bar
@@ -0,0 +1,3 @@
this file should be skipped
yep.
@@ -0,0 +1,33 @@
{% set something = 1 -%}
to: tests/fixtures/test1/generated/{{name}}.txt
injections:
- into: tests/fixtures/test1/generated/prepend.txt
prepend: true
content: "this was prepended"
- into: tests/fixtures/test1/generated/append.txt
append: true
content: "this was appended"
- into: tests/fixtures/test1/generated/skipped.txt
skip_if: "be skipped"
append: true
content: "this was appended"
- into: tests/fixtures/test1/generated/before.txt
content: "// doc comment"
before: "pub class"
- into: tests/fixtures/test1/generated/before_last.txt
content: "before-last"
before_last: "\\]"
- into: tests/fixtures/test1/generated/after.txt
content: "field: integer"
after: "pub class"
- into: tests/fixtures/test1/generated/after_last.txt
content: "field: integer"
after_last: "\\{"
- into: tests/fixtures/test1/generated/remove_lines.txt
content: ""
remove_lines: "Delete this line"
---
hello, this is the file body.
variable: {{ name | pascal_case }}
+96
View File
@@ -0,0 +1,96 @@
#![allow(non_snake_case)]
use std::fs;
use fs_extra::{self, dir::CopyOptions};
use rrgen::RRgen;
use serde_json::json;
#[test]
fn test_generate() {
let FROM = "tests/fixtures/test1/app";
let GENERATED = "tests/fixtures/test1/generated";
let vars = json!({"name": "post"});
fs_extra::dir::remove(GENERATED).unwrap();
fs_extra::dir::copy(
FROM,
GENERATED,
&CopyOptions {
copy_inside: true,
..Default::default()
},
)
.unwrap();
let rgen = RRgen::default();
rgen.generate(
&fs::read_to_string("tests/fixtures/test1/template.t").unwrap(),
&vars,
)
.unwrap();
assert!(!dir_diff::is_different(GENERATED, "tests/fixtures/test1/expected").unwrap());
}
#[test]
fn test_generate_with_working_dir() {
let tree_fs = tree_fs::TreeBuilder::default()
.drop(true)
.create()
.expect("create temp file");
let FROM = "tests/fixtures/test1/app";
let GENERATED = "tests/fixtures/test1/generated";
let vars = json!({"name": "post"});
fs_extra::dir::copy(
FROM,
tree_fs.root.join(GENERATED),
&CopyOptions {
copy_inside: true,
..Default::default()
},
)
.unwrap();
let rgen = RRgen::with_working_dir(&tree_fs.root);
rgen.generate(
&fs::read_to_string("tests/fixtures/test1/template.t").unwrap(),
&vars,
)
.unwrap();
assert!(!dir_diff::is_different(
tree_fs.root.join(GENERATED),
"tests/fixtures/test1/expected"
)
.unwrap());
}
#[test]
fn test_realistic() {
let FROM = "tests/fixtures/realistic/app";
let GENERATED = "tests/fixtures/realistic/generated";
let vars = json!({"name": "email_stats"});
fs_extra::dir::remove(GENERATED).unwrap();
fs_extra::dir::copy(
FROM,
GENERATED,
&CopyOptions {
copy_inside: true,
..Default::default()
},
)
.unwrap();
let rgen = RRgen::default();
rgen.generate(
&fs::read_to_string("tests/fixtures/realistic/controller.t").unwrap(),
&vars,
)
.unwrap();
rgen.generate(
&fs::read_to_string("tests/fixtures/realistic/task.t").unwrap(),
&vars,
)
.unwrap();
assert!(!dir_diff::is_different(GENERATED, "tests/fixtures/realistic/expected").unwrap());
}