From 9451924c73da976abc965cd9b825c7f0b8fb87fc Mon Sep 17 00:00:00 2001 From: "Dotan J. Nahum" Date: Mon, 16 Sep 2024 17:56:38 +0300 Subject: [PATCH] impl gen controller (#751) * impl gen controller * align path separator * improve route uri building * align tests --- .github/workflows/ci-generators.yml | 2 +- src/cli.rs | 23 ++++++-- src/controller/app_routes.rs | 40 ++++++++------ src/gen/controller.rs | 53 +++++++++++++++++++ src/gen/mod.rs | 20 +++++-- src/gen/scaffold.rs | 5 +- src/gen/templates/controller/api/controller.t | 40 ++++++++++++++ src/gen/templates/controller/api/test.t | 26 +++++++++ .../templates/controller/html/controller.t | 37 +++++++++++++ src/gen/templates/controller/html/view.t | 19 +++++++ .../templates/controller/htmx/controller.t | 37 +++++++++++++ src/gen/templates/controller/htmx/view.t | 19 +++++++ src/gen/templates/scaffold/api/controller.t | 8 +-- src/gen/templates/scaffold/api/test.t | 26 +++++++++ src/gen/templates/scaffold/html/controller.t | 12 ++--- src/gen/templates/scaffold/htmx/controller.t | 12 ++--- 16 files changed, 336 insertions(+), 43 deletions(-) create mode 100644 src/gen/controller.rs create mode 100644 src/gen/templates/controller/api/controller.t create mode 100644 src/gen/templates/controller/api/test.t create mode 100644 src/gen/templates/controller/html/controller.t create mode 100644 src/gen/templates/controller/html/view.t create mode 100644 src/gen/templates/controller/htmx/controller.t create mode 100644 src/gen/templates/controller/htmx/view.t create mode 100644 src/gen/templates/scaffold/api/test.t diff --git a/.github/workflows/ci-generators.yml b/.github/workflows/ci-generators.yml index 16dc26790..1d5bba032 100644 --- a/.github/workflows/ci-generators.yml +++ b/.github/workflows/ci-generators.yml @@ -69,7 +69,7 @@ jobs: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test - name: controller - run: cargo run -- generate controller post && cargo build && cargo test requests::post + run: cargo run -- generate controller pages about && cargo build && cargo test pages working-directory: ./examples/demo env: REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} diff --git a/src/cli.rs b/src/cli.rs index 93512ee56..9c3a9a2c6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -100,7 +100,8 @@ enum Commands { /// Run jobs that are associated with a specific tag. #[arg(short, long, action)] tag: Option, - /// Specify a path to a dedicated scheduler configuration file. by default load schedulers job setting from environment config. + /// Specify a path to a dedicated scheduler configuration file. by + /// default load schedulers job setting from environment config. #[clap(value_parser)] #[arg(short, long, action)] config: Option, @@ -179,6 +180,13 @@ enum ComponentArg { Controller { /// Name of the thing to generate name: String, + + /// Actions + actions: Vec, + + /// The kind of scaffold to generate + #[clap(short, long, value_enum, default_value_t = gen::ScaffoldKind::Api)] + kind: gen::ScaffoldKind, }, /// Generate a Task based on the given name Task { @@ -244,7 +252,15 @@ impl TryFrom for Component { Ok(Self::Scaffold { name, fields, kind }) } - ComponentArg::Controller { name } => Ok(Self::Controller { name }), + ComponentArg::Controller { + name, + actions, + kind, + } => Ok(Self::Controller { + name, + actions, + kind, + }), ComponentArg::Task { name } => Ok(Self::Task { name }), ComponentArg::Scheduler {} => Ok(Self::Scheduler {}), ComponentArg::Worker { name } => Ok(Self::Worker { name }), @@ -260,7 +276,8 @@ enum DbCommands { Create, /// Migrate schema (up) Migrate, - /// Run one down migration, or add a number to run multiple down migrations (i.e. `down 2`) + /// Run one down migration, or add a number to run multiple down migrations + /// (i.e. `down 2`) Down { /// The number of migrations to rollback #[arg(default_value_t = 1)] diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index b1bb79a16..1d21940aa 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -100,32 +100,38 @@ impl AppRoutes { #[must_use] pub fn collect(&self) -> Vec { - let base_url_prefix = self.get_prefix().map_or("/", |url| url.as_str()); + let base_url_prefix = self + .get_prefix() + // add a leading slash forcefully. Axum routes must start with a leading slash. + // if we have double leading slashes - it will get normalized into a single slash later + .map_or("/".to_string(), |url| format!("/{}", url.as_str())); self.get_routes() .iter() - .flat_map(|router| { - let mut uri_parts = vec![base_url_prefix]; - if let Some(prefix) = router.prefix.as_ref() { - uri_parts.push(prefix); + .flat_map(|controller| { + let mut uri_parts = vec![base_url_prefix.clone()]; + if let Some(prefix) = controller.prefix.as_ref() { + uri_parts.push(prefix.to_string()); } - router.handlers.iter().map(move |controller| { - let uri = format!("{}{}", uri_parts.join("/"), &controller.uri); - let binding = NORMALIZE_URL.replace_all(&uri, "/"); - - let uri = if binding.len() > 1 { - NORMALIZE_URL - .replace_all(&uri, "/") - .strip_suffix('/') - .map_or_else(|| binding.to_string(), std::string::ToString::to_string) + controller.handlers.iter().map(move |handler| { + let mut parts = uri_parts.clone(); + parts.push(handler.uri.to_string()); + let joined_parts = parts.join("/"); + + let normalized = NORMALIZE_URL.replace_all(&joined_parts, "/"); + let uri = if normalized == "/" { + normalized.to_string() } else { - binding.to_string() + normalized.strip_suffix('/').map_or_else( + || normalized.to_string(), + std::string::ToString::to_string, + ) }; ListRoutes { uri, - actions: controller.actions.clone(), - method: controller.method.clone(), + actions: handler.actions.clone(), + method: handler.method.clone(), } }) }) diff --git a/src/gen/controller.rs b/src/gen/controller.rs new file mode 100644 index 000000000..b2947be4f --- /dev/null +++ b/src/gen/controller.rs @@ -0,0 +1,53 @@ +use rrgen::RRgen; +use serde_json::json; + +use crate::{app::Hooks, gen}; + +const API_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/api/controller.t"); +const API_CONTROLLER_TEST_T: &str = include_str!("templates/controller/api/test.t"); + +const HTMX_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/htmx/controller.t"); +const HTMX_VIEW_T: &str = include_str!("templates/controller/htmx/view.t"); + +const HTML_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/html/controller.t"); +const HTML_VIEW_T: &str = include_str!("templates/controller/html/view.t"); + +use super::collect_messages; +use crate::Result; + +pub fn generate( + rrgen: &RRgen, + name: &str, + actions: &[String], + kind: &gen::ScaffoldKind, +) -> Result { + let vars = json!({"name": name, "actions": actions, "pkg_name": H::app_name()}); + match kind { + gen::ScaffoldKind::Api => { + let res1 = rrgen.generate(API_CONTROLLER_CONTROLLER_T, &vars)?; + let res2 = rrgen.generate(API_CONTROLLER_TEST_T, &vars)?; + let messages = collect_messages(vec![res1, res2]); + Ok(messages) + } + gen::ScaffoldKind::Html => { + let mut messages = Vec::new(); + let res = rrgen.generate(HTML_CONTROLLER_CONTROLLER_T, &vars)?; + messages.push(res); + for action in actions { + let vars = json!({"name": name, "action": action, "pkg_name": H::app_name()}); + messages.push(rrgen.generate(HTML_VIEW_T, &vars)?); + } + Ok(collect_messages(messages)) + } + gen::ScaffoldKind::Htmx => { + let mut messages = Vec::new(); + let res = rrgen.generate(HTMX_CONTROLLER_CONTROLLER_T, &vars)?; + messages.push(res); + for action in actions { + let vars = json!({"name": name, "action": action, "pkg_name": H::app_name()}); + messages.push(rrgen.generate(HTMX_VIEW_T, &vars)?); + } + Ok(collect_messages(messages)) + } + } +} diff --git a/src/gen/mod.rs b/src/gen/mod.rs index 9b0cd9616..e5a68ab44 100644 --- a/src/gen/mod.rs +++ b/src/gen/mod.rs @@ -7,6 +7,7 @@ use rrgen::{GenResult, RRgen}; use serde::{Deserialize, Serialize}; use serde_json::json; +mod controller; #[cfg(feature = "with-db")] mod model; #[cfg(feature = "with-db")] @@ -154,6 +155,12 @@ pub enum Component { Controller { /// Name of the thing to generate name: String, + + /// Action names + actions: Vec, + + // kind + kind: ScaffoldKind, }, Task { /// Name of the thing to generate @@ -198,10 +205,15 @@ pub fn generate(component: Component, config: &Config) -> Result<()> { let vars = json!({ "name": name, "ts": chrono::Utc::now(), "pkg_name": H::app_name()}); rrgen.generate(MIGRATION_T, &vars)?; } - Component::Controller { name } => { - let vars = json!({ "name": name, "pkg_name": H::app_name()}); - rrgen.generate(CONTROLLER_T, &vars)?; - rrgen.generate(CONTROLLER_TEST_T, &vars)?; + Component::Controller { + name, + actions, + kind, + } => { + println!( + "{}", + controller::generate::(&rrgen, &name, &actions, &kind)? + ); } Component::Task { name } => { let vars = json!({"name": name, "pkg_name": H::app_name()}); diff --git a/src/gen/scaffold.rs b/src/gen/scaffold.rs index c6a4b4d2e..e1e5b5093 100644 --- a/src/gen/scaffold.rs +++ b/src/gen/scaffold.rs @@ -4,6 +4,7 @@ use serde_json::json; use crate::{app::Hooks, gen}; const API_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/api/controller.t"); +const API_CONTROLLER_TEST_T: &str = include_str!("templates/scaffold/api/test.t"); const HTMX_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/controller.t"); const HTMX_BASE_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/base.t"); @@ -21,7 +22,7 @@ const HTML_VIEW_CREATE_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/ const HTML_VIEW_SHOW_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_show.t"); const HTML_VIEW_LIST_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_list.t"); -use super::{collect_messages, model, CONTROLLER_TEST_T, MAPPINGS}; +use super::{collect_messages, model, MAPPINGS}; use crate::{errors::Error, Result}; pub fn generate( @@ -59,7 +60,7 @@ pub fn generate( match kind { gen::ScaffoldKind::Api => { let res1 = rrgen.generate(API_CONTROLLER_SCAFFOLD_T, &vars)?; - let res2 = rrgen.generate(CONTROLLER_TEST_T, &vars)?; + let res2 = rrgen.generate(API_CONTROLLER_TEST_T, &vars)?; let messages = collect_messages(vec![res1, res2]); Ok(format!("{model_messages}{messages}")) } diff --git a/src/gen/templates/controller/api/controller.t b/src/gen/templates/controller/api/controller.t new file mode 100644 index 000000000..1982f1426 --- /dev/null +++ b/src/gen/templates/controller/api/controller.t @@ -0,0 +1,40 @@ +{% set file_name = name | snake_case -%} +{% set module_name = file_name | pascal_case -%} +to: src/controllers/{{ file_name }}.rs +skip_exists: true +message: "Controller `{{module_name}}` was added successfully." +injections: +- into: src/controllers/mod.rs + append: true + content: "pub mod {{ file_name }};" +- into: src/app.rs + after: "AppRoutes::" + content: " .add_route(controllers::{{ file_name }}::routes())" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use axum::debug_handler; + +#[debug_handler] +pub async fn index(State(_ctx): State) -> Result { + format::empty() +} + +{% for action in actions -%} +#[debug_handler] +pub async fn {{action}}(State(_ctx): State) -> Result { + format::empty() +} + +{% endfor -%} + +pub fn routes() -> Routes { + Routes::new() + .prefix("{{file_name | plural}}/") + .add("/", get(index)) + {%- for action in actions %} + .add("{{action}}", get({{action}})) + {%- endfor %} +} diff --git a/src/gen/templates/controller/api/test.t b/src/gen/templates/controller/api/test.t new file mode 100644 index 000000000..3a507ec9f --- /dev/null +++ b/src/gen/templates/controller/api/test.t @@ -0,0 +1,26 @@ +{% set file_name = name | snake_case -%} +{% set module_name = file_name | pascal_case -%} +to: tests/requests/{{ file_name }}.rs +skip_exists: true +message: "Tests for controller `{{module_name}}` was added successfully. Run `cargo run test`." +injections: +- into: tests/requests/mod.rs + append: true + content: "pub mod {{ file_name }};" +--- +use {{pkg_name}}::app::App; +use loco_rs::testing; +use serial_test::serial; + +{% for action in actions -%} +#[tokio::test] +#[serial] +async fn can_get_{{action}}() { + testing::request::(|request, _ctx| async move { + let res = request.get("/{{ name | snake_case }}/{{action}}").await; + assert_eq!(res.status_code(), 200); + }) + .await; +} + +{% endfor -%} diff --git a/src/gen/templates/controller/html/controller.t b/src/gen/templates/controller/html/controller.t new file mode 100644 index 000000000..9716af6eb --- /dev/null +++ b/src/gen/templates/controller/html/controller.t @@ -0,0 +1,37 @@ +{% set file_name = name | snake_case -%} +{% set module_name = file_name | pascal_case -%} +to: src/controllers/{{ file_name }}.rs +skip_exists: true +message: "Controller `{{module_name}}` was added successfully." +injections: +- into: src/controllers/mod.rs + append: true + content: "pub mod {{ file_name }};" +- into: src/app.rs + after: "AppRoutes::" + content: " .add_route(controllers::{{ file_name }}::routes())" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use axum::debug_handler; + +{% for action in actions -%} +#[debug_handler] +pub async fn {{action}}( + ViewEngine(v): ViewEngine, + State(_ctx): State +) -> Result { + format::render().view(&v, "{{file_name}}/{{action}}.html", serde_json::json!({})) +} + +{% endfor -%} + +pub fn routes() -> Routes { + Routes::new() + .prefix("{{file_name | plural}}/") + {%- for action in actions %} + .add("{{action}}", get({{action}})) + {%- endfor %} +} diff --git a/src/gen/templates/controller/html/view.t b/src/gen/templates/controller/html/view.t new file mode 100644 index 000000000..24ef81f36 --- /dev/null +++ b/src/gen/templates/controller/html/view.t @@ -0,0 +1,19 @@ +{% set file_name = name | snake_case -%} +{% set module_name = file_name | pascal_case -%} +to: assets/views/{{file_name}}/{{action}}.html +skip_exists: true +message: "{{file_name}}/{{action}} view was added successfully." +--- + + + + + + + + +

View {{action}}

+ Find me in {{file_name}}/{{action}} + + + diff --git a/src/gen/templates/controller/htmx/controller.t b/src/gen/templates/controller/htmx/controller.t new file mode 100644 index 000000000..5b1d90068 --- /dev/null +++ b/src/gen/templates/controller/htmx/controller.t @@ -0,0 +1,37 @@ +{% set file_name = name | snake_case -%} +{% set module_name = file_name | pascal_case -%} +to: src/controllers/{{ file_name }}.rs +skip_exists: true +message: "Controller `{{module_name}}` was added successfully." +injections: +- into: src/controllers/mod.rs + append: true + content: "pub mod {{ file_name }};" +- into: src/app.rs + after: "AppRoutes::" + content: " .add_route(controllers::{{ file_name }}::routes())" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use axum::debug_handler; + +{% for action in actions -%} +#[debug_handler] +pub async fn {{action}}( + ViewEngine(v): ViewEngine, + State(_ctx): State +) -> Result { + format::render().view(&v, "{{file_name}}/{{action}}.html", serde_json::json!({})) +} + +{% endfor -%} + +pub fn routes() -> Routes { + Routes::new() + .prefix("{{file_name | plural}}") + {%- for action in actions %} + .add("{{action}}", get({{action}})) + {%- endfor %} +} diff --git a/src/gen/templates/controller/htmx/view.t b/src/gen/templates/controller/htmx/view.t new file mode 100644 index 000000000..24ef81f36 --- /dev/null +++ b/src/gen/templates/controller/htmx/view.t @@ -0,0 +1,19 @@ +{% set file_name = name | snake_case -%} +{% set module_name = file_name | pascal_case -%} +to: assets/views/{{file_name}}/{{action}}.html +skip_exists: true +message: "{{file_name}}/{{action}} view was added successfully." +--- + + + + + + + + +

View {{action}}

+ Find me in {{file_name}}/{{action}} + + + diff --git a/src/gen/templates/scaffold/api/controller.t b/src/gen/templates/scaffold/api/controller.t index f27c04ab0..382eb6a29 100644 --- a/src/gen/templates/scaffold/api/controller.t +++ b/src/gen/templates/scaffold/api/controller.t @@ -81,10 +81,10 @@ pub async fn get_one(Path(id): Path, State(ctx): State) -> Resu pub fn routes() -> Routes { Routes::new() - .prefix("{{file_name | plural}}") + .prefix("{{file_name | plural}}/") .add("/", get(list)) .add("/", post(add)) - .add("/:id", get(get_one)) - .add("/:id", delete(remove)) - .add("/:id", post(update)) + .add(":id", get(get_one)) + .add(":id", delete(remove)) + .add(":id", post(update)) } diff --git a/src/gen/templates/scaffold/api/test.t b/src/gen/templates/scaffold/api/test.t new file mode 100644 index 000000000..d6298659d --- /dev/null +++ b/src/gen/templates/scaffold/api/test.t @@ -0,0 +1,26 @@ +{% set file_name = name | snake_case -%} +{% set module_name = file_name | pascal_case -%} +to: tests/requests/{{ file_name }}.rs +skip_exists: true +message: "Tests for controller `{{module_name}}` was added successfully. Run `cargo run test`." +injections: +- into: tests/requests/mod.rs + append: true + content: "pub mod {{ file_name }};" +--- +use {{pkg_name}}::app::App; +use loco_rs::testing; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn can_list() { + testing::request::(|request, _ctx| async move { + let res = request.get("/{{ name | snake_case }}/").await; + assert_eq!(res.status_code(), 200); + + // you can assert content like this: + // assert_eq!(res.text(), "content"); + }) + .await; +} diff --git a/src/gen/templates/scaffold/html/controller.t b/src/gen/templates/scaffold/html/controller.t index 4be3fe4ec..a1b6ba6d7 100644 --- a/src/gen/templates/scaffold/html/controller.t +++ b/src/gen/templates/scaffold/html/controller.t @@ -119,12 +119,12 @@ pub async fn remove(Path(id): Path, State(ctx): State) -> Resul pub fn routes() -> Routes { Routes::new() - .prefix("{{file_name | plural}}") + .prefix("{{file_name | plural}}/") .add("/", get(list)) - .add("/new", get(new)) - .add("/:id", get(show)) - .add("/:id/edit", get(edit)) - .add("/:id", post(update)) - .add("/:id", delete(remove)) .add("/", post(add)) + .add("new", get(new)) + .add(":id", get(show)) + .add(":id/edit", get(edit)) + .add(":id", post(update)) + .add(":id", delete(remove)) } diff --git a/src/gen/templates/scaffold/htmx/controller.t b/src/gen/templates/scaffold/htmx/controller.t index a2826b420..49538c1cb 100644 --- a/src/gen/templates/scaffold/htmx/controller.t +++ b/src/gen/templates/scaffold/htmx/controller.t @@ -119,12 +119,12 @@ pub async fn remove(Path(id): Path, State(ctx): State) -> Resul pub fn routes() -> Routes { Routes::new() - .prefix("{{file_name | plural}}") + .prefix("{{file_name | plural}}/") .add("/", get(list)) - .add("/new", get(new)) - .add("/:id", get(show)) - .add("/:id/edit", get(edit)) - .add("/:id", post(update)) - .add("/:id", delete(remove)) .add("/", post(add)) + .add("new", get(new)) + .add(":id", get(show)) + .add(":id/edit", get(edit)) + .add(":id", post(update)) + .add(":id", delete(remove)) }