diff --git a/.github/workflows/loco-cli-e2e-master.yaml b/.github/workflows/loco-cli-e2e-master.yaml deleted file mode 100644 index 6ffba1494..000000000 --- a/.github/workflows/loco-cli-e2e-master.yaml +++ /dev/null @@ -1,105 +0,0 @@ -name: "[loco-cli:e2e(master)]" - -on: - push: - branches: - - master - pull_request: - -jobs: - # TODO: re-enable after 0.8 to check cmd spawning fix - saas-win32: - runs-on: windows-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - components: rustfmt - - name: Install seaorm cli - run: cargo install sea-orm-cli - - run: | - cargo install --path . - working-directory: ./loco-cli - - run: | - loco new -n saas -t saas --db sqlite --bg async --assets serverside - env: - ALLOW_IN_GIT_REPO: true - - run: | - cargo build - working-directory: ./saas - - run: | - cargo loco routes - working-directory: ./saas - - run: | - cargo loco db migrate - working-directory: ./saas - - run: | - cargo loco generate scaffold movie title:string --htmx - working-directory: ./saas - - run: | - cargo loco db migrate - working-directory: ./saas - - saas: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - - run: | - cargo install loco-cli - ALLOW_IN_GIT_REPO=true LOCO_APP_NAME=saas LOCO_TEMPLATE=saas loco new --db postgres --bg queue --assets serverside - - run: | - cargo build - working-directory: ./saas - - rest-api: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - - run: | - cargo install loco-cli - ALLOW_IN_GIT_REPO=true LOCO_APP_NAME=restapi LOCO_TEMPLATE=rest-api loco new --db postgres --bg queue - - run: | - cargo build - working-directory: ./restapi - - lightweight-service: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - override: true - - run: | - cargo install loco-cli - ALLOW_IN_GIT_REPO=true LOCO_APP_NAME=lightweight LOCO_TEMPLATE=lightweight-service loco new - - run: | - cargo build - working-directory: ./lightweight diff --git a/.github/workflows/loco-cli-e2e.yaml b/.github/workflows/loco-cli-e2e.yaml deleted file mode 100644 index 88f55aea3..000000000 --- a/.github/workflows/loco-cli-e2e.yaml +++ /dev/null @@ -1,63 +0,0 @@ -name: "[loco-cli:e2e]" - -on: - schedule: - - cron: 0 * * * * # every hour - -jobs: - saas: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - - run: | - cargo install loco-cli - ALLOW_IN_GIT_REPO=true loco new --template saas --name saas --db sqlite --bg async --assets serverside - - run: | - cargo build - working-directory: ./saas - - rest-api: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - - run: | - cargo install loco-cli - ALLOW_IN_GIT_REPO=true loco new --template rest-api --name restapi --db sqlite --bg async - - run: | - cargo build - working-directory: ./restapi - - lightweight-service: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - - run: | - cargo install loco-cli - ALLOW_IN_GIT_REPO=true loco new --template lightweight-service --name lightweight --db sqlite --bg async - - run: | - cargo build - working-directory: ./lightweight diff --git a/.github/workflows/loco-gen-ci.yml b/.github/workflows/loco-gen-ci.yml index cf097c80e..6c72b4c82 100644 --- a/.github/workflows/loco-gen-ci.yml +++ b/.github/workflows/loco-gen-ci.yml @@ -58,10 +58,9 @@ jobs: uses: Swatinem/rust-cache@v2 - run: | - cargo install --path ../loco-cli + cargo install --path ../loco-new - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - args: --all-features + run: cargo test --all-features + env: + LOCO_DEV_MODE_PATH: ${{ github.workspace }} diff --git a/.github/workflows/loco-cli.yml b/.github/workflows/loco-new.yml similarity index 60% rename from .github/workflows/loco-cli.yml rename to .github/workflows/loco-new.yml index 09f8874bb..300ab45a2 100644 --- a/.github/workflows/loco-cli.yml +++ b/.github/workflows/loco-new.yml @@ -1,10 +1,14 @@ -name: "[loco-cli:ci]" +name: "[loco-new:ci]" on: push: branches: - master + paths: + - "loco-new/**" pull_request: + paths: + - "loco-new/**" env: RUST_TOOLCHAIN: stable @@ -27,13 +31,13 @@ jobs: - name: Setup Rust cache uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - working-directory: ./loco-cli + working-directory: ./loco-new - name: Run cargo clippy run: cargo clippy --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms - working-directory: ./loco-cli + working-directory: ./loco-new test: - needs: [style] + # needs: [style] runs-on: ${{ matrix.os }} strategy: matrix: @@ -53,8 +57,18 @@ jobs: - name: Setup Rust cache uses: Swatinem/rust-cache@v2 + - name: Configure sccache + run: | + echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.6 + - name: Run cargo test - run: cargo test --all-features --all - working-directory: ./loco-cli + run: cargo test --all-features -- --test-threads 1 + working-directory: ./loco-new env: - LOCO_CI_MODE: 1 + LOCO_DEV_MODE_PATH: ${{ github.workspace }} + # NOTE NOTE NOTE: this is for optimizing build and may result in strange behavior + CARGO_TARGET_DIR: /tmp/shared-target diff --git a/.github/workflows/loco-rs-ci.yml b/.github/workflows/loco-rs-ci.yml index 4c822a841..5129cfc2b 100644 --- a/.github/workflows/loco-rs-ci.yml +++ b/.github/workflows/loco-rs-ci.yml @@ -57,4 +57,4 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --all-features --workspace --exclude loco-gen + args: --all-features --workspace --exclude loco-gen --exclude loco diff --git a/.github/workflows/starter-lightweight-service.yml b/.github/workflows/starter-lightweight-service.yml deleted file mode 100644 index c8f0d8ccd..000000000 --- a/.github/workflows/starter-lightweight-service.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: "[starters/lightweight:ci]" - -on: - push: - branches: - - master - paths: - - starters/lightweight-service/** - pull_request: - paths: - - starters/lightweight-service/** - -env: - RUST_TOOLCHAIN: stable - TOOLCHAIN_PROFILE: minimal - -jobs: - style: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - components: rustfmt - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - run: cargo fmt --all -- --check - working-directory: ./starters/lightweight-service - - name: Run cargo clippy - run: cargo clippy -- -W clippy::nursery -W clippy::pedantic -W rust-2018-idioms -W rust-2021-compatibility - working-directory: ./starters/lightweight-service - - test: - needs: [style] - runs-on: ubuntu-latest - - permissions: - contents: read - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - name: Run cargo test - run: cargo test --all-features --all - working-directory: ./starters/lightweight-service - - # generate_template: - # name: Generate Template - # needs: [test] - # runs-on: ubuntu-latest - - # permissions: - # contents: read - - # steps: - # - name: Checkout the code - # uses: actions/checkout@v4 - # - uses: dtolnay/rust-toolchain@stable - # with: - # toolchain: ${{ env.RUST_TOOLCHAIN }} - # - name: Setup Rust cache - # uses: Swatinem/rust-cache@v2 - # - name: Inject slug/short variables - # uses: rlespinasse/github-slug-action@v3.x - # - name: Generate template - # run: | - # cargo build --release --features github_ci - # RUST_LOG=debug LOCO_CURRENT_REPOSITORY=${{ github.event.pull_request.head.repo.html_url }} LOCO_CI_MODE=true LOCO_APP_NAME=stateless_html_starter LOCO_TEMPLATE=stateless_html LOCO_BRANCH=${{ env.GITHUB_HEAD_REF_SLUG }} ./target/release/loco new - # cd stateless_html_starter - # echo "Building generate template..." - # cargo build --release - # echo "Run cargo test on generated template..." - # cargo test - # working-directory: ./loco-cli diff --git a/.github/workflows/starter-rest-api.yml b/.github/workflows/starter-rest-api.yml deleted file mode 100644 index 56f4d9b25..000000000 --- a/.github/workflows/starter-rest-api.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: "[starters/rest-api:ci]" - -on: - push: - branches: - - master - paths: - - starters/rest-api/** - pull_request: - paths: - - starters/rest-api/** - -env: - RUST_TOOLCHAIN: stable - TOOLCHAIN_PROFILE: minimal - -jobs: - style: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - components: rustfmt - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - run: cargo fmt --all -- --check - working-directory: ./starters/rest-api - - name: Run cargo clippy - run: cargo clippy -- -W clippy::nursery -W clippy::pedantic -W rust-2018-idioms -W rust-2021-compatibility - working-directory: ./starters/rest-api - - test: - needs: [style] - runs-on: ubuntu-latest - strategy: - matrix: - db: - - "postgres://postgres:postgres@localhost:5432/postgres_test" - - "sqlite://loco_app.sqlite?mode=rwc" - - permissions: - contents: read - - services: - redis: - image: redis - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - "6379:6379" - postgres: - image: postgres - env: - POSTGRES_DB: postgres_test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - # Set health checks to wait until postgres has started - options: --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - name: Install seaorm cli - run: cargo install sea-orm-cli - - name: Run cargo test - run: cargo loco db reset && cargo loco db entities && cargo test --all-features --all - working-directory: ./starters/rest-api - env: - REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} - DATABASE_URL: ${{matrix.db}} - # generate_template: - # name: Generate Template - # needs: [test] - # runs-on: ubuntu-latest - - # permissions: - # contents: read - - # steps: - # - name: Checkout the code - # uses: actions/checkout@v4 - # - uses: dtolnay/rust-toolchain@stable - # with: - # toolchain: ${{ env.RUST_TOOLCHAIN }} - # - name: Setup Rust cache - # uses: Swatinem/rust-cache@v2 - # - name: Inject slug/short variables - # uses: rlespinasse/github-slug-action@v3.x - # - name: Generate template - # run: | - # cargo build --release --features github_ci - # RUST_LOG=debug LOCO_CURRENT_REPOSITORY=${{ github.event.pull_request.head.repo.html_url }} LOCO_CI_MODE=true LOCO_APP_NAME=stateless_starter LOCO_TEMPLATE=stateless LOCO_BRANCH=${{ env.GITHUB_HEAD_REF_SLUG }} ./target/release/loco new - # cd stateless_starter - # echo "Building generate template..." - # cargo build --release - # echo "Run cargo test on generated template..." - # cargo test - # working-directory: ./loco-cli diff --git a/.github/workflows/starter-saas.yml b/.github/workflows/starter-saas.yml deleted file mode 100644 index a59e0be4f..000000000 --- a/.github/workflows/starter-saas.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: "[starters/saas:ci]" - -on: - push: - branches: - - master - paths: - - starters/saas/** - pull_request: - paths: - - starters/saas/** - -env: - RUST_TOOLCHAIN: stable - TOOLCHAIN_PROFILE: minimal - -jobs: - style: - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - components: rustfmt - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - run: cargo fmt --all -- --check - working-directory: ./starters/saas - - name: Run cargo clippy - run: cargo clippy -- -W clippy::nursery -W clippy::pedantic -W rust-2018-idioms -W rust-2021-compatibility - working-directory: ./starters/saas - - test: - needs: [style] - runs-on: ubuntu-latest - strategy: - matrix: - db: - - "postgres://postgres:postgres@localhost:5432/postgres_test" - - "sqlite://loco_app.sqlite?mode=rwc" - - permissions: - contents: read - - services: - redis: - image: redis - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - "6379:6379" - postgres: - image: postgres - env: - POSTGRES_DB: postgres_test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - # Set health checks to wait until postgres has started - options: --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - name: Install seaorm cli - run: cargo install sea-orm-cli - - name: Run cargo test - run: cargo loco db reset && cargo loco db entities && cargo test --all-features --all - working-directory: ./starters/saas - env: - REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} - DATABASE_URL: ${{matrix.db}} - - # generate_template: - # name: Generate Template - # needs: [test] - # runs-on: ubuntu-latest - - # permissions: - # contents: read - - # services: - # redis: - # image: redis - # options: >- - # --health-cmd "redis-cli ping" - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 - # ports: - # - "6379:6379" - # postgres: - # image: postgres - # env: - # POSTGRES_DB: postgres_test - # POSTGRES_USER: postgres - # POSTGRES_PASSWORD: postgres - # ports: - # - "5432:5432" - # # Set health checks to wait until postgres has started - # options: --health-cmd pg_isready - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 - # - # steps: - # - name: Checkout the code - # uses: actions/checkout@v4 - # - uses: dtolnay/rust-toolchain@stable - # with: - # toolchain: ${{ env.RUST_TOOLCHAIN }} - # - name: Setup Rust cache - # uses: Swatinem/rust-cache@v2 - # - name: Inject slug/short variables - # uses: rlespinasse/github-slug-action@v3.x - # - name: Generate template - # run: | - # cargo build --release --features github_ci - # RUST_LOG=debug LOCO_CURRENT_REPOSITORY=${{ github.event.pull_request.head.repo.html_url }} LOCO_CI_MODE=true LOCO_APP_NAME=saas_starter LOCO_TEMPLATE=saas LOCO_BRANCH=${{ env.GITHUB_HEAD_REF_SLUG }} ./target/release/loco new - # cd saas_starter - # echo "Building generate template..." - # cargo build --release - # echo "Run cargo test on generated template..." - # cargo test - # working-directory: ./loco-cli - # env: - # APP_REDIS_URI: redis://localhost:${{job.services.redis.ports[6379]}} - # APP_DATABASE_URI: postgres://postgres:postgres@localhost:5432/postgres_test diff --git a/Cargo.toml b/Cargo.toml index ad57098a2..ab93f5e5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["xtask", "loco-gen"] +members = ["xtask", "loco-gen", "loco-new"] exclude = ["starters"] [workspace.package] @@ -138,6 +138,7 @@ english-to-cron = { version = "0.1.2" } # bg_pg: postgres workers sqlx = { version = "0.8.2", default-features = false, features = [ "postgres", + "chrono", "sqlite", ], optional = true } ulid = { version = "1", optional = true } diff --git a/README-pt_BR.md b/README-pt_BR.md index 81f2e3af6..ce34820a5 100644 --- a/README-pt_BR.md +++ b/README-pt_BR.md @@ -54,7 +54,7 @@ Para ver mais recursos do Loco, confira nosso [site de documentação](https://l ## Começando ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # Only when DB is needed ``` diff --git a/README-zh_CN.md b/README-zh_CN.md index 9bc439a55..123c0e2c1 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -27,7 +27,7 @@ Loco 是一个用 Rust 编写的 Web 框架,类似于 Rails。Loco 提供快 通过 Cargo 安装 Loco: ```sh -cargo install loco-cli +cargo install loco ``` ## 快速开始 diff --git a/README.fr.md b/README.fr.md index 8c21a21db..52e55f129 100644 --- a/README.fr.md +++ b/README.fr.md @@ -48,7 +48,7 @@ Pour en savoir plus sur les fonctionnalités de Loco, consultez notre [site Web ## Commencez rapidement ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # Only when DB is needed ``` diff --git a/README.ja.md b/README.ja.md index 4ab130385..4843c141e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,7 +47,7 @@ Locoの詳細な機能については、[ドキュメントウェブサイト](h ## 始め方 ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # データベースが必要な場合のみ ``` diff --git a/README.md b/README.md index f445016c2..eafc5c5df 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ So see more Loco features, check out our [documentation website](https://loco.rs ## Getting Started ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # Only when DB is needed ``` diff --git a/docs-site/content/blog/axum-session.md b/docs-site/content/blog/axum-session.md index 7054dcb05..95e8a21e1 100644 --- a/docs-site/content/blog/axum-session.md +++ b/docs-site/content/blog/axum-session.md @@ -16,7 +16,7 @@ To build a Rust app with [Axum session](https://crates.io/crates/axum_session), Start by creating a new project and selecting the `SaaS app` template: ```sh -$ cargo install loco-cli +$ cargo install loco $ loco new ✔ ❯ App name? · myapp ? ❯ What would you like to build? › diff --git a/docs-site/content/blog/deploy-aws.md b/docs-site/content/blog/deploy-aws.md index 51d18f203..8752ae3ed 100644 --- a/docs-site/content/blog/deploy-aws.md +++ b/docs-site/content/blog/deploy-aws.md @@ -18,7 +18,7 @@ In this article, we will explore how to deploy a Rust app built with [loco](http ````sh ```sh -$ cargo install loco-cli +$ cargo install loco $ loco new ✔ ❯ App name? · myapp ? ❯ What would you like to build? › diff --git a/docs-site/content/docs/extras/authentication.md b/docs-site/content/docs/extras/authentication.md index 561fd5ffe..05acd694a 100644 --- a/docs-site/content/docs/extras/authentication.md +++ b/docs-site/content/docs/extras/authentication.md @@ -25,7 +25,7 @@ The `auth` feature comes as a default with the library. If desired, you can turn ### Getting Started with a SaaS App -Create your app using the [loco-cli](/docs/getting-started/tour) and select the `SaaS app (with DB and user auth)` option. +Create your app using the [loco cli](/docs/getting-started/tour) and select the `SaaS app (with DB and user auth)` option. To explore the out-of-the-box auth controllers, run the following command: @@ -177,7 +177,7 @@ async fn current( ### Creating new app -For this time, let create your rest app using the [loco-cli](/docs/getting-started/tour) and select the `Rest app` option. +For this time, let create your rest app using the [loco cli](/docs/getting-started/tour) and select the `Rest app` option. To create new app, run the following command and follow the instructions: ```sh diff --git a/docs-site/content/docs/getting-started/guide.md b/docs-site/content/docs/getting-started/guide.md index cbf2c4fa8..bc4f9b813 100644 --- a/docs-site/content/docs/getting-started/guide.md +++ b/docs-site/content/docs/getting-started/guide.md @@ -51,7 +51,7 @@ You can follow this guide for a step-by-step "bottom up" learning, or you can ju ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # Only when DB is needed ``` diff --git a/docs-site/content/docs/getting-started/starters.md b/docs-site/content/docs/getting-started/starters.md index 09fc01015..31ba3320e 100644 --- a/docs-site/content/docs/getting-started/starters.md +++ b/docs-site/content/docs/getting-started/starters.md @@ -17,7 +17,7 @@ Simplify your project setup with Loco's predefined boilerplates, designed to mak ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # Only when DB is needed ``` diff --git a/docs-site/content/docs/getting-started/tour/index.md b/docs-site/content/docs/getting-started/tour/index.md index d99c4d46f..44aa5e4e9 100644 --- a/docs-site/content/docs/getting-started/tour/index.md +++ b/docs-site/content/docs/getting-started/tour/index.md @@ -18,11 +18,11 @@ flair =[]


-Let's create a blog backend on Loco in just a few minutes. First install `loco-cli` and `sea-orm-cli`: +Let's create a blog backend on Loco in just a few minutes. First install `loco` and `sea-orm-cli`: ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # Only when DB is needed ``` @@ -165,7 +165,7 @@ $ curl localhost:5150/posts For those counting -- the commands for creating a blog backend were: -1. `cargo install loco-cli` +1. `cargo install loco` 2. `cargo install sea-orm-cli` 3. `loco new` 4. `cargo loco generate scaffold post title:string content:text --api` diff --git a/docs-site/content/docs/processing/task.md b/docs-site/content/docs/processing/task.md index 51d88c540..114d9f582 100644 --- a/docs-site/content/docs/processing/task.md +++ b/docs-site/content/docs/processing/task.md @@ -32,17 +32,7 @@ Generate the task: ```sh -Generate a Task based on the given name - -Usage: demo_app-cli generate task [OPTIONS] - -Arguments: - Name of the thing to generate - -Options: - -e, --environment Specify the environment [default: development] - -h, --help Print help - -V, --version Print version +cd ./examples/demo && cargo loco generate task --help ``` diff --git a/docs-site/content/docs/the-app/your-project.md b/docs-site/content/docs/the-app/your-project.md index 22f8082cc..9c9b52952 100644 --- a/docs-site/content/docs/the-app/your-project.md +++ b/docs-site/content/docs/the-app/your-project.md @@ -43,27 +43,7 @@ cargo loco --help ```sh -The one-person framework for Rust - -Usage: demo_app-cli [OPTIONS] - -Commands: - start Start an app - db Perform DB operations - routes Describe all application endpoints - middleware Describe all application middlewares - task Run a custom task - scheduler Run the scheduler - generate code generation creates a set of files and code templates based on a predefined set of rules - doctor Validate and diagnose configurations - version Display the app version - watch Watch and restart the app - help Print this message or the help of the given subcommand(s) - -Options: - -e, --environment Specify the environment [default: development] - -h, --help Print help - -V, --version Print version +cd ./examples/demo && cargo loco --help ``` @@ -134,22 +114,7 @@ Scaffolding is an efficient and speedy method for generating key components of a See scaffold command: ```sh -Generates a CRUD scaffold, model and controller - -Usage: demo_app-cli generate scaffold [OPTIONS] [FIELDS]... - -Arguments: - Name of the thing to generate - [FIELDS]... Model fields, eg. title:string hits:int - -Options: - -k, --kind The kind of scaffold to generate [possible values: api, html, htmx] - --htmx Use HTMX scaffold - --html Use HTML scaffold - --api Use API scaffold - -e, --environment Specify the environment [default: development] - -h, --help Print help - -V, --version Print version +cd ./examples/demo && cargo loco generate scaffold --help ``` diff --git a/docs-site/translations/tour-fr.md b/docs-site/translations/tour-fr.md index 8a21b4683..1475b3921 100644 --- a/docs-site/translations/tour-fr.md +++ b/docs-site/translations/tour-fr.md @@ -19,11 +19,11 @@ flair =[]


-Créons un blog coté serveur sur Loco en quelques minutes. Commençons par installer `loco-cli` et `sea-orm-cli`: +Créons un blog coté serveur sur Loco en quelques minutes. Commençons par installer `loco` et `sea-orm-cli`: ```sh -cargo install loco-cli +cargo install loco cargo install sea-orm-cli # Only when DB is needed ``` @@ -146,7 +146,7 @@ $ curl localhost:5150/posts Pour ceux qui comptent -- les commandes pour créer un backend de blog étaient: -1. `cargo install loco-cli` +1. `cargo install loco` 2. `cargo install sea-orm-cli` 3. `loco new` 4. `cargo loco generate scaffold post title:string content:text` diff --git a/loco-cli/src/bin/main.rs b/loco-cli/src/bin/main.rs index 44deca9b0..866473bd5 100644 --- a/loco-cli/src/bin/main.rs +++ b/loco-cli/src/bin/main.rs @@ -51,6 +51,17 @@ enum Commands { } #[allow(clippy::unnecessary_wraps)] fn main() -> eyre::Result<()> { + println!(""); + println!(""); + println!("!!!!!"); + println!("!!!!! NOTE: `loco-cli` is now replaced with `loco` which is a much more powerful "); + println!("!!!!! and flexible new app creator for Loco. To install the new CLI run:"); + println!("!!!!!"); + println!("!!!!! $ cargo uninstall loco-cli && cargo install loco"); + println!("!!!!!"); + println!(""); + println!(""); + println!(""); let cli = Cli::parse(); tracing_subscriber::fmt() diff --git a/loco-gen/src/model.rs b/loco-gen/src/model.rs index 0d70c1100..0e1a74041 100644 --- a/loco-gen/src/model.rs +++ b/loco-gen/src/model.rs @@ -108,22 +108,20 @@ mod tests { where F: FnOnce(), { - testutil::with_temp_dir(|previous, current| { + testutil::with_temp_dir(|_previous, current| { let status = Command::new("loco") .args([ "new", "-n", app_name, - "-t", - "saas", "--db", "sqlite", "--bg", "async", "--assets", "serverside", + "-a", ]) - .env("STARTERS_LOCAL_PATH", previous.join("../")) .status() .expect("cannot run command"); diff --git a/loco-new/Cargo.toml b/loco-new/Cargo.toml new file mode 100644 index 000000000..f2f71e97c --- /dev/null +++ b/loco-new/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "loco" +version = "0.2.10" +edition = "2021" +description = "Loco new app generator" +license = "Apache-2.0" +homepage = "https://docs.rs/loco" +documentation = "https://docs.rs/loco" +authors = ["Dotan Nahum ", "Elad Kaplan "] + +[features] +test-wizard = [] + +[profile.release] +strip = true + +[[bin]] +name = "loco" +path = "src/bin/main.rs" +required-features = [] + + +[dependencies] +thiserror = { version = "1.0.63" } +clap = { version = "4.4.7", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1.0" } +serde_variant = { version = "0.1.3" } +tracing = { version = "0.1.40" } +tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } +heck = { version = "0.5.0" } +dialoguer = "0.11.0" +strum = { version = "0.26", features = ["derive"] } +unicode-xid = { version = "0.2.6" } +rhai = { version = "1.20.0" } +include_dir = { version = "0.7.4" } +fs_extra = { version = "1.3.0" } +walkdir = { version = "2.5.0" } +tera = { version = "1.20.0" } +colored = { version = "2" } +duct = { version = "0.13.6" } +rand = { version = "0.8.5" } + +[dev-dependencies] +uuid = { version = "1.11.0", features = ["v4", "fast-rng"] } +serde_yaml = { version = "0.9" } +insta = { version = "1.41.1", features = ["redactions", "yaml", "filters"] } +rstest = { version = "0.23.0" } +tree-fs = "0.2.0" +mockall = "0.13.0" +toml = "0.8.19" +regex = "1.11.1" diff --git a/loco-new/base_template/.cargo/config.toml b/loco-new/base_template/.cargo/config.toml new file mode 100644 index 000000000..fb921ea85 --- /dev/null +++ b/loco-new/base_template/.cargo/config.toml @@ -0,0 +1,4 @@ +[alias] +loco = "run --" +loco-tool = "run --bin tool --" +playground = "run --example playground" diff --git a/loco-new/base_template/.github/workflows/ci.yaml b/loco-new/base_template/.github/workflows/ci.yaml new file mode 100644 index 000000000..75ba8a5e3 --- /dev/null +++ b/loco-new/base_template/.github/workflows/ci.yaml @@ -0,0 +1,102 @@ +name: CI +on: + push: + branches: + - master + - main + pull_request: + +env: + RUST_TOOLCHAIN: stable + TOOLCHAIN_PROFILE: minimal + +jobs: + rustfmt: + name: Check Style + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt + - name: Run cargo fmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + clippy: + name: Run Clippy + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms + + test: + name: Run Tests + runs-on: ubuntu-latest + + permissions: + contents: read + + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - "6379:6379" + postgres: + image: postgres + env: + POSTGRES_DB: postgres_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + # Set health checks to wait until postgres has started + options: --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + - name: Run cargo test + uses: actions-rs/cargo@v1 + with: + command: test + args: --all-features --all + env: + REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} + DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test + diff --git a/loco-new/base_template/.gitignore b/loco-new/base_template/.gitignore new file mode 100644 index 000000000..d83d21a83 --- /dev/null +++ b/loco-new/base_template/.gitignore @@ -0,0 +1,19 @@ +**/config/local.yaml +**/config/*.local.yaml +**/config/production.yaml + +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# include cargo lock +!Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +*.sqlite \ No newline at end of file diff --git a/loco-new/base_template/.rustfmt.toml b/loco-new/base_template/.rustfmt.toml new file mode 100644 index 000000000..d862e0810 --- /dev/null +++ b/loco-new/base_template/.rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 100 +use_small_heuristics = "Default" diff --git a/loco-new/base_template/Cargo.toml.t b/loco-new/base_template/Cargo.toml.t new file mode 100644 index 000000000..d449a99c9 --- /dev/null +++ b/loco-new/base_template/Cargo.toml.t @@ -0,0 +1,70 @@ +{%- set_global feature_list = [] -%} +{%- if settings.features.names | length > 0 -%} + {%- for name in settings.features.names -%} + {%- set_global feature_list = feature_list | concat(with=['"' ~ name ~ '"']) -%} + {%- endfor -%} +{%- endif -%} +[workspace] + +[package] +name = "{{settings.package_name}}" +version = "0.1.0" +edition = "2021" +publish = false +default-run = "{{settings.module_name}}-cli" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[workspace.dependencies] +loco-rs = { {{settings.loco_version_text}} {%- if not settings.features.default_features %}, default-features = false {%- endif %} } + +[dependencies] +loco-rs = { workspace = true {% if feature_list | length > 0 %}, features = {{feature_list}}{% endif %} } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1.33.0", default-features = false, features = [ + "rt-multi-thread", +] } +async-trait = "0.1.74" +axum = "0.7.5" +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] } +{%- if settings.db %} +migration = { path = "migration" } +sea-orm = { version = "1.1.0", features = [ + "sqlx-sqlite", + "sqlx-postgres", + "runtime-tokio-rustls", + "macros", +] } +chrono = "0.4" +validator = { version = "0.18" } +uuid = { version = "1.6.0", features = ["v4"] } +{%- endif %} + +{%- if settings.mailer %} +include_dir = "0.7" +{%- endif %} + +{%- if settings.asset %} +# view engine i18n +fluent-templates = { version = "0.8.0", features = ["tera"] } +unic-langid = "0.9.4" +# /view engine +{%- endif %} + +[[bin]] +name = "{{settings.module_name}}-cli" +path = "src/bin/main.rs" +required-features = [] + +[[bin]] +name = "tool" +path = "src/bin/tool.rs" +required-features = [] + +[dev-dependencies] +loco-rs = { workspace = true, features = ["testing"] } +serial_test = "3.1.1" +rstest = "0.21.0" +insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] } diff --git a/loco-new/base_template/README.md b/loco-new/base_template/README.md new file mode 100644 index 000000000..43b9bddaa --- /dev/null +++ b/loco-new/base_template/README.md @@ -0,0 +1,58 @@ +# Welcome to Loco :train: + +[Loco](https://loco.rs) is a web and API framework running on Rust. + +This is the **SaaS starter** which includes a `User` model and authentication based on JWT. +It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server. + + +## Quick Start + +```sh +cargo loco start +``` + +```sh +$ cargo loco start +Finished dev [unoptimized + debuginfo] target(s) in 21.63s + Running `target/debug/myapp start` + + : + : + : + +controller/app_routes.rs:203: [Middleware] Adding log trace id + + ▄ ▀ + ▀ ▄ + ▄ ▀ ▄ ▄ ▄▀ + ▄ ▀▄▄ + ▄ ▀ ▀ ▀▄▀█▄ + ▀█▄ +▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█ + ██████ █████ ███ █████ ███ █████ ███ ▀█ + ██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄ + ██████ █████ ███ █████ █████ ███ ████▄ + ██████ █████ ███ █████ ▄▄▄ █████ ███ █████ + ██████ █████ ███ ████ ███ █████ ███ ████▀ + ▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + https://loco.rs + +environment: development + database: automigrate + logger: debug +compilation: debug + modes: server + +listening on http://localhost:5150 +``` + +## Full Stack Serving + +You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections. + + +## Getting help + +Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/). diff --git a/loco-new/base_template/assets/i18n/de-DE/main.ftl b/loco-new/base_template/assets/i18n/de-DE/main.ftl new file mode 100644 index 000000000..ced609fe4 --- /dev/null +++ b/loco-new/base_template/assets/i18n/de-DE/main.ftl @@ -0,0 +1,4 @@ +hello-world = Hallo Welt! +greeting = Hallochen { $name }! + .placeholder = Hallo Freund! +about = Uber diff --git a/loco-new/base_template/assets/i18n/en-US/main.ftl b/loco-new/base_template/assets/i18n/en-US/main.ftl new file mode 100644 index 000000000..9d4d5e7c4 --- /dev/null +++ b/loco-new/base_template/assets/i18n/en-US/main.ftl @@ -0,0 +1,10 @@ +hello-world = Hello World! +greeting = Hello { $name }! + .placeholder = Hello Friend! +about = About +simple = simple text +reference = simple text with a reference: { -something } +parameter = text with a { $param } +parameter2 = text one { $param } second { $multi-word-param } +email = text with an EMAIL("example@example.org") +fallback = this should fall back diff --git a/loco-new/base_template/assets/i18n/shared.ftl b/loco-new/base_template/assets/i18n/shared.ftl new file mode 100644 index 000000000..f169eca9d --- /dev/null +++ b/loco-new/base_template/assets/i18n/shared.ftl @@ -0,0 +1 @@ +-something = foo diff --git a/loco-new/base_template/assets/static/404.html b/loco-new/base_template/assets/static/404.html new file mode 100644 index 000000000..66e78fb22 --- /dev/null +++ b/loco-new/base_template/assets/static/404.html @@ -0,0 +1,3 @@ + +not found :-( + diff --git a/loco-new/base_template/assets/static/image.png b/loco-new/base_template/assets/static/image.png new file mode 100644 index 000000000..fa5a09508 Binary files /dev/null and b/loco-new/base_template/assets/static/image.png differ diff --git a/loco-new/base_template/assets/views/home/hello.html b/loco-new/base_template/assets/views/home/hello.html new file mode 100644 index 000000000..6b97c398e --- /dev/null +++ b/loco-new/base_template/assets/views/home/hello.html @@ -0,0 +1,12 @@ + + +
+ find this tera template at assets/views/home/hello.html: +
+
+ {{ t(key="hello-world", lang="en-US") }}, +
+ {{ t(key="hello-world", lang="de-DE") }} + + + \ No newline at end of file diff --git a/loco-new/base_template/config/development.yaml.t b/loco-new/base_template/config/development.yaml.t new file mode 100644 index 000000000..44ba73f03 --- /dev/null +++ b/loco-new/base_template/config/development.yaml.t @@ -0,0 +1,129 @@ +# Loco configuration file documentation + +# Application logging configuration +logger: + # Enable or disable logging. + enable: true + # Enable pretty backtrace (sets RUST_BACKTRACE=1) + pretty_backtrace: true + # Log level, options: trace, debug, info, warn or error. + level: debug + # Define the logging format. options: compact, pretty or json + format: compact + # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries + # Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters. + # override_filter: trace + +# Web server configuration +server: + # Port on which the server will listen. the server binding is 0.0.0.0:{PORT} + port: 5150 + # The UI hostname or IP address that mailers will point to. + host: http://localhost + # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block + middlewares: + {%- if settings.asset %} + {%- if settings.asset.kind == "server" %} + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/static" + path: "assets/static" + fallback: "assets/static/404.html" + {%- elif settings.asset.kind == "client" %} + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/" + path: "frontend/dist" + fallback: "frontend/dist/index.html" + {%- endif -%} + + {%- endif -%} + +{%- if settings.background%} + +# Worker Configuration +workers: + # specifies the worker mode. Options: + # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. + # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. + # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. + mode: {{settings.background.kind}} + + {% if settings.background.kind == "BackgroundQueue"%} +# Queue Configuration +queue: + kind: Redis + # Redis connection URI + uri: {% raw %}{{{% endraw %} get_env(name="REDIS_URL", default="redis://127.0.0.1") {% raw %}}}{% endraw %} + # Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_flush: false + {%- endif %} +{%- endif -%} + +{%- if settings.mailer %} + +# Mailer Configuration. +mailer: + # SMTP mailer configuration. + smtp: + # Enable/Disable smtp mailer. + enable: true + # SMTP server host. e.x localhost, smtp.gmail.com + host: {{ get_env(name="MAILER_HOST", default="localhost") }} + # SMTP server port + port: 1025 + # Use secure connection (SSL/TLS). + secure: false + # auth: + # user: + # password: +{%- endif %} + +# Initializers Configuration +# initializers: +# oauth2: +# authorization_code: # Authorization code grant type +# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config. +# ... other fields + +{%- if settings.db %} + +# Database Configuration +database: + # Database connection URI + uri: {% raw %}{{{% endraw %} get_env(name="DATABASE_URL", default="{{settings.db.endpoint}}") {% raw %}}}{% endraw %} + # When enabled, the sql query will be logged. + enable_logging: false + # Set the timeout duration when acquiring a connection. + connect_timeout: {% raw %}{{{% endraw %} get_env(name="DB_CONNECT_TIMEOUT", default="500") {% raw %}}}{% endraw %} + # Set the idle duration before closing a connection. + idle_timeout: {% raw %}{{{% endraw %} get_env(name="DB_IDLE_TIMEOUT", default="500") {% raw %}}}{% endraw %} + # Minimum number of connections for a pool. + min_connections: {% raw %}{{{% endraw %} get_env(name="DB_MIN_CONNECTIONS", default="1") {% raw %}}}{% endraw %} + # Maximum number of connections for a pool. + max_connections: {% raw %}{{{% endraw %} get_env(name="DB_MAX_CONNECTIONS", default="1") {% raw %}}}{% endraw %} + # Run migration up when application loaded + auto_migrate: true + # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_truncate: false + # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_recreate: false +{%- endif %} + +{%- if settings.auth %} + +# Authentication Configuration +auth: + # JWT authentication + jwt: + # Secret key for token generation and verification + secret: {{20 | random_string }} + # Token expiration time in seconds + expiration: 604800 # 7 days +{%- endif %} diff --git a/loco-new/base_template/config/production.yaml b/loco-new/base_template/config/production.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/loco-new/base_template/config/test.yaml.t b/loco-new/base_template/config/test.yaml.t new file mode 100644 index 000000000..dcc902fc7 --- /dev/null +++ b/loco-new/base_template/config/test.yaml.t @@ -0,0 +1,129 @@ +# Loco configuration file documentation + +# Application logging configuration +logger: + # Enable or disable logging. + enable: false + # Enable pretty backtrace (sets RUST_BACKTRACE=1) + pretty_backtrace: true + # Log level, options: trace, debug, info, warn or error. + level: debug + # Define the logging format. options: compact, pretty or json + format: compact + # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries + # Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters. + # override_filter: trace + +# Web server configuration +server: + # Port on which the server will listen. the server binding is 0.0.0.0:{PORT} + port: 5150 + # The UI hostname or IP address that mailers will point to. + host: http://localhost + # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block + middlewares: + {%- if settings.asset %} + {%- if settings.asset.kind == "server" %} + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/static" + path: "assets/static" + fallback: "assets/static/404.html" + {%- elif settings.asset.kind == "client" %} + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/" + path: "frontend/dist" + fallback: "frontend/dist/index.html" + {%- endif -%} + + {%- endif -%} + +{%- if settings.background%} + +# Worker Configuration +workers: + # specifies the worker mode. Options: + # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. + # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. + # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. + mode: {{settings.background.kind}} + + {% if settings.background.kind == "BackgroundQueue"%} +# Queue Configuration +queue: + kind: Redis + # Redis connection URI + uri: {% raw %}{{{% endraw %} get_env(name="REDIS_URL", default="redis://127.0.0.1") {% raw %}}}{% endraw %} + # Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_flush: false + {%- endif %} +{%- endif -%} + +{%- if settings.mailer %} + +# Mailer Configuration. +mailer: + # SMTP mailer configuration. + smtp: + # Enable/Disable smtp mailer. + enable: true + # SMTP server host. e.x localhost, smtp.gmail.com + host: {{ get_env(name="MAILER_HOST", default="localhost") }} + # SMTP server port + port: 1025 + # Use secure connection (SSL/TLS). + secure: false + # auth: + # user: + # password: +{%- endif %} + +# Initializers Configuration +# initializers: +# oauth2: +# authorization_code: # Authorization code grant type +# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config. +# ... other fields + +{%- if settings.db %} + +# Database Configuration +database: + # Database connection URI + uri: {% raw %}{{{% endraw %} get_env(name="DATABASE_URL", default="{{settings.db.endpoint}}") {% raw %}}}{% endraw %} + # When enabled, the sql query will be logged. + enable_logging: false + # Set the timeout duration when acquiring a connection. + connect_timeout: {% raw %}{{{% endraw %} get_env(name="DB_CONNECT_TIMEOUT", default="500") {% raw %}}}{% endraw %} + # Set the idle duration before closing a connection. + idle_timeout: {% raw %}{{{% endraw %} get_env(name="DB_IDLE_TIMEOUT", default="500") {% raw %}}}{% endraw %} + # Minimum number of connections for a pool. + min_connections: {% raw %}{{{% endraw %} get_env(name="DB_MIN_CONNECTIONS", default="1") {% raw %}}}{% endraw %} + # Maximum number of connections for a pool. + max_connections: {% raw %}{{{% endraw %} get_env(name="DB_MAX_CONNECTIONS", default="1") {% raw %}}}{% endraw %} + # Run migration up when application loaded + auto_migrate: true + # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_truncate: true + # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_recreate: false +{%- endif %} + +{%- if settings.auth %} + +# Authentication Configuration +auth: + # JWT authentication + jwt: + # Secret key for token generation and verification + secret: {{20 | random_string }} + # Token expiration time in seconds + expiration: 604800 # 7 days +{%- endif %} diff --git a/loco-new/base_template/examples/playground.rs.t b/loco-new/base_template/examples/playground.rs.t new file mode 100644 index 000000000..2a2c362fd --- /dev/null +++ b/loco-new/base_template/examples/playground.rs.t @@ -0,0 +1,21 @@ +#[allow(unused_imports)] +use loco_rs::{cli::playground, prelude::*}; +use {{settings.module_name}}::app::App; + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + let _ctx = playground::().await?; + + // let active_model: articles::ActiveModel = ActiveModel { + // title: Set(Some("how to build apps in 3 steps".to_string())), + // content: Set(Some("use Loco: https://loco.rs".to_string())), + // ..Default::default() + // }; + // active_model.insert(&ctx.db).await.unwrap(); + + // let res = articles::Entity::find().all(&ctx.db).await.unwrap(); + // println!("{:?}", res); + println!("welcome to playground. edit me at `examples/playground.rs`"); + + Ok(()) +} diff --git a/loco-new/base_template/frontend/.gitignore b/loco-new/base_template/frontend/.gitignore new file mode 100644 index 000000000..a1dccae53 --- /dev/null +++ b/loco-new/base_template/frontend/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist-ssr +dist/ +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Common local dotenv files popularised by Create React App & Next.js +# https://rsbuild.dev/guide/advanced/env-vars#env-file +.env.local +.env.development.local +.env.production.local +.env.test.local diff --git a/loco-new/base_template/frontend/README.md b/loco-new/base_template/frontend/README.md new file mode 100644 index 000000000..9fd9aed41 --- /dev/null +++ b/loco-new/base_template/frontend/README.md @@ -0,0 +1,42 @@ +# SaaS Frontend + +## Batteries included + +- [TypeScript](https://www.typescriptlang.org/): A typed superset of JavaScript +- [Rsbuild](https://rsbuild.dev/): A Rust-based web build tool +- [Biome](https://biomejs.dev/): A Rust-based formatter and sensible linter for the web +- [React](https://reactjs.org/): A JavaScript library for building user interfaces + +If you don't like React for some reason, Rsbuild makes it easy to replace it with something else! + +# Development + +To get started with the development of the SaaS frontend, follow these steps: + +### 1. Install Packages + +Use the following command to install the required packages using pnpm: + +```sh +pnpm install +``` + +### 2. Run in Development Mode + +Once the packages are installed, run your frontend application in development mode with the following command: + +```sh +pnpm dev +``` + +This will start the development frontend server serving via vit + +### 3. Build The application + +To build your application run the following command: + +```sh +pnpm build +``` + +After the build `dist` folder is ready to served by loco. run loco `cargo loco start` and the frontend application will served via Loco \ No newline at end of file diff --git a/loco-new/base_template/frontend/biome.json b/loco-new/base_template/frontend/biome.json new file mode 100644 index 000000000..0dd32511a --- /dev/null +++ b/loco-new/base_template/frontend/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.2/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "enabled": true, + "indentStyle": "space" + } + }, + "json": { + "formatter": { + "enabled": true, + "indentStyle": "space" + } + } +} diff --git a/loco-new/base_template/frontend/package.json b/loco-new/base_template/frontend/package.json new file mode 100644 index 000000000..dd7a791da --- /dev/null +++ b/loco-new/base_template/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "rsbuild dev --open", + "build": "rsbuild build", + "lint": "biome check src/", + "preview": "rsbuild preview" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@biomejs/biome": "^1", + "@rsbuild/core": "^1", + "@rsbuild/plugin-react": "^1", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "^5" + } +} diff --git a/loco-new/base_template/frontend/rsbuild.config.ts b/loco-new/base_template/frontend/rsbuild.config.ts new file mode 100644 index 000000000..d86582c5d --- /dev/null +++ b/loco-new/base_template/frontend/rsbuild.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; + +// https://rsbuild.dev/guide/basic/configure-rsbuild +export default defineConfig({ + plugins: [pluginReact()], + html: { + favicon: "src/assets/favicon.ico", + title: "Loco SaaS Starter", + }, + server: { + proxy: { + "/api": { + target: "http://127.0.0.1:5150", + changeOrigin: true, + secure: false, + }, + }, + }, +}); diff --git a/loco-new/base_template/frontend/src/LocoSplash.tsx b/loco-new/base_template/frontend/src/LocoSplash.tsx new file mode 100644 index 000000000..cb96ace54 --- /dev/null +++ b/loco-new/base_template/frontend/src/LocoSplash.tsx @@ -0,0 +1,105 @@ +export const LocoSplash = () => { + return ( + + ); +}; diff --git a/loco-new/base_template/frontend/src/assets/favicon.ico b/loco-new/base_template/frontend/src/assets/favicon.ico new file mode 100644 index 000000000..07f5b7372 Binary files /dev/null and b/loco-new/base_template/frontend/src/assets/favicon.ico differ diff --git a/loco-new/base_template/frontend/src/env.d.ts b/loco-new/base_template/frontend/src/env.d.ts new file mode 100644 index 000000000..b0ac762b0 --- /dev/null +++ b/loco-new/base_template/frontend/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/loco-new/base_template/frontend/src/index.css b/loco-new/base_template/frontend/src/index.css new file mode 100644 index 000000000..854aa19d6 --- /dev/null +++ b/loco-new/base_template/frontend/src/index.css @@ -0,0 +1,100 @@ +body { + margin: 0; + font-family: "Arimo", -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + background: #212529; + color: #dee2e6; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba(29, 45, 53, 0) +} + +ul { + margin-top: 0; + margin-bottom: 1rem; + list-style: none; +} + +a { + color: #dee2e6; + text-decoration: none +} + +.container { + max-width: 1320px; + padding-right: var(--bs-gutter-x, 24px); + padding-left: var(--bs-gutter-x, 24px); + margin-right: auto; + margin-left: auto +} + + +.navbar { + padding-top: .5rem; + padding-bottom: .5rem +} + +.navbar .container { + display: flex; + justify-content: space-between +} + +.navbar-nav { + margin-bottom: 0; +} +.navbar-nav li { + display: inline-flex; + margin-right: 10px; +} + +.fixed-top { + position: fixed; + top: 0; + right: 0; + left: 0; +} + +body { + font-size: 1rem; + padding-top: 6rem !important +} + +.navbar { + border-bottom: 1px solid #2a2f34; +} + + +.logo { + max-width: 1280px; + margin: 0 auto; + text-align: center; +} + +.logo img { + width: 250px; +} +footer { + position: absolute; + bottom: 0; + width: 100%; + text-align: center; +} + +footer ul { + display: inline-block; + padding: 0; +} + +footer ul li { + display: inline-flex; + align-items: center; + margin: 0 5px; + list-style: none; +} + +footer ul li:not(:last-child) { + border-right: 1px solid #ccc; + padding-right: 5px; + height: 15px; +} \ No newline at end of file diff --git a/loco-new/base_template/frontend/src/index.tsx b/loco-new/base_template/frontend/src/index.tsx new file mode 100644 index 000000000..8d48a1dd7 --- /dev/null +++ b/loco-new/base_template/frontend/src/index.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { LocoSplash } from "./LocoSplash"; + +import "./index.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("No root element found"); +} + +ReactDOM.createRoot(root).render( + + + , +); diff --git a/loco-new/base_template/frontend/tsconfig.json b/loco-new/base_template/frontend/tsconfig.json new file mode 100644 index 000000000..e6b9bdf4e --- /dev/null +++ b/loco-new/base_template/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["DOM", "ES2020"], + "module": "ESNext", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "moduleResolution": "bundler", + "useDefineForClassFields": true + }, + "include": ["src"] +} diff --git a/loco-new/base_template/migration/Cargo.toml b/loco-new/base_template/migration/Cargo.toml new file mode 100644 index 000000000..cb2ad9b20 --- /dev/null +++ b/loco-new/base_template/migration/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "migration" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "migration" +path = "src/lib.rs" + +[dependencies] +async-std = { version = "1", features = ["attributes", "tokio1"] } +loco-rs = { workspace = true } + + +[dependencies.sea-orm-migration] +version = "1.1.0" +features = [ + # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. + # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. + # e.g. + "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature +] diff --git a/loco-new/base_template/migration/src/lib.rs b/loco-new/base_template/migration/src/lib.rs new file mode 100644 index 000000000..d37c3d1b0 --- /dev/null +++ b/loco-new/base_template/migration/src/lib.rs @@ -0,0 +1,17 @@ +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; + +mod m20220101_000001_users; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20220101_000001_users::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-new/base_template/migration/src/m20220101_000001_users.rs b/loco-new/base_template/migration/src/m20220101_000001_users.rs new file mode 100644 index 000000000..936ad3d0c --- /dev/null +++ b/loco-new/base_template/migration/src/m20220101_000001_users.rs @@ -0,0 +1,50 @@ +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = table_auto_tz(Users::Table) + .col(pk_auto(Users::Id)) + .col(uuid(Users::Pid)) + .col(string_uniq(Users::Email)) + .col(string(Users::Password)) + .col(string(Users::ApiKey).unique_key()) + .col(string(Users::Name)) + .col(string_null(Users::ResetToken)) + .col(timestamp_with_time_zone_null(Users::ResetSentAt)) + .col(string_null(Users::EmailVerificationToken)) + .col(timestamp_with_time_zone_null( + Users::EmailVerificationSentAt, + )) + .col(timestamp_with_time_zone_null(Users::EmailVerifiedAt)) + .to_owned(); + manager.create_table(table).await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Users::Table).to_owned()) + .await + } +} + +#[derive(Iden)] +pub enum Users { + Table, + Id, + Pid, + Email, + Name, + Password, + ApiKey, + ResetToken, + ResetSentAt, + EmailVerificationToken, + EmailVerificationSentAt, + EmailVerifiedAt, +} diff --git a/loco-new/base_template/src/app.rs.t b/loco-new/base_template/src/app.rs.t new file mode 100644 index 000000000..f72de672d --- /dev/null +++ b/loco-new/base_template/src/app.rs.t @@ -0,0 +1,114 @@ +{%- if settings.db %} +use std::path::Path; +{%- endif %} +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + {%- if settings.background %} + BackgroundWorker, + {%- endif %} + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + {%- if settings.db %} + db::{self, truncate_table}, + {%- endif %} + environment::Environment, + task::Tasks, + Result, +}; +{%- if settings.db %} +use migration::Migrator; +use sea_orm::DatabaseConnection; +{%- endif %} + +use crate::{ + controllers + {%- if settings.initializers -%} + , initializers + {%- endif %} + {%- if settings.db %} + ,tasks + , models::_entities::users + {%- endif %} + {%- if settings.background %} + , workers::downloader::DownloadWorker + {%- endif %}, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + {%- if settings.db %} + create_app::(mode, environment).await + {% else %} + create_app::(mode, environment).await + {%- endif %} + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![ + {%- if settings.initializers.view_engine -%} + Box::new(initializers::view_engine::ViewEngineInitializer) + {%- endif -%} + ]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + {%- if settings.auth %} + .add_route(controllers::auth::routes()) + {%- else %} + .add_route(controllers::home::routes()) + {%- endif %} + } + + {%- if settings.background %} + async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { + {%- else %} + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + {%- endif %} + {%- if settings.background %} + queue.register(DownloadWorker::build(ctx)).await?; + {%- endif %} + Ok(()) + } + + {%- if settings.db %} + fn register_tasks(tasks: &mut Tasks) { + {%- else %} + fn register_tasks(_tasks: &mut Tasks) { + {%- endif %} + {%- if settings.db %} + tasks.register(tasks::seed::SeedData); + {%- endif %} + } + + {%- if settings.db %} + async fn truncate(db: &DatabaseConnection) -> Result<()> { + truncate_table(db, users::Entity).await?; + Ok(()) + } + + async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { + db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + Ok(()) + } + {%- endif %} +} diff --git a/loco-new/base_template/src/bin/main.rs.t b/loco-new/base_template/src/bin/main.rs.t new file mode 100644 index 000000000..9e667a883 --- /dev/null +++ b/loco-new/base_template/src/bin/main.rs.t @@ -0,0 +1,14 @@ +use loco_rs::cli; +use {{settings.module_name}}::app::App; +{%- if settings.db %} +use migration::Migrator; +{%- endif %} + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + {%- if settings.db %} + cli::main::().await + {%- else %} + cli::main::().await + {%- endif %} +} diff --git a/loco-new/base_template/src/bin/tool.rs.t b/loco-new/base_template/src/bin/tool.rs.t new file mode 100644 index 000000000..a4cb3f997 --- /dev/null +++ b/loco-new/base_template/src/bin/tool.rs.t @@ -0,0 +1,14 @@ +use loco_rs::cli; +use {{settings.module_name}}::app::App; +{%- if settings.db %} +use migration::Migrator; +{%- endif %} + +#[tokio::main] +async fn main() -> loco_rs::Result<()> { + {%- if settings.db %} + cli::main::().await + {%- else %} + cli::main::().await + {%- endif %} +} diff --git a/loco-new/base_template/src/controllers/auth.rs b/loco-new/base_template/src/controllers/auth.rs new file mode 100644 index 000000000..27e3de71e --- /dev/null +++ b/loco-new/base_template/src/controllers/auth.rs @@ -0,0 +1,157 @@ +use axum::debug_handler; +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + mailers::auth::AuthMailer, + models::{ + _entities::users, + users::{LoginParams, RegisterParams}, + }, + views::auth::{CurrentResponse, LoginResponse}, +}; +#[derive(Debug, Deserialize, Serialize)] +pub struct VerifyParams { + pub token: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ForgotParams { + pub email: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResetParams { + pub token: String, + pub password: String, +} + +/// Register function creates a new user with the given parameters and sends a +/// welcome email to the user +#[debug_handler] +async fn register( + State(ctx): State, + Json(params): Json, +) -> Result { + let res = users::Model::create_with_password(&ctx.db, ¶ms).await; + + let user = match res { + Ok(user) => user, + Err(err) => { + tracing::info!( + message = err.to_string(), + user_email = ¶ms.email, + "could not register user", + ); + return format::json(()); + } + }; + + let user = user + .into_active_model() + .set_email_verification_sent(&ctx.db) + .await?; + + AuthMailer::send_welcome(&ctx, &user).await?; + + format::json(()) +} + +/// Verify register user. if the user not verified his email, he can't login to +/// the system. +#[debug_handler] +async fn verify( + State(ctx): State, + Json(params): Json, +) -> Result { + let user = users::Model::find_by_verification_token(&ctx.db, ¶ms.token).await?; + + if user.email_verified_at.is_some() { + tracing::info!(pid = user.pid.to_string(), "user already verified"); + } else { + let active_model = user.into_active_model(); + let user = active_model.verified(&ctx.db).await?; + tracing::info!(pid = user.pid.to_string(), "user verified"); + } + + format::json(()) +} + +/// In case the user forgot his password this endpoints generate a forgot token +/// and send email to the user. In case the email not found in our DB, we are +/// returning a valid request for for security reasons (not exposing users DB +/// list). +#[debug_handler] +async fn forgot( + State(ctx): State, + Json(params): Json, +) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + return format::json(()); + }; + + let user = user + .into_active_model() + .set_forgot_password_sent(&ctx.db) + .await?; + + AuthMailer::forgot_password(&ctx, &user).await?; + + format::json(()) +} + +/// reset user password by the given parameters +#[debug_handler] +async fn reset(State(ctx): State, Json(params): Json) -> Result { + let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + tracing::info!("reset token not found"); + + return format::json(()); + }; + user.into_active_model() + .reset_password(&ctx.db, ¶ms.password) + .await?; + + format::json(()) +} + +/// Creates a user login and returns a token +#[debug_handler] +async fn login(State(ctx): State, Json(params): Json) -> Result { + let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?; + + let valid = user.verify_password(¶ms.password); + + if !valid { + return unauthorized("unauthorized!"); + } + + let jwt_secret = ctx.config.get_jwt_config()?; + + let token = user + .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .or_else(|_| unauthorized("unauthorized!"))?; + + format::json(LoginResponse::new(&user, &token)) +} + +#[debug_handler] +async fn current(auth: auth::JWT, State(ctx): State) -> Result { + 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("/api/auth") + .add("/register", post(register)) + .add("/verify", post(verify)) + .add("/login", post(login)) + .add("/forgot", post(forgot)) + .add("/reset", post(reset)) + .add("/current", get(current)) +} diff --git a/loco-new/base_template/src/controllers/home.rs b/loco-new/base_template/src/controllers/home.rs new file mode 100644 index 000000000..0d6430f60 --- /dev/null +++ b/loco-new/base_template/src/controllers/home.rs @@ -0,0 +1,13 @@ +use axum::debug_handler; +use loco_rs::prelude::*; + +use crate::views::home::HomeResponse; + +#[debug_handler] +async fn current() -> Result { + format::json(HomeResponse::new("loco")) +} + +pub fn routes() -> Routes { + Routes::new().prefix("/api").add("/", get(current)) +} diff --git a/loco-new/base_template/src/controllers/mod.rs.t b/loco-new/base_template/src/controllers/mod.rs.t new file mode 100644 index 000000000..48fcf6eb9 --- /dev/null +++ b/loco-new/base_template/src/controllers/mod.rs.t @@ -0,0 +1,6 @@ +{%- if settings.auth -%} +pub mod auth; +{%- else -%} +pub mod home; +{%- endif -%} + diff --git a/loco-new/base_template/src/fixtures/users.yaml b/loco-new/base_template/src/fixtures/users.yaml new file mode 100644 index 000000000..8f5b5ed2e --- /dev/null +++ b/loco-new/base_template/src/fixtures/users.yaml @@ -0,0 +1,17 @@ +--- +- id: 1 + pid: 11111111-1111-1111-1111-111111111111 + email: user1@example.com + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" + api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758 + name: user1 + created_at: "2023-11-12T12:34:56.789Z" + updated_at: "2023-11-12T12:34:56.789Z" +- id: 2 + pid: 22222222-2222-2222-2222-222222222222 + email: user2@example.com + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" + api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e + name: user2 + created_at: "2023-11-12T12:34:56.789Z" + updated_at: "2023-11-12T12:34:56.789Z" diff --git a/loco-new/base_template/src/initializers/mod.rs.t b/loco-new/base_template/src/initializers/mod.rs.t new file mode 100644 index 000000000..056fb0ec2 --- /dev/null +++ b/loco-new/base_template/src/initializers/mod.rs.t @@ -0,0 +1,3 @@ +{%- if settings.initializers.view_engine %} +pub mod view_engine; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/initializers/view_engine.rs b/loco-new/base_template/src/initializers/view_engine.rs new file mode 100644 index 000000000..397a21ad6 --- /dev/null +++ b/loco-new/base_template/src/initializers/view_engine.rs @@ -0,0 +1,46 @@ +use axum::{async_trait, Extension, Router as AxumRouter}; +use fluent_templates::{ArcLoader, FluentLoader}; +use loco_rs::{ + app::{AppContext, Initializer}, + controller::views::{engines, ViewEngine}, + Error, Result, +}; +use tracing::info; + +const I18N_DIR: &str = "assets/i18n"; +const I18N_SHARED: &str = "assets/i18n/shared.ftl"; +#[allow(clippy::module_name_repetitions)] +pub struct ViewEngineInitializer; + +#[async_trait] +impl Initializer for ViewEngineInitializer { + fn name(&self) -> String { + "view-engine".to_string() + } + + async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result { + #[allow(unused_mut)] + let mut tera_engine = engines::TeraView::build()?; + if std::path::Path::new(I18N_DIR).exists() { + let arc = ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US")) + .shared_resources(Some(&[I18N_SHARED.into()])) + .customize(|bundle| bundle.set_use_isolating(false)) + .build() + .map_err(|e| Error::string(&e.to_string()))?; + #[cfg(debug_assertions)] + tera_engine + .tera + .lock() + .expect("lock") + .register_function("t", FluentLoader::new(arc)); + + #[cfg(not(debug_assertions))] + tera_engine + .tera + .register_function("t", FluentLoader::new(arc)); + info!("locales loaded"); + } + + Ok(router.layer(Extension(ViewEngine::from(tera_engine)))) + } +} diff --git a/loco-new/base_template/src/lib.rs.t b/loco-new/base_template/src/lib.rs.t new file mode 100644 index 000000000..8a9ee5327 --- /dev/null +++ b/loco-new/base_template/src/lib.rs.t @@ -0,0 +1,14 @@ +pub mod app; +pub mod controllers; +pub mod initializers; +{%- if settings.mailer %} +pub mod mailers; +{%- endif %} +{%- if settings.db %} +pub mod models; +{%- endif %} +pub mod tasks; +pub mod views; +{%- if settings.background %} +pub mod workers; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/mailers/auth.rs b/loco-new/base_template/src/mailers/auth.rs new file mode 100644 index 000000000..30bb1bf2f --- /dev/null +++ b/loco-new/base_template/src/mailers/auth.rs @@ -0,0 +1,65 @@ +// auth mailer +#![allow(non_upper_case_globals)] + +use loco_rs::prelude::*; +use serde_json::json; + +use crate::models::users; + +static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome"); +static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot"); +// #[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: &users::Model) -> Result<()> { + Self::mail_template( + ctx, + &welcome, + mailer::Args { + to: user.email.to_string(), + locals: json!({ + "name": user.name, + "verifyToken": user.email_verification_token, + "domain": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } + + /// Sending forgot password email + /// + /// # Errors + /// + /// When email sending is failed + pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> { + Self::mail_template( + ctx, + &forgot, + mailer::Args { + to: user.email.to_string(), + locals: json!({ + "name": user.name, + "resetToken": user.reset_token, + "domain": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } +} diff --git a/loco-new/base_template/src/mailers/auth/forgot/html.t b/loco-new/base_template/src/mailers/auth/forgot/html.t new file mode 100644 index 000000000..221dd6020 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/forgot/html.t @@ -0,0 +1,11 @@ +; + + + Hey {{name}}, + Forgot your password? No worries! You can reset it by clicking the link below: + Reset Your Password + If you didn't request a password reset, please ignore this email. + Best regards,
The Loco Team
+ + + diff --git a/loco-new/base_template/src/mailers/auth/forgot/subject.t b/loco-new/base_template/src/mailers/auth/forgot/subject.t new file mode 100644 index 000000000..4938df1e3 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/forgot/subject.t @@ -0,0 +1 @@ +Your reset password link diff --git a/loco-new/base_template/src/mailers/auth/forgot/text.t b/loco-new/base_template/src/mailers/auth/forgot/text.t new file mode 100644 index 000000000..58c30fd8d --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/forgot/text.t @@ -0,0 +1,3 @@ +Reset your password with this link: + +http://localhost/reset#{{resetToken}} diff --git a/loco-new/base_template/src/mailers/auth/welcome/html.t b/loco-new/base_template/src/mailers/auth/welcome/html.t new file mode 100644 index 000000000..ae4c41c65 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/welcome/html.t @@ -0,0 +1,13 @@ +; + + + Dear {{name}}, + Welcome to Loco! You can now log in to your account. + Before you get started, please verify your account by clicking the link below: + + Verify Your Account + +

Best regards,
The Loco Team

+ + + diff --git a/loco-new/base_template/src/mailers/auth/welcome/subject.t b/loco-new/base_template/src/mailers/auth/welcome/subject.t new file mode 100644 index 000000000..82cc6fbf7 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/welcome/subject.t @@ -0,0 +1 @@ +Welcome {{name}} diff --git a/loco-new/base_template/src/mailers/auth/welcome/text.t b/loco-new/base_template/src/mailers/auth/welcome/text.t new file mode 100644 index 000000000..63beefd56 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/welcome/text.t @@ -0,0 +1,4 @@ +Welcome {{name}}, you can now log in. + Verify your account with the link below: + + http://localhost/verify#{{verifyToken}} diff --git a/loco-new/base_template/src/mailers/mod.rs b/loco-new/base_template/src/mailers/mod.rs new file mode 100644 index 000000000..0e4a05d59 --- /dev/null +++ b/loco-new/base_template/src/mailers/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/loco-new/base_template/src/models/_entities/mod.rs b/loco-new/base_template/src/models/_entities/mod.rs new file mode 100644 index 000000000..095dade0f --- /dev/null +++ b/loco-new/base_template/src/models/_entities/mod.rs @@ -0,0 +1,5 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +pub mod prelude; + +pub mod users; diff --git a/loco-new/base_template/src/models/_entities/prelude.rs b/loco-new/base_template/src/models/_entities/prelude.rs new file mode 100644 index 000000000..4036adeec --- /dev/null +++ b/loco-new/base_template/src/models/_entities/prelude.rs @@ -0,0 +1,3 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +pub use super::users::Entity as Users; diff --git a/loco-new/base_template/src/models/_entities/users.rs b/loco-new/base_template/src/models/_entities/users.rs new file mode 100644 index 000000000..120b1a1b1 --- /dev/null +++ b/loco-new/base_template/src/models/_entities/users.rs @@ -0,0 +1,28 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub pid: Uuid, + #[sea_orm(unique)] + pub email: String, + pub password: String, + #[sea_orm(unique)] + pub api_key: String, + pub name: String, + pub reset_token: Option, + pub reset_sent_at: Option, + pub email_verification_token: Option, + pub email_verification_sent_at: Option, + pub email_verified_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/loco-new/base_template/src/models/mod.rs b/loco-new/base_template/src/models/mod.rs new file mode 100644 index 000000000..48da463b6 --- /dev/null +++ b/loco-new/base_template/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod _entities; +pub mod users; diff --git a/loco-new/base_template/src/models/users.rs b/loco-new/base_template/src/models/users.rs new file mode 100644 index 000000000..b4f3aaea0 --- /dev/null +++ b/loco-new/base_template/src/models/users.rs @@ -0,0 +1,298 @@ +use async_trait::async_trait; +use chrono::offset::Local; +use loco_rs::{auth::jwt, hash, prelude::*}; +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 Validator { + #[validate(length(min = 2, message = "Name must be at least 2 characters long."))] + pub name: String, + #[validate(custom(function = "validation::is_valid_email"))] + pub email: String, +} + +impl Validatable for super::_entities::users::ActiveModel { + fn validator(&self) -> Box { + Box::new(Validator { + name: self.name.as_ref().to_owned(), + email: self.email.as_ref().to_owned(), + }) + } +} + +#[async_trait::async_trait] +impl ActiveModelBehavior for super::_entities::users::ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> Result + where + C: ConnectionTrait, + { + self.validate()?; + if insert { + let mut this = self; + this.pid = ActiveValue::Set(Uuid::new_v4()); + this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4())); + Ok(this) + } else { + Ok(self) + } + } +} + +#[async_trait] +impl Authenticable for super::_entities::users::Model { + async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::ApiKey, api_key) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult { + Self::find_by_pid(db, claims_key).await + } +} + +impl super::_entities::users::Model { + /// finds a user by the provided email + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::Email, email) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the provided verification token + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_verification_token( + db: &DatabaseConnection, + token: &str, + ) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::EmailVerificationToken, token) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the provided reset token + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::ResetToken, token) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the provided pid + /// + /// # Errors + /// + /// When could not find user or DB query error + pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult { + let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?; + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::Pid, parse_uuid) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// finds a user by the provided api key + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error + pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::ApiKey, api_key) + .build(), + ) + .one(db) + .await?; + user.ok_or_else(|| ModelError::EntityNotFound) + } + + /// Verifies whether the provided plain password matches the hashed password + /// + /// # Errors + /// + /// when could not verify password + #[must_use] + pub fn verify_password(&self, password: &str) -> bool { + hash::verify_password(password, &self.password) + } + + /// Asynchronously creates a user with a password and saves it to the + /// database. + /// + /// # Errors + /// + /// When could not save the user into the DB + pub async fn create_with_password( + db: &DatabaseConnection, + params: &RegisterParams, + ) -> ModelResult { + let txn = db.begin().await?; + + if users::Entity::find() + .filter( + model::query::condition() + .eq(users::Column::Email, ¶ms.email) + .build(), + ) + .one(&txn) + .await? + .is_some() + { + return Err(ModelError::EntityAlreadyExists {}); + } + + let password_hash = + hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?; + let user = users::ActiveModel { + 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) + } + + /// Creates a JWT + /// + /// # Errors + /// + /// when could not convert user claims to jwt token + pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult { + Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), None)?) + } +} + +impl super::_entities::users::ActiveModel { + /// Sets the email verification information for the user and + /// updates it in the database. + /// + /// This method is used to record the timestamp when the email verification + /// was sent and generate a unique verification token for the user. + /// + /// # Errors + /// + /// when has DB query error + pub async fn set_email_verification_sent( + mut self, + db: &DatabaseConnection, + ) -> ModelResult { + self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into())); + self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); + Ok(self.update(db).await?) + } + + /// Sets the information for a reset password request, + /// generates a unique reset password token, and updates it in the + /// database. + /// + /// This method records the timestamp when the reset password token is sent + /// and generates a unique token for the user. + /// + /// # Arguments + /// + /// # Errors + /// + /// when has DB query error + pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult { + self.reset_sent_at = ActiveValue::set(Some(Local::now().into())); + self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); + Ok(self.update(db).await?) + } + + /// Records the verification time when a user verifies their + /// email and updates it in the database. + /// + /// This method sets the timestamp when the user successfully verifies their + /// email. + /// + /// # Errors + /// + /// when has DB query error + pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult { + self.email_verified_at = ActiveValue::set(Some(Local::now().into())); + Ok(self.update(db).await?) + } + + /// Resets the current user password with a new password and + /// updates it in the database. + /// + /// This method hashes the provided password and sets it as the new password + /// for the user. + /// + /// # Errors + /// + /// when has DB query error or could not hashed the given password + pub async fn reset_password( + mut self, + db: &DatabaseConnection, + password: &str, + ) -> ModelResult { + self.password = + ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?); + self.reset_token = ActiveValue::Set(None); + self.reset_sent_at = ActiveValue::Set(None); + Ok(self.update(db).await?) + } +} diff --git a/loco-new/base_template/src/tasks/mod.rs.t b/loco-new/base_template/src/tasks/mod.rs.t new file mode 100644 index 000000000..d955d1add --- /dev/null +++ b/loco-new/base_template/src/tasks/mod.rs.t @@ -0,0 +1,3 @@ +{%- if settings.db %} +pub mod seed; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/tasks/seed.rs b/loco-new/base_template/src/tasks/seed.rs new file mode 100644 index 000000000..1647beb84 --- /dev/null +++ b/loco-new/base_template/src/tasks/seed.rs @@ -0,0 +1,45 @@ +//! This task implements data seeding functionality for initializing new +//! development/demo environments. +//! +//! # Example +//! +//! Run the task with the following command: +//! ```sh +//! cargo run task +//! ``` +//! +//! To override existing data and reset the data structure, use the following +//! command with the `refresh:true` argument: +//! ```sh +//! cargo run task seed_data refresh:true +//! ``` + +use loco_rs::{db, prelude::*}; +use migration::Migrator; + +use crate::app::App; + +#[allow(clippy::module_name_repetitions)] +pub struct SeedData; +#[async_trait] +impl Task for SeedData { + fn task(&self) -> TaskInfo { + TaskInfo { + name: "seed_data".to_string(), + detail: "Task for seeding data".to_string(), + } + } + + async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> { + let refresh = vars + .cli_arg("refresh") + .is_ok_and(|refresh| refresh == "true"); + + if refresh { + db::reset::(&app_context.db).await?; + } + let path = std::path::Path::new("src/fixtures"); + db::run_app_seed::(&app_context.db, path).await?; + Ok(()) + } +} diff --git a/loco-new/base_template/src/views/auth.rs b/loco-new/base_template/src/views/auth.rs new file mode 100644 index 000000000..3d2d74fdd --- /dev/null +++ b/loco-new/base_template/src/views/auth.rs @@ -0,0 +1,41 @@ +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, + pub is_verified: bool, +} + +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(), + is_verified: user.email_verified_at.is_some(), + } + } +} + +#[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(), + } + } +} diff --git a/loco-new/base_template/src/views/home.rs b/loco-new/base_template/src/views/home.rs new file mode 100644 index 000000000..83fd56e2a --- /dev/null +++ b/loco-new/base_template/src/views/home.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +impl HomeResponse { + #[must_use] + pub fn new(app_name: &str) -> Self { + Self { + app_name: app_name.to_string(), + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[allow(clippy::module_name_repetitions)] +pub struct HomeResponse { + pub app_name: String, +} diff --git a/loco-new/base_template/src/views/mod.rs.t b/loco-new/base_template/src/views/mod.rs.t new file mode 100644 index 000000000..629982b5c --- /dev/null +++ b/loco-new/base_template/src/views/mod.rs.t @@ -0,0 +1,5 @@ +{%- if settings.auth -%} +pub mod auth; +{%- else -%} +pub mod home; +{%- endif -%} \ No newline at end of file diff --git a/loco-new/base_template/src/workers/downloader.rs b/loco-new/base_template/src/workers/downloader.rs new file mode 100644 index 000000000..1abafa447 --- /dev/null +++ b/loco-new/base_template/src/workers/downloader.rs @@ -0,0 +1,23 @@ +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; + +pub struct DownloadWorker { + pub ctx: AppContext, +} + +#[derive(Deserialize, Debug, Serialize)] +pub struct DownloadWorkerArgs { + pub user_guid: String, +} + +#[async_trait] +impl BackgroundWorker for DownloadWorker { + fn build(ctx: &AppContext) -> Self { + Self { ctx: ctx.clone() } + } + async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> { + // TODO: Some actual work goes here... + + Ok(()) + } +} diff --git a/loco-new/base_template/src/workers/mod.rs.t b/loco-new/base_template/src/workers/mod.rs.t new file mode 100644 index 000000000..7e2a88e5e --- /dev/null +++ b/loco-new/base_template/src/workers/mod.rs.t @@ -0,0 +1 @@ +pub mod downloader; \ No newline at end of file diff --git a/loco-new/base_template/tests/mod.rs.t b/loco-new/base_template/tests/mod.rs.t new file mode 100644 index 000000000..764375997 --- /dev/null +++ b/loco-new/base_template/tests/mod.rs.t @@ -0,0 +1,9 @@ +{%- if settings.db %} +mod models; +{%- endif %} +mod requests; +mod tasks; +{%- if settings.background %} +mod workers; +{%- endif %} + diff --git a/loco-new/base_template/tests/models/mod.rs b/loco-new/base_template/tests/models/mod.rs new file mode 100644 index 000000000..59759880d --- /dev/null +++ b/loco-new/base_template/tests/models/mod.rs @@ -0,0 +1 @@ +mod users; diff --git a/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap new file mode 100644 index 000000000..6e66fd35a --- /dev/null +++ b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap @@ -0,0 +1,21 @@ +--- +source: tests/models/users.rs +expression: res +--- +Ok( + Model { + created_at: DATE, + updated_at: DATE, + id: ID + pid: PID, + email: "test@framework.com", + password: "PASSWORD", + api_key: "lo-PID", + name: "framework", + reset_token: None, + reset_sent_at: None, + email_verification_token: None, + email_verification_sent_at: None, + email_verified_at: None, + }, +) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users-2.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users-2.snap new file mode 100644 index 000000000..25c700a5a --- /dev/null +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users-2.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: non_existing_user_results +--- +Err( + EntityNotFound, +) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap new file mode 100644 index 000000000..067d0e752 --- /dev/null +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap @@ -0,0 +1,21 @@ +--- +source: tests/models/users.rs +expression: existing_user +--- +Ok( + Model { + created_at: 2023-11-12T12:34:56.789+00:00, + updated_at: 2023-11-12T12:34:56.789+00:00, + id: 1, + pid: 11111111-1111-1111-1111-111111111111, + email: "user1@example.com", + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc", + api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758", + name: "user1", + reset_token: None, + reset_sent_at: None, + email_verification_token: None, + email_verification_sent_at: None, + email_verified_at: None, + }, +) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users-2.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users-2.snap new file mode 100644 index 000000000..25c700a5a --- /dev/null +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users-2.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: non_existing_user_results +--- +Err( + EntityNotFound, +) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap new file mode 100644 index 000000000..067d0e752 --- /dev/null +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap @@ -0,0 +1,21 @@ +--- +source: tests/models/users.rs +expression: existing_user +--- +Ok( + Model { + created_at: 2023-11-12T12:34:56.789+00:00, + updated_at: 2023-11-12T12:34:56.789+00:00, + id: 1, + pid: 11111111-1111-1111-1111-111111111111, + email: "user1@example.com", + password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc", + api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758", + name: "user1", + reset_token: None, + reset_sent_at: None, + email_verification_token: None, + email_verification_sent_at: None, + email_verified_at: None, + }, +) diff --git a/loco-new/base_template/tests/models/snapshots/can_validate_model@users.snap b/loco-new/base_template/tests/models/snapshots/can_validate_model@users.snap new file mode 100644 index 000000000..708479af8 --- /dev/null +++ b/loco-new/base_template/tests/models/snapshots/can_validate_model@users.snap @@ -0,0 +1,9 @@ +--- +source: tests/models/users.rs +expression: res +--- +Err( + Custom( + "{\"email\":[{\"code\":\"invalid email\",\"message\":null}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}", + ), +) diff --git a/loco-new/base_template/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap b/loco-new/base_template/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap new file mode 100644 index 000000000..ff28ea196 --- /dev/null +++ b/loco-new/base_template/tests/models/snapshots/handle_create_with_password_with_duplicate@users.snap @@ -0,0 +1,7 @@ +--- +source: tests/models/users.rs +expression: new_user +--- +Err( + EntityAlreadyExists, +) diff --git a/loco-new/base_template/tests/models/users.rs.t b/loco-new/base_template/tests/models/users.rs.t new file mode 100644 index 000000000..e569a1c4c --- /dev/null +++ b/loco-new/base_template/tests/models/users.rs.t @@ -0,0 +1,223 @@ +use insta::assert_debug_snapshot; +use loco_rs::{model::ModelError, testing}; +use {{settings.module_name}}::{ + app::App, + models::users::{self, Model, RegisterParams}, +}; +use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; +use serial_test::serial; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("users"); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_can_validate_model() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + + let res = users::ActiveModel { + name: ActiveValue::set("1".to_string()), + email: ActiveValue::set("invalid-email".to_string()), + ..Default::default() + } + .insert(&boot.app_context.db) + .await; + + assert_debug_snapshot!(res); +} + +#[tokio::test] +#[serial] +async fn can_create_with_password() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + + let params = RegisterParams { + email: "test@framework.com".to_string(), + password: "1234".to_string(), + name: "framework".to_string(), + }; + let res = Model::create_with_password(&boot.app_context.db, ¶ms).await; + + insta::with_settings!({ + filters => testing::cleanup_user_model() + }, { + assert_debug_snapshot!(res); + }); +} + +#[tokio::test] +#[serial] +async fn handle_create_with_password_with_duplicate() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + testing::seed::(&boot.app_context.db).await.unwrap(); + + let new_user: Result = Model::create_with_password( + &boot.app_context.db, + &RegisterParams { + email: "user1@example.com".to_string(), + password: "1234".to_string(), + name: "framework".to_string(), + }, + ) + .await; + assert_debug_snapshot!(new_user); +} + +#[tokio::test] +#[serial] +async fn can_find_by_email() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + testing::seed::(&boot.app_context.db).await.unwrap(); + + let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await; + let non_existing_user_results = + Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await; + + assert_debug_snapshot!(existing_user); + assert_debug_snapshot!(non_existing_user_results); +} + +#[tokio::test] +#[serial] +async fn can_find_by_pid() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + testing::seed::(&boot.app_context.db).await.unwrap(); + + let existing_user = + Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await; + let non_existing_user_results = + Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await; + + assert_debug_snapshot!(existing_user); + assert_debug_snapshot!(non_existing_user_results); +} + +#[tokio::test] +#[serial] +async fn can_verification_token() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + testing::seed::(&boot.app_context.db).await.unwrap(); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!(user.email_verification_sent_at.is_none()); + assert!(user.email_verification_token.is_none()); + + assert!(user + .into_active_model() + .set_email_verification_sent(&boot.app_context.db) + .await + .is_ok()); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!(user.email_verification_sent_at.is_some()); + assert!(user.email_verification_token.is_some()); +} + +#[tokio::test] +#[serial] +async fn can_set_forgot_password_sent() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + testing::seed::(&boot.app_context.db).await.unwrap(); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!(user.reset_sent_at.is_none()); + assert!(user.reset_token.is_none()); + + assert!(user + .into_active_model() + .set_forgot_password_sent(&boot.app_context.db) + .await + .is_ok()); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!(user.reset_sent_at.is_some()); + assert!(user.reset_token.is_some()); +} + +#[tokio::test] +#[serial] +async fn can_verified() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + testing::seed::(&boot.app_context.db).await.unwrap(); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!(user.email_verified_at.is_none()); + + assert!(user + .into_active_model() + .verified(&boot.app_context.db) + .await + .is_ok()); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!(user.email_verified_at.is_some()); +} + +#[tokio::test] +#[serial] +async fn can_reset_password() { + configure_insta!(); + + let boot = testing::boot_test::().await.unwrap(); + testing::seed::(&boot.app_context.db).await.unwrap(); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!(user.verify_password("12341234")); + + assert!(user + .clone() + .into_active_model() + .reset_password(&boot.app_context.db, "new-password") + .await + .is_ok()); + + assert!( + Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap() + .verify_password("new-password") + ); +} diff --git a/loco-new/base_template/tests/requests/auth.rs.t b/loco-new/base_template/tests/requests/auth.rs.t new file mode 100644 index 000000000..fbc2d2875 --- /dev/null +++ b/loco-new/base_template/tests/requests/auth.rs.t @@ -0,0 +1,218 @@ +use insta::{assert_debug_snapshot, with_settings}; +use loco_rs::testing; +use {{settings.module_name}}::{app::App, models::users}; +use rstest::rstest; +use serial_test::serial; + +use super::prepare_data; + +// TODO: see how to dedup / extract this to app-local test utils +// not to framework, because that would require a runtime dep on insta +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("auth_request"); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn can_register() { + configure_insta!(); + + testing::request::(|request, ctx| async move { + let email = "test@loco.com"; + let payload = serde_json::json!({ + "name": "loco", + "email": email, + "password": "12341234" + }); + + let _response = request.post("/api/auth/register").json(&payload).await; + let saved_user = users::Model::find_by_email(&ctx.db, email).await; + + with_settings!({ + filters => testing::cleanup_user_model() + }, { + assert_debug_snapshot!(saved_user); + }); + + with_settings!({ + filters => testing::cleanup_email() + }, { + assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); + }); + }) + .await; +} + +#[rstest] +#[case("login_with_valid_password", "12341234")] +#[case("login_with_invalid_password", "invalid-password")] +#[tokio::test] +#[serial] +async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) { + configure_insta!(); + + testing::request::(|request, ctx| async move { + let email = "test@loco.com"; + let register_payload = serde_json::json!({ + "name": "loco", + "email": email, + "password": "12341234" + }); + + //Creating a new user + _ = request + .post("/api/auth/register") + .json(®ister_payload) + .await; + + let user = users::Model::find_by_email(&ctx.db, email).await.unwrap(); + let verify_payload = serde_json::json!({ + "token": user.email_verification_token, + }); + request.post("/api/auth/verify").json(&verify_payload).await; + + //verify user request + let response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": email, + "password": password + })) + .await; + + // Make sure email_verified_at is set + assert!(users::Model::find_by_email(&ctx.db, email) + .await + .unwrap() + .email_verified_at + .is_some()); + + with_settings!({ + filters => testing::cleanup_user_model() + }, { + assert_debug_snapshot!(test_name, (response.status_code(), response.text())); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_login_without_verify() { + configure_insta!(); + + testing::request::(|request, _ctx| async move { + let email = "test@loco.com"; + let password = "12341234"; + let register_payload = serde_json::json!({ + "name": "loco", + "email": email, + "password": password + }); + + //Creating a new user + _ = request + .post("/api/auth/register") + .json(®ister_payload) + .await; + + //verify user request + let response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": email, + "password": password + })) + .await; + + with_settings!({ + filters => testing::cleanup_user_model() + }, { + assert_debug_snapshot!((response.status_code(), response.text())); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reset_password() { + configure_insta!(); + + testing::request::(|request, ctx| async move { + let login_data = prepare_data::init_user_login(&request, &ctx).await; + + let forgot_payload = serde_json::json!({ + "email": login_data.user.email, + }); + _ = request.post("/api/auth/forgot").json(&forgot_payload).await; + + let user = users::Model::find_by_email(&ctx.db, &login_data.user.email) + .await + .unwrap(); + assert!(user.reset_token.is_some()); + assert!(user.reset_sent_at.is_some()); + + let new_password = "new-password"; + let reset_payload = serde_json::json!({ + "token": user.reset_token, + "password": new_password, + }); + + let reset_response = request.post("/api/auth/reset").json(&reset_payload).await; + + let user = users::Model::find_by_email(&ctx.db, &user.email) + .await + .unwrap(); + + assert!(user.reset_token.is_none()); + assert!(user.reset_sent_at.is_none()); + + assert_debug_snapshot!((reset_response.status_code(), reset_response.text())); + + let response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": user.email, + "password": new_password + })) + .await; + + assert_eq!(response.status_code(), 200); + + with_settings!({ + filters => testing::cleanup_email() + }, { + assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); + }); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_get_current_user() { + configure_insta!(); + + testing::request::(|request, ctx| async move { + let user = prepare_data::init_user_login(&request, &ctx).await; + + let (auth_key, auth_value) = prepare_data::auth_header(&user.token); + let response = request + .get("/api/auth/current") + .add_header(auth_key, auth_value) + .await; + + with_settings!({ + filters => testing::cleanup_user_model() + }, { + assert_debug_snapshot!((response.status_code(), response.text())); + }); + }) + .await; +} diff --git a/loco-new/base_template/tests/requests/home.rs.t b/loco-new/base_template/tests/requests/home.rs.t new file mode 100644 index 000000000..492acc632 --- /dev/null +++ b/loco-new/base_template/tests/requests/home.rs.t @@ -0,0 +1,16 @@ +use loco_rs::testing; +use {{settings.module_name}}::app::App; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn can_get_home() { + + testing::request::(|request, _ctx| async move { + let res = request.get("/api").await; + + assert_eq!(res.status_code(), 200); + res.assert_json(&serde_json::json!({"app_name":"loco"})); + }) + .await; +} diff --git a/loco-new/base_template/tests/requests/mod.rs.t b/loco-new/base_template/tests/requests/mod.rs.t new file mode 100644 index 000000000..74efb0941 --- /dev/null +++ b/loco-new/base_template/tests/requests/mod.rs.t @@ -0,0 +1,6 @@ +{%- if settings.auth -%} +mod auth; +mod prepare_data; +{%- else -%} +mod home; +{%- endif -%} \ No newline at end of file diff --git a/loco-new/base_template/tests/requests/prepare_data.rs.t b/loco-new/base_template/tests/requests/prepare_data.rs.t new file mode 100644 index 000000000..b2dcc3053 --- /dev/null +++ b/loco-new/base_template/tests/requests/prepare_data.rs.t @@ -0,0 +1,57 @@ +use axum::http::{HeaderName, HeaderValue}; +use loco_rs::{app::AppContext, TestServer}; +use {{settings.module_name}}::{models::users, views::auth::LoginResponse}; + +const USER_EMAIL: &str = "test@loco.com"; +const USER_PASSWORD: &str = "1234"; + +pub struct LoggedInUser { + pub user: users::Model, + pub token: String, +} + +pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser { + let register_payload = serde_json::json!({ + "name": "loco", + "email": USER_EMAIL, + "password": USER_PASSWORD + }); + + //Creating a new user + request + .post("/api/auth/register") + .json(®ister_payload) + .await; + let user = users::Model::find_by_email(&ctx.db, USER_EMAIL) + .await + .unwrap(); + + let verify_payload = serde_json::json!({ + "token": user.email_verification_token, + }); + + request.post("/api/auth/verify").json(&verify_payload).await; + + let response = request + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": USER_EMAIL, + "password": USER_PASSWORD + })) + .await; + + let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap(); + + LoggedInUser { + user: users::Model::find_by_email(&ctx.db, USER_EMAIL) + .await + .unwrap(), + token: login_response.token, + } +} + +pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) { + let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap(); + + (HeaderName::from_static("authorization"), auth_header_value) +} diff --git a/loco-new/base_template/tests/requests/snapshots/can_get_current_user@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_get_current_user@auth_request.snap new file mode 100644 index 000000000..74f7e713b --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_get_current_user@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}", +) diff --git a/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap new file mode 100644 index 000000000..ef54ba671 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}", +) diff --git a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap new file mode 100644 index 000000000..f380dd9f0 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: ctx.mailer.unwrap().deliveries() +--- +Deliveries { + count: 0, + messages: [], +} diff --git a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap new file mode 100644 index 000000000..0c0e13bb7 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap @@ -0,0 +1,25 @@ +--- +source: tests/requests/auth.rs +expression: saved_user +--- +Ok( + Model { + created_at: DATE, + updated_at: DATE, + id: ID + pid: PID, + email: "test@loco.com", + password: "PASSWORD", + api_key: "lo-PID", + name: "loco", + reset_token: None, + reset_sent_at: None, + email_verification_token: Some( + "PID", + ), + email_verification_sent_at: Some( + DATE, + ), + email_verified_at: None, + }, +) diff --git a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap new file mode 100644 index 000000000..f380dd9f0 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: ctx.mailer.unwrap().deliveries() +--- +Deliveries { + count: 0, + messages: [], +} diff --git a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap new file mode 100644 index 000000000..be6838d35 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(reset_response.status_code(), reset_response.text())" +--- +( + 200, + "null", +) diff --git a/loco-new/base_template/tests/requests/snapshots/login_with_invalid_password@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/login_with_invalid_password@auth_request.snap new file mode 100644 index 000000000..eb6e89f44 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/login_with_invalid_password@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 401, + "{\"error\":\"unauthorized\",\"description\":\"You do not have permission to access this resource\"}", +) diff --git a/loco-new/base_template/tests/requests/snapshots/login_with_valid_password@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/login_with_valid_password@auth_request.snap new file mode 100644 index 000000000..f06fbaa86 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/login_with_valid_password@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/auth.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true}", +) diff --git a/loco-new/base_template/tests/tasks/mod.rs.t b/loco-new/base_template/tests/tasks/mod.rs.t new file mode 100644 index 000000000..6b1a58c15 --- /dev/null +++ b/loco-new/base_template/tests/tasks/mod.rs.t @@ -0,0 +1,3 @@ +{%- if settings.db -%} +pub mod seed; +{%- endif -%} \ No newline at end of file diff --git a/loco-new/base_template/tests/tasks/seed.rs.t b/loco-new/base_template/tests/tasks/seed.rs.t new file mode 100644 index 000000000..6e49be51d --- /dev/null +++ b/loco-new/base_template/tests/tasks/seed.rs.t @@ -0,0 +1,17 @@ +use loco_rs::{boot::run_task, task, testing}; +use {{settings.module_name}}::app::App; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn test_can_seed_data() { + let boot = testing::boot_test::().await.unwrap(); + + assert!(run_task::( + &boot.app_context, + Some(&"seed_data".to_string()), + &task::Vars::default() + ) + .await + .is_ok()); +} diff --git a/loco-new/base_template/tests/workers/mod.rs b/loco-new/base_template/tests/workers/mod.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/loco-new/base_template/tests/workers/mod.rs @@ -0,0 +1 @@ + diff --git a/loco-new/setup.rhai b/loco-new/setup.rhai new file mode 100644 index 000000000..b44455d29 --- /dev/null +++ b/loco-new/setup.rhai @@ -0,0 +1,117 @@ +// ===================== +// Base Files +// ===================== +// Copy core project structure files and directories that are fundamental +// to the Rust environment, GitHub actions, and formatting settings. + +gen.copy_dirs([".cargo", ".github"]); +gen.copy_files([".gitignore", ".rustfmt.toml", "README.md"]); + +// ===================== +// Core Source Files +// ===================== +gen.copy_template("src/controllers/mod.rs.t"); // Main controller module template +gen.copy_template("src/views/mod.rs.t"); // Main views module template +gen.copy_template("src/tasks/mod.rs.t"); // Main tasks module template + +gen.copy_template("src/initializers/mod.rs.t"); // initializer module + +// Main application and library templates +gen.copy_template("src/app.rs.t"); // App root file +gen.copy_template("src/lib.rs.t"); // Library entry file +gen.copy_template("Cargo.toml.t"); // Project’s cargo configuration +gen.copy_template_dir("src/bin"); // Copies binary directory with templates + + +// ===================== +// Test Files +// ===================== +// Generates and organizes tests modules and templates for different areas of the application. + +gen.copy_template("tests/mod.rs.t"); // Main tests module template +gen.copy_template("tests/requests/mod.rs.t"); // HTTP requests tests module +gen.copy_template("tests/tasks/mod.rs.t"); // Tasks tests module + + +// ===================== +// App Configuration +// ===================== +gen.copy_template("config/development.yaml.t"); // Development config template +gen.copy_template("config/test.yaml.t"); // Test config template +gen.copy_file("config/production.yaml"); // Production config + +// ===================== +// Database-Related Files +// ===================== +if db { + gen.copy_dir("migration"); // Database migrations directory + gen.copy_dir("src/models"); // Models directory, copied if background enabled + gen.copy_file("src/tasks/seed.rs"); // Task to seed database + gen.copy_dir("src/fixtures"); // Database fixtures directory + gen.copy_template("examples/playground.rs.t"); // Example playground template with DB setup + + // Test modules related to database models + gen.copy_file("tests/models/mod.rs"); // Models tests root + gen.copy_dir("tests/models/snapshots"); // Test snapshots for models + gen.copy_template("tests/models/users.rs.t"); // User model test template + gen.copy_template("tests/tasks/seed.rs.t"); // Seed tasks test template + gen.copy_template("tests/requests/prepare_data.rs.t"); // Data preparation template +} + +// ===================== +// Initializers Support +// ===================== +if initializers { + gen.copy_file("src/initializers/view_engine.rs"); // Template for view engine initializer +} + +// ===================== +// Authentication Setup +// ===================== +if settings.auth { + gen.copy_file("src/controllers/auth.rs"); // Auth controller + gen.copy_file("src/views/auth.rs"); // Auth views + + gen.copy_template("tests/requests/auth.rs.t"); // Auth tests template + gen.copy_dir("tests/requests/snapshots"); // Snapshots directory for auth tests +} +else { + gen.copy_file("src/controllers/home.rs"); // Home controller if auth not enabled + gen.copy_file("src/views/home.rs"); // Home views + gen.copy_template("tests/requests/home.rs.t"); // Home tests template +} + +// ===================== +// Mailer Setup +// ===================== +if settings.mailer { + gen.copy_dir("src/mailers"); // Mailers directory, copied if enabled +} + +// ===================== +// Background Processing +// ===================== +if background { + gen.copy_template("src/workers/mod.rs.t"); // Workers directory + gen.copy_dir("tests/workers"); // Workers test directory + gen.copy_file("src/workers/downloader.rs"); +} + +// ===================== +// Asset Management +// ===================== +// Adds asset directory if assets are configured in the app. + +if asset { + gen.copy_dir("assets"); // Static assets directory +} + + +// ===================== +// Client side +// ===================== + +if settings.clientside { + gen.copy_dir("frontend"); + gen.create_file("frontend/dist/index.html", "this is a placeholder. please run your frontend build (npm build)"); +} diff --git a/loco-new/src/bin/main.rs b/loco-new/src/bin/main.rs new file mode 100644 index 000000000..1bc78449e --- /dev/null +++ b/loco-new/src/bin/main.rs @@ -0,0 +1,220 @@ +use std::{ + env, + path::{Path, PathBuf}, + process::{exit, Command}, + sync::Arc, +}; + +use clap::{Parser, Subcommand}; +use duct::cmd; +use loco::{ + generator::{executer, extract_default_template, Generator}, + settings::Settings, + wizard, Result, +}; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[arg(global = true, short, long, value_enum, default_value = "ERROR")] + /// Verbosity level + log: LevelFilter, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create a new Loco app + New { + /// Local path to generate into + #[arg(short, long, default_value = ".")] + path: PathBuf, + + /// App name + #[arg(short, long)] + name: Option, + + /// DB Provider + #[arg(long)] + db: Option, + + /// Background worker configuration + #[arg(long)] + bg: Option, + + /// Assets serving configuration + #[arg(long)] + assets: Option, + + /// Allows create loco starter in target git repository + #[arg(short, long)] + allow_in_git_repo: bool, + }, +} + +#[allow(clippy::cognitive_complexity)] +fn main() -> Result<()> { + let cli = Cli::parse(); + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(cli.log.into()) + .from_env_lossy(), + ) + .init(); + + let res = match cli.command { + Commands::New { + path, + db, + bg, + assets, + name, + allow_in_git_repo, + } => { + if !allow_in_git_repo && is_a_git_repo(path.as_path()).unwrap_or(false) { + tracing::debug!("the target directory is a Git repository"); + wizard::warn_if_in_git_repo()?; + } + + let app_name = wizard::app_name(name)?; + + let to: PathBuf = path.canonicalize()?.join(&app_name); + + if to.exists() { + CmdExit::error_with_message(format!( + "The specified path '{}' already exist", + to.display() + )) + } else { + tracing::debug!(dir = %to.display(), "creating application directory"); + std::fs::create_dir_all(&to)?; + + let args = wizard::ArgsPlaceholder { db, bg, assets }; + let user_selection = wizard::start(&args)?; + + let generator_tmp_folder = extract_default_template()?; + tracing::debug!( + dir = %generator_tmp_folder.display(), + "temporary template folder created", + + ); + + let executor = + executer::FileSystem::new(generator_tmp_folder.as_path(), to.as_path()); + + let settings = Settings::from_wizard(&app_name, &user_selection); + + if let Ok(path) = env::var("LOCO_DEV_MODE_PATH") { + println!("⚠️ NOTICE: working in dev mode, pointing to local Loco on '{path}'"); + } + + let res = match Generator::new(Arc::new(executor), settings).run() { + Ok(()) => { + tracing::debug!("loco template app generated successfully",); + if let Err(err) = cmd!("cargo", "fmt") + .dir(&to) + .stdout_null() + .stderr_null() + .run() + { + tracing::debug!(dir = %to.display(), err = %err,"failed to run 'cargo fmt'"); + } + + CmdExit::ok_with_message(format!( + "\n🚂 Loco app generated successfully in:\n{}\n\n{}", + to.display(), + user_selection + .message() + .iter() + .map(|m| format!("- {m}")) + .collect::>() + .join("\n") + )) + } + Err(err) => { + tracing::error!(error = %err, args = format!("{args:?}"), "app generation failed due to template error."); + CmdExit::error_with_message("generate template failed") + } + }; + + if let Err(err) = std::fs::remove_dir_all(&generator_tmp_folder) { + tracing::warn!( + error = %err, + dir = %generator_tmp_folder.display(), + "failed to delete temporary generator folder" + ); + } + res + } + } + }; + + res.exit(); + Ok(()) +} + +/// Check if a given path is a Git repository +/// +/// # Errors +/// +/// when git binary is not found or could not canonicalize the given path +pub fn is_a_git_repo(destination_path: &Path) -> Result { + let destination_path = destination_path.canonicalize()?; + match Command::new("git") + .arg("-C") + .arg(destination_path) + .arg("rev-parse") + .arg("--is-inside-work-tree") + .output() + { + Ok(output) => { + if output.status.success() { + Ok(true) + } else { + Ok(false) + } + } + Err(err) => { + tracing::debug!(error = err.to_string(), "git not found"); + Ok(false) + } + } +} + +#[derive(Debug)] +pub struct CmdExit { + pub code: i32, + pub message: Option, +} + +impl CmdExit { + #[must_use] + pub fn error_with_message>(msg: S) -> Self { + Self { + code: 1, + message: Some(format!("🙀 {}", msg.into())), + } + } + + #[must_use] + pub fn ok_with_message>(msg: S) -> Self { + Self { + code: 0, + message: Some(msg.into()), + } + } + + pub fn exit(&self) { + if let Some(message) = &self.message { + eprintln!("{message}"); + }; + + exit(self.code); + } +} diff --git a/loco-new/src/generator/executer/filesystem.rs b/loco-new/src/generator/executer/filesystem.rs new file mode 100644 index 000000000..0c6411b02 --- /dev/null +++ b/loco-new/src/generator/executer/filesystem.rs @@ -0,0 +1,269 @@ +use std::path::{Path, PathBuf}; + +use fs_extra::file::{move_file, write_all}; +use walkdir::WalkDir; + +use super::Executer; +use crate::{generator, settings::Settings}; + +#[derive(Debug, Default, Clone)] +pub struct FileSystem { + pub source_dir: PathBuf, + pub target_dir: PathBuf, + pub template_engine: generator::template::Template, +} + +impl FileSystem { + #[must_use] + pub fn new(from: &Path, to: &Path) -> Self { + Self { + source_dir: from.to_path_buf(), + target_dir: to.to_path_buf(), + template_engine: generator::template::Template::default(), + } + } + + #[must_use] + pub fn with_template_engine( + from: &Path, + to: &Path, + template_engine: generator::template::Template, + ) -> Self { + Self { + source_dir: from.to_path_buf(), + target_dir: to.to_path_buf(), + template_engine, + } + } + + fn render_and_rename_template_file( + &self, + file_path: &Path, + settings: &Settings, + ) -> super::Result<()> { + let template_content = fs_extra::file::read_to_string(file_path).map_err(|err| { + tracing::debug!(err = %err, "failed to read template file"); + err + })?; + let rendered_content = self.template_engine.render(&template_content, settings)?; + write_all(file_path, &rendered_content).map_err(|err| { + tracing::debug!(err = %err, "failed to write rendered content to file"); + err + })?; + + let renamed_path = self + .template_engine + .strip_template_extension(file_path) + .map_err(|err| { + tracing::debug!(err = %err, "error stripping template extension from file"); + super::Error::msg("error striping template file") + })?; + move_file(file_path, renamed_path, &fs_extra::file::CopyOptions::new())?; + Ok(()) + } +} + +impl Executer for FileSystem { + fn copy_file(&self, path: &Path) -> super::Result { + let source_path = self.source_dir.join(path); + let target_path = self.target_dir.join(path); + + let span = tracing::error_span!("copy_file", source_path = %source_path.display(), target_path = %target_path.display()); + let _guard = span.enter(); + + tracing::debug!("starting file copy operation"); + + fs_extra::dir::create_all(target_path.parent().unwrap(), false).map_err(|error| { + tracing::debug!(error = %error, "error creating target parent directory"); + error + })?; + + let copy_options = fs_extra::file::CopyOptions::new(); + fs_extra::file::copy(source_path, &target_path, ©_options)?; + tracing::debug!("file copy completed successfully"); + + Ok(target_path) + } + + fn create_file(&self, path: &Path, content: String) -> super::Result { + let target_path = self.target_dir.join(path); + if let Some(parent) = path.parent() { + fs_extra::dir::create_all(parent, false)?; + } + + let span = tracing::info_span!("create_file", target_path = %target_path.display()); + let _guard = span.enter(); + + tracing::debug!("starting file copy operation"); + + fs_extra::dir::create_all(target_path.parent().unwrap(), false).map_err(|error| { + tracing::debug!(error = %error, "error creating target parent directory"); + error + })?; + + fs_extra::file::write_all(&target_path, &content)?; + tracing::debug!("file created successfully"); + + Ok(target_path) + } + + fn copy_dir(&self, directory_path: &Path) -> super::Result<()> { + let source_path = self.source_dir.join(directory_path); + let target_path = self.target_dir.join(directory_path); + + let span = tracing::error_span!("", source_path = %source_path.display(), target_path = %target_path.display()); + let _guard = span.enter(); + + tracing::debug!("starting directory copy operation"); + let copy_options = fs_extra::dir::CopyOptions::new().copy_inside(true); + fs_extra::dir::copy(source_path, target_path, ©_options)?; + tracing::debug!("directory copy completed successfully"); + Ok(()) + } + + fn copy_template(&self, file_path: &Path, settings: &Settings) -> super::Result<()> { + let span = tracing::error_span!("copy_template", file_path = %file_path.display()); + let _guard: tracing::span::Entered<'_> = span.enter(); + if !self.template_engine.is_template(file_path) { + tracing::debug!("file is not a template, skipping rendering"); + return Err(super::Error::msg("File is not a template")); + } + + //todo fix the if here + tracing::debug!("copying template file"); + + let copied_path = self.copy_file(file_path)?; + self.render_and_rename_template_file(&copied_path, settings) + } + + #[allow(clippy::cognitive_complexity)] + fn copy_template_dir(&self, directory_path: &Path, settings: &Settings) -> super::Result<()> { + let source_path = self.source_dir.join(directory_path); + let target_path = self.target_dir.join(directory_path); + + let span = tracing::error_span!("copy_template_dir", source_path = %source_path.display(), target_path = %target_path.display()); + let _guard: tracing::span::Entered<'_> = span.enter(); + + tracing::debug!("starting template directory copy operation"); + + let copy_options = fs_extra::dir::CopyOptions::new().copy_inside(true); + fs_extra::dir::copy(source_path, target_path, ©_options)?; + + tracing::debug!("scanning copied directory for template files to render"); + for entry in WalkDir::new(self.target_dir.join(directory_path)) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if self.template_engine.is_template(path) { + tracing::debug!(template_path = %path.display(), "rendering template file in directory"); + self.render_and_rename_template_file(path, settings)?; + } else { + tracing::debug!(file_path = %path.display(), "not a template file"); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use tree_fs::TreeBuilder; + + use super::*; + + fn init_filesystem() -> FileSystem { + let source_path = TreeBuilder::default() + .add("test/foo.txt", "bar") + .add("test/bar.txt.t", "crate: {{settings.package_name}}") + .create() + .expect("Failed to create mock data"); + + let copy_to = TreeBuilder::default() + .create() + .expect("Failed to create mock data"); + FileSystem::new(&source_path.root, ©_to.root) + } + + #[test] + fn can_copy_file() { + let fs = init_filesystem(); + + assert!(fs.copy_file(&PathBuf::from("test").join("foo.txt")).is_ok()); + let copied_path = fs.target_dir.join("test").join("foo.txt"); + assert!(copied_path.exists()); + assert_eq!( + fs_extra::file::read_to_string(copied_path).expect("read content"), + "bar" + ); + } + + #[test] + fn can_copy_dir() { + let fs = init_filesystem(); + assert!(fs.copy_dir(&PathBuf::from("test")).is_ok()); + let copied_path_1 = fs.target_dir.join("test").join("foo.txt"); + let copied_path_2 = fs.target_dir.join("test").join("bar.txt.t"); + assert!(copied_path_1.exists()); + assert!(copied_path_2.exists()); + + assert_eq!( + fs_extra::file::read_to_string(copied_path_1).expect("read content"), + "bar" + ); + + assert_eq!( + fs_extra::file::read_to_string(copied_path_2).expect("read content"), + "crate: {{settings.package_name}}" + ); + } + + #[test] + fn can_copy_template() { + let fs = init_filesystem(); + + let settings = Settings { + package_name: "loco-app".to_string(), + ..Default::default() + }; + + assert!(fs + .copy_template(&PathBuf::from("test").join("bar.txt.t"), &settings) + .is_ok()); + let copied_path = fs.target_dir.join("test").join("bar.txt"); + assert!(copied_path.exists()); + assert_eq!( + fs_extra::file::read_to_string(copied_path).expect("read content"), + "crate: loco-app" + ); + } + + #[test] + fn can_copy_template_dir() { + let fs = init_filesystem(); + + let settings = Settings { + package_name: "loco-app".to_string(), + ..Default::default() + }; + + assert!(fs + .copy_template_dir(&PathBuf::from("test"), &settings) + .is_ok()); + let copied_path_1 = fs.target_dir.join("test").join("foo.txt"); + let copied_path_2 = fs.target_dir.join("test").join("bar.txt"); + assert!(copied_path_1.exists()); + assert!(copied_path_2.exists()); + + assert_eq!( + fs_extra::file::read_to_string(copied_path_1).expect("read content"), + "bar" + ); + + assert_eq!( + fs_extra::file::read_to_string(copied_path_2).expect("read content"), + "crate: loco-app" + ); + } +} diff --git a/loco-new/src/generator/executer/inmem.rs b/loco-new/src/generator/executer/inmem.rs new file mode 100644 index 000000000..7852d6e01 --- /dev/null +++ b/loco-new/src/generator/executer/inmem.rs @@ -0,0 +1,177 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use super::Executer; +use crate::{generator, settings::Settings}; + +pub struct Inmem { + pub source_path: PathBuf, + pub file_store: Mutex>, + pub template_engine: generator::template::Template, +} + +impl Inmem { + #[must_use] + pub fn new(source: &Path) -> Self { + Self::with_template_engine(source, generator::template::Template::default()) + } + + #[must_use] + pub fn with_template_engine( + source: &Path, + template_engine: generator::template::Template, + ) -> Self { + Self { + source_path: source.to_path_buf(), + file_store: Mutex::new(BTreeMap::default()), + template_engine, + } + } + + pub fn get_file_content(&self, path: &Path) -> Option { + self.file_store + .lock() + .ok() + .and_then(|store| store.get(path).cloned()) + } +} + +impl Executer for Inmem { + fn copy_file(&self, file_path: &Path) -> super::Result { + let file_content = fs_extra::file::read_to_string(self.source_path.join(file_path))?; + self.file_store + .lock() + .unwrap() + .insert(file_path.to_path_buf(), file_content); + Ok(file_path.to_path_buf()) + } + + fn create_file(&self, path: &Path, content: String) -> super::Result { + self.file_store + .lock() + .unwrap() + .insert(path.to_path_buf(), content); + Ok(path.to_path_buf()) + } + + fn copy_dir(&self, directory_path: &Path) -> super::Result<()> { + let directory_content = fs_extra::dir::get_dir_content(directory_path)?; + for file in directory_content.files { + let mut store = self.file_store.lock().unwrap(); + store.insert(PathBuf::from(&file), fs_extra::file::read_to_string(file)?); + } + Ok(()) + } + + fn copy_template(&self, file_path: &Path, settings: &Settings) -> super::Result<()> { + let copied_path = self.copy_file(file_path)?; + + if self.template_engine.is_template(&copied_path) { + let template_content = { + let store = self.file_store.lock().unwrap(); + store.get(&copied_path).cloned() + }; + + if let Some(content) = template_content { + let rendered_content = self.template_engine.render(&content, settings)?; + self.file_store + .lock() + .unwrap() + .insert(file_path.to_path_buf(), rendered_content); + Ok(()) + } else { + Err(super::Error::msg("Template content not found")) + } + } else { + Err(super::Error::msg("File is not a template")) + } + } + + fn copy_template_dir(&self, _path: &Path, _data: &Settings) -> super::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use tree_fs::{Tree, TreeBuilder}; + + use super::*; + + fn init_in_memory_store() -> (Inmem, Tree) { + let tree = TreeBuilder::default() + .drop(true) + .add("test/foo.txt", "bar") + .add("test/bar.txt.t", "crate: {{settings.package_name}}") + .create() + .expect("Failed to create mock data"); + (Inmem::new(&tree.root), tree) + } + + #[test] + fn can_copy_file() { + let (store, source_dir) = init_in_memory_store(); + let test_file_path = source_dir.root.join("test").join("foo.txt"); + + let copied_path = store.copy_file(&test_file_path).unwrap(); + + assert_eq!(copied_path, test_file_path); + assert_eq!( + store + .file_store + .lock() + .unwrap() + .get(&test_file_path) + .unwrap(), + "bar" + ); + } + + #[test] + fn test_copy_directory() { + let (store, source_dir) = init_in_memory_store(); + let dir_path = source_dir.root.join("test"); + + store.copy_dir(&dir_path).unwrap(); + + let file1_path = dir_path.join("foo.txt"); + let file2_path = dir_path.join("bar.txt.t"); + + assert_eq!( + store.file_store.lock().unwrap().get(&file1_path).unwrap(), + "bar" + ); + assert_eq!( + store.file_store.lock().unwrap().get(&file2_path).unwrap(), + "crate: {{settings.package_name}}" + ); + } + + #[test] + fn can_copy_template_file() { + let (store, source_dir) = init_in_memory_store(); + let test_file_path = source_dir.root.join("test").join("bar.txt.t"); + + let settings = Settings { + package_name: "loco-app".to_string(), + ..Default::default() + }; + + store + .copy_template(&test_file_path, &settings) + .expect("copy template"); + + assert_eq!( + store + .file_store + .lock() + .unwrap() + .get(&test_file_path) + .unwrap(), + "crate: loco-app" + ); + } +} diff --git a/loco-new/src/generator/executer/mod.rs b/loco-new/src/generator/executer/mod.rs new file mode 100644 index 000000000..fdd8318b4 --- /dev/null +++ b/loco-new/src/generator/executer/mod.rs @@ -0,0 +1,78 @@ +//! This module defines error handling and the [`Executer`] trait + +use crate::settings::Settings; +mod filesystem; +mod inmem; +use std::path::{Path, PathBuf}; + +pub use filesystem::FileSystem; +pub use inmem::Inmem; +#[cfg(test)] +use mockall::{automock, predicate::*}; + +pub type Result = std::result::Result; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("{0}")] + Message(String), + + #[error(transparent)] + TemplateEngine(#[from] Box), + + #[error(transparent)] + FS(#[from] fs_extra::error::Error), + + #[error(transparent)] + Template(#[from] tera::Error), +} +impl Error { + /// Creates a new error with a custom message. + pub fn msg>(msg: S) -> Self { + Self::Message(msg.into()) + } +} + +#[cfg_attr(test, automock)] +pub trait Executer: Send + Sync { + /// Copies a single file from the specified path. + /// + /// # Errors + /// + /// Returns an error if the file cannot be copied, such as if the path is + /// invalid or if a file system error occurs. + fn copy_file(&self, path: &Path) -> Result; + + /// Copies a single file from the specified path. + /// + /// # Errors + /// + /// Returns an error if the file cannot be copied, such as if the path is + /// invalid or if a file system error occurs. + fn create_file(&self, path: &Path, content: String) -> Result; + + /// Copies an entire directory from the specified path. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be copied, such as if the path + /// is invalid or if a file system error occurs. + fn copy_dir(&self, path: &Path) -> Result<()>; + + /// Copies a template file from the specified path, applying settings. + /// + /// # Errors + /// + /// Returns an error if the template cannot be copied or if any + /// settings-related error occurs. + fn copy_template(&self, path: &Path, data: &Settings) -> Result<()>; + + /// Copies an entire template directory from the specified path, applying + /// settings. + /// + /// # Errors + /// + /// Returns an error if the template directory cannot be copied or if any + /// settings-related error occurs. + fn copy_template_dir(&self, path: &Path, data: &Settings) -> Result<()>; +} diff --git a/loco-new/src/generator/mod.rs b/loco-new/src/generator/mod.rs new file mode 100644 index 000000000..3fde04784 --- /dev/null +++ b/loco-new/src/generator/mod.rs @@ -0,0 +1,361 @@ +//! This module defines the `Generator` struct, which is responsible for +//! executing scripted commands + +use std::path::{Path, PathBuf}; +pub mod executer; +pub mod template; +use std::sync::Arc; + +use include_dir::{include_dir, Dir}; +use rhai::{Engine, Scope}; + +use crate::settings; + +static APP_TEMPLATE: Dir<'_> = include_dir!("loco-new/base_template"); + +/// Extracts a default template to a temporary directory for use by the +/// application. +/// +/// # Errors +/// when could not extract the the base template +pub fn extract_default_template() -> std::io::Result { + let generator_tmp_folder = std::env::temp_dir().join("loco-generator"); + if generator_tmp_folder.exists() { + std::fs::remove_dir_all(&generator_tmp_folder)?; + } + + std::fs::create_dir_all(&generator_tmp_folder)?; + + APP_TEMPLATE.extract(&generator_tmp_folder)?; + Ok(generator_tmp_folder) +} + +/// The `Generator` struct provides functionality to execute scripted +/// operations, such as copying files and templates, based on the current +/// settings. +#[derive(Clone)] +pub struct Generator { + pub executer: Arc, + pub settings: settings::Settings, +} +impl Generator { + /// Creates a new [`Generator`] with a given executor and settings. + pub fn new(executer: Arc, settings: settings::Settings) -> Self { + Self { executer, settings } + } + + /// Runs the default script. + /// + /// # Errors + /// + /// Returns an error if the script execution fails. + pub fn run(&self) -> crate::Result<()> { + self.run_from_script(include_str!("../../setup.rhai")) + } + + /// Runs a custom script provided as a string. + /// + /// # Errors + /// + /// Returns an error if the script execution fails. + pub fn run_from_script(&self, script: &str) -> crate::Result<()> { + let mut engine = Engine::new(); + + tracing::debug!( + settings = format!("{:?}", self.settings), + script, + "prepare installation script" + ); + engine + .build_type::() + .build_type::() + .register_fn("copy_file", Self::copy_file) + .register_fn("create_file", Self::create_file) + .register_fn("copy_files", Self::copy_files) + .register_fn("copy_dir", Self::copy_dir) + .register_fn("copy_dirs", Self::copy_dirs) + .register_fn("copy_template", Self::copy_template) + .register_fn("copy_template_dir", Self::copy_template_dir); + + let settings_dynamic = rhai::Dynamic::from(self.settings.clone()); + + let mut scope = Scope::new(); + scope.set_value("settings", settings_dynamic); + scope.push("gen", self.clone()); + // TODO:: move it as part of the settings? + scope.push("db", self.settings.db.is_some()); + scope.push("background", self.settings.background.is_some()); + scope.push("initializers", self.settings.initializers.is_some()); + scope.push("asset", self.settings.asset.is_some()); + + engine.run_with_scope(&mut scope, script)?; + Ok(()) + } + + /// Copies a single file from the specified path. + /// + /// # Errors + /// + /// Returns an error if the file copy operation fails. + pub fn copy_file(&mut self, path: &str) -> Result<(), Box> { + let span = tracing::info_span!("copy_file", path); + let _guard = span.enter(); + + self.executer.copy_file(Path::new(path)).map_err(|err| { + Box::new(rhai::EvalAltResult::ErrorSystem( + "copy_file".to_string(), + err.into(), + )) + })?; + Ok(()) + } + + /// Creates a single file in the specified path. + /// + /// # Errors + /// + /// Returns an error if the file copy operation fails. + pub fn create_file( + &mut self, + path: &str, + content: &str, + ) -> Result<(), Box> { + let span = tracing::info_span!("create_file", path); + let _guard = span.enter(); + + self.executer + .create_file(Path::new(path), content.to_string()) + .map_err(|err| { + Box::new(rhai::EvalAltResult::ErrorSystem( + "create_file".to_string(), + err.into(), + )) + })?; + Ok(()) + } + + /// Copies list of files from the specified path. + /// + /// # Errors + /// + /// Returns an error if the file copy operation fails. + pub fn copy_files(&mut self, paths: rhai::Array) -> Result<(), Box> { + let span = tracing::info_span!("copy_files"); + let _guard = span.enter(); + for path in paths { + self.executer + .copy_file(Path::new(&path.to_string())) + .map_err(|err| { + Box::new(rhai::EvalAltResult::ErrorSystem( + "copy_files".to_string(), + err.into(), + )) + })?; + } + + Ok(()) + } + + /// Copies an entire directory from the specified path. + /// + /// # Errors + /// + /// Returns an error if the directory copy operation fails. + pub fn copy_dir(&mut self, path: &str) -> Result<(), Box> { + let span = tracing::info_span!("copy_dir", path); + let _guard = span.enter(); + self.executer.copy_dir(Path::new(path)).map_err(|err| { + Box::new(rhai::EvalAltResult::ErrorSystem( + "copy_dir".to_string(), + err.into(), + )) + }) + } + + /// Copies list of directories from the specified path. + /// + /// # Errors + /// + /// Returns an error if the directory copy operation fails. + pub fn copy_dirs(&mut self, paths: rhai::Array) -> Result<(), Box> { + let span = tracing::info_span!("copy_dirs"); + let _guard = span.enter(); + for path in paths { + self.executer + .copy_dir(Path::new(&path.to_string())) + .map_err(|err| { + Box::new(rhai::EvalAltResult::ErrorSystem( + "copy_dirs".to_string(), + err.into(), + )) + })?; + } + Ok(()) + } + + /// Copies a template file from the specified path, applying settings. + /// + /// # Errors + /// + /// Returns an error if the template copy operation fails. + pub fn copy_template(&mut self, path: &str) -> Result<(), Box> { + let span = tracing::info_span!("copy_template", path); + let _guard = span.enter(); + self.executer + .copy_template(Path::new(path), &self.settings) + .map_err(|err| { + Box::new(rhai::EvalAltResult::ErrorSystem( + "copy_template".to_string(), + err.into(), + )) + }) + } + + /// Copies an entire template directory from the specified path, applying + /// settings. + /// + /// # Errors + /// + /// Returns an error if the template directory copy operation fails. + pub fn copy_template_dir(&mut self, path: &str) -> Result<(), Box> { + let span = tracing::info_span!("copy_template_dir", path); + let _guard = span.enter(); + self.executer + .copy_template_dir(Path::new(path), &self.settings) + .map_err(|err| { + Box::new(rhai::EvalAltResult::ErrorSystem( + "copy_template_dir".to_string(), + err.into(), + )) + }) + } +} + +#[cfg(test)] +mod tests { + use executer::MockExecuter; + use mockall::predicate::*; + + use super::*; + + #[test] + pub fn can_copy_file() { + let mut executor = MockExecuter::new(); + + executor + .expect_copy_file() + .with(eq(Path::new("test.rs"))) + .times(1) + .returning(|p| Ok(p.to_path_buf())); + + let g = Generator::new(Arc::new(executor), settings::Settings::default()); + let script_res = g.run_from_script(r#"gen.copy_file("test.rs");"#); + + assert!(script_res.is_ok()); + } + + #[test] + pub fn can_copy_files() { + let mut executor = MockExecuter::new(); + + executor + .expect_copy_file() + .with(eq(Path::new(".gitignore"))) + .times(1) + .returning(|p| Ok(p.to_path_buf())); + + executor + .expect_copy_file() + .with(eq(Path::new(".rustfmt.toml"))) + .times(1) + .returning(|p| Ok(p.to_path_buf())); + + executor + .expect_copy_file() + .with(eq(Path::new("README.md"))) + .times(1) + .returning(|p| Ok(p.to_path_buf())); + + let g = Generator::new(Arc::new(executor), settings::Settings::default()); + let script_res = + g.run_from_script(r#"gen.copy_files([".gitignore", ".rustfmt.toml", "README.md"]);"#); + + assert!(script_res.is_ok()); + } + + #[test] + pub fn can_copy_dir() { + let mut executor = MockExecuter::new(); + + executor + .expect_copy_dir() + .with(eq(Path::new("test"))) + .times(1) + .returning(|_| Ok(())); + + let g = Generator::new(Arc::new(executor), settings::Settings::default()); + let script_res = g.run_from_script(r#"gen.copy_dir("test");"#); + + assert!(script_res.is_ok()); + } + + #[test] + pub fn can_copy_dirs() { + let mut executor = MockExecuter::new(); + + executor + .expect_copy_dir() + .with(eq(Path::new("src"))) + .times(1) + .returning(|_| Ok(())); + + executor + .expect_copy_dir() + .with(eq(Path::new("example"))) + .times(1) + .returning(|_| Ok(())); + + executor + .expect_copy_dir() + .with(eq(Path::new(".github"))) + .times(1) + .returning(|_| Ok(())); + + let g = Generator::new(Arc::new(executor), settings::Settings::default()); + let script_res = g.run_from_script(r#"gen.copy_dirs(["src", "example", ".github"]);"#); + + assert!(script_res.is_ok()); + } + + #[test] + pub fn can_copy_template() { + let mut executor = MockExecuter::new(); + + executor + .expect_copy_template() + .with(eq(Path::new("src/lib.rs.t")), always()) + .times(1) + .returning(|_, _| Ok(())); + + let g = Generator::new(Arc::new(executor), settings::Settings::default()); + let script_res = g.run_from_script(r#"gen.copy_template("src/lib.rs.t");"#); + + assert!(script_res.is_ok()); + } + + #[test] + pub fn can_copy_template_dir() { + let mut executor = MockExecuter::new(); + + executor + .expect_copy_template_dir() + .with(eq(Path::new("src/examples")), always()) + .times(1) + .returning(|_, _| Ok(())); + + let g = Generator::new(Arc::new(executor), settings::Settings::default()); + let script_res = g.run_from_script(r#"gen.copy_template_dir("src/examples");"#); + + assert!(script_res.is_ok()); + } +} diff --git a/loco-new/src/generator/template.rs b/loco-new/src/generator/template.rs new file mode 100644 index 000000000..1c5afb309 --- /dev/null +++ b/loco-new/src/generator/template.rs @@ -0,0 +1,200 @@ +//! This module defines a `Template` struct for handling template files. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use rand::{distributions::Alphanumeric, rngs::StdRng, Rng, SeedableRng}; +use tera::{Context, Tera}; + +use crate::settings::Settings; + +const TEMPLATE_EXTENSION: &str = "t"; + +fn generate_random_string(rng: &mut R, length: u64) -> String { + (0..length) + .map(|_| rng.sample(Alphanumeric) as char) + .collect() +} + +/// Represents a template that can be rendered with injected settings. +#[derive(Debug, Clone)] +pub struct Template { + rng: Arc>, +} + +impl Default for Template { + fn default() -> Self { + #[cfg(test)] + let rng = StdRng::seed_from_u64(42); + #[cfg(not(test))] + let rng = StdRng::from_entropy(); + Self { + rng: Arc::new(Mutex::new(rng)), + } + } +} + +impl Template { + #[must_use] + pub fn new(rng: StdRng) -> Self { + Self { + rng: Arc::new(Mutex::new(rng)), + } + } + /// Checks if the provided file path has a ".t" extension, marking it as a + /// template. + /// + /// Returns `true` if the file has a ".t" extension, otherwise `false`. + #[must_use] + pub fn is_template(&self, path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .filter(|&ext| ext == TEMPLATE_EXTENSION) + .is_some() + } + + // Method to register filters in the Tera instance. + fn register_filters(&self, tera_instance: &mut tera::Tera) { + // Clone the Arc to move it into the closure. + let rng_clone = Arc::clone(&self.rng); + + tera_instance.register_filter( + "random_string", + move |value: &tera::Value, _args: &HashMap| { + if let tera::Value::Number(length) = value { + if let Some(length) = length.as_u64() { + let rand_str: String = rng_clone.lock().map_or_else( + |_| { + let mut r = StdRng::from_entropy(); + generate_random_string(&mut r, length) + }, + |mut rng| generate_random_string(&mut *rng, length), + ); + return Ok(tera::Value::String(rand_str)); + } + } + // Ok(tera::Value::String(String::new())) + Err(tera::Error::msg("arg must be a number")) + }, + ); + } + + /// Renders a template with the provided content and settings. + /// + /// # Errors + /// when could not render the template + pub fn render(&self, template_content: &str, settings: &Settings) -> tera::Result { + tracing::trace!( + template_content, + settings = format!("{settings:#?}"), + "render template" + ); + + let mut tera_instance = Tera::default(); + self.register_filters(&mut tera_instance); + + let mut context = Context::new(); + context.insert("settings", &settings); + + let rendered_output = tera_instance.render_str(template_content, &context)?; + + Ok(rendered_output) + } + + /// Removes the ".t" extension from a template file path, if present. + /// + /// # Errors + /// if the given path is not contains template extension + pub fn strip_template_extension(&self, path: &Path) -> std::io::Result { + path.file_stem().map_or_else( + || { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Failed to retrieve file stem", + )) + }, + |stem| { + let mut path_without_extension = path.to_path_buf(); + path_without_extension.set_file_name(stem); + if let Some(parent_dir) = path.parent() { + path_without_extension = parent_dir.join(stem.to_string_lossy().to_string()); + } + Ok(path_without_extension) + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_template() { + let template = Template::default(); + + let path = Path::new("example.t"); + assert!(template.is_template(path)); + + let path = Path::new("example.txt"); + assert!(!template.is_template(path)); + + let path = Path::new("directory/"); + assert!(!template.is_template(path)); + } + + #[test] + fn test_render_template() { + let template = Template::default(); + let template_content = "crate: {{ settings.package_name }}"; + + let mock_settings = Settings { + package_name: "loco-app".to_string(), + ..Default::default() + }; + + let result = template.render(template_content, &mock_settings); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "crate: loco-app"); + } + + #[test] + fn test_strip_template_extension() { + let template = Template::default(); + + let path = Path::new("example.t"); + let result = template.strip_template_extension(path); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Path::new("example")); + + let path = Path::new("example"); + let result = template.strip_template_extension(path); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Path::new("example")); + + let path = Path::new(""); + let result = template.strip_template_extension(path); + assert!(result.is_err()); + } + + #[test] + fn can_create_random_string() { + let template = Template::default(); + let template_content = "rand: {{20 | random_string }}"; + + let mock_settings = Settings { + package_name: "loco-app".to_string(), + ..Default::default() + }; + + let result = template.render(template_content, &mock_settings); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "rand: IhPi3oZCnaWvL2oIeA07"); + let result = template.render(template_content, &mock_settings); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "rand: mg3ZtJzh0NoAKhdDqpQ2"); + } +} diff --git a/loco-new/src/lib.rs b/loco-new/src/lib.rs new file mode 100644 index 000000000..4cf08ce39 --- /dev/null +++ b/loco-new/src/lib.rs @@ -0,0 +1,34 @@ +pub mod generator; +pub mod settings; +pub mod wizard; + +pub type Result = std::result::Result; + +/// Matching minimal Loco version. +pub const LOCO_VERSION: &str = "0.13"; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("{0}")] + Message(String), + + #[error(transparent)] + Dialog(#[from] dialoguer::Error), + + #[error(transparent)] + IO(#[from] std::io::Error), + + #[error(transparent)] + FS(#[from] fs_extra::error::Error), + + #[error(transparent)] + TemplateEngine(#[from] Box), + + #[error(transparent)] + Generator(#[from] crate::generator::executer::Error), +} +impl Error { + pub fn msg>(msg: S) -> Self { + Self::Message(msg.into()) + } +} diff --git a/loco-new/src/settings.rs b/loco-new/src/settings.rs new file mode 100644 index 000000000..1e6649162 --- /dev/null +++ b/loco-new/src/settings.rs @@ -0,0 +1,171 @@ +//! Defines configurable application settings. + +use std::env; + +use heck::ToSnakeCase; +use rhai::{CustomType, TypeBuilder}; +use serde::{Deserialize, Serialize}; + +use crate::{ + wizard::{self, AssetsOption, BackgroundOption, DBOption}, + LOCO_VERSION, +}; + +/// Represents general application settings. +#[derive(Serialize, Deserialize, Clone, Debug, CustomType)] +pub struct Settings { + pub package_name: String, + pub module_name: String, + pub db: Option, + pub background: Option, + pub asset: Option, + pub auth: bool, + pub mailer: bool, + pub clientside: bool, + pub initializers: Option, + pub features: Features, + pub loco_version_text: String, +} + +impl From for Option { + fn from(db_option: DBOption) -> Self { + match db_option { + DBOption::None => None, + _ => Some(Db { + kind: db_option.clone(), + endpoint: db_option.endpoint_config().to_string(), + }), + } + } +} + +impl From for Option { + fn from(bg: BackgroundOption) -> Self { + match bg { + BackgroundOption::None => None, + _ => Some(Background { kind: bg }), + } + } +} + +impl From for Option { + fn from(asset: AssetsOption) -> Self { + match asset { + AssetsOption::None => None, + _ => Some(Asset { kind: asset }), + } + } +} + +impl Settings { + /// Creates a new [`Settings`] instance based on prompt selections. + #[must_use] + pub fn from_wizard(package_name: &str, prompt_selection: &wizard::Selections) -> Self { + let features = if prompt_selection.db.enable() { + Features::default() + } else { + let mut features = Features::disable_features(); + if prompt_selection.background.enable() { + features.names.push("bg_redis".to_string()); + }; + features + }; + + Self { + package_name: package_name.to_string(), + module_name: package_name.to_snake_case(), + auth: prompt_selection.db.enable(), + mailer: prompt_selection.db.enable(), + db: prompt_selection.db.clone().into(), + background: prompt_selection.background.clone().into(), + asset: prompt_selection.asset.clone().into(), + clientside: prompt_selection.asset.enable(), + initializers: if prompt_selection.asset.enable() { + Some(Initializers { view_engine: true }) + } else { + None + }, + features, + loco_version_text: get_loco_version_text(), + } + } +} +impl Default for Settings { + fn default() -> Self { + #[allow(clippy::default_trait_access)] + Self { + package_name: Default::default(), + module_name: Default::default(), + db: Default::default(), + background: Default::default(), + asset: Default::default(), + auth: Default::default(), + mailer: Default::default(), + clientside: Default::default(), + initializers: Default::default(), + features: Default::default(), + loco_version_text: get_loco_version_text(), + } + } +} + +fn get_loco_version_text() -> String { + env::var("LOCO_DEV_MODE_PATH").map_or_else( + |_| format!(r#"version = "{LOCO_VERSION}""#), + |path| { + let path = path.replace('\\', "/"); + format!(r#"version="*", path="{path}""#) + }, + ) +} + +/// Database configuration settings. +#[derive(Serialize, Deserialize, Clone, Debug, Default, CustomType)] +pub struct Db { + pub kind: DBOption, + pub endpoint: String, +} + +/// Background processing configuration. +#[derive(Serialize, Deserialize, Clone, Debug, Default, CustomType)] +pub struct Background { + pub kind: BackgroundOption, +} + +/// Asset configuration settings. +#[derive(Serialize, Deserialize, Clone, Debug, Default, CustomType)] +pub struct Asset { + pub kind: AssetsOption, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default, CustomType)] +pub struct Initializers { + pub view_engine: bool, +} + +/// Feature configuration, allowing toggling of optional features. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Features { + pub default_features: bool, + pub names: Vec, +} + +impl Default for Features { + fn default() -> Self { + Self { + default_features: true, + names: vec![], + } + } +} + +impl Features { + /// Disables default features. + #[must_use] + pub fn disable_features() -> Self { + Self { + default_features: false, + names: vec!["cli".to_string()], + } + } +} diff --git a/loco-new/src/wizard.rs b/loco-new/src/wizard.rs new file mode 100644 index 000000000..d7dd912a4 --- /dev/null +++ b/loco-new/src/wizard.rs @@ -0,0 +1,380 @@ +//! This module provides interactive utilities for setting up application +//! configurations based on user input. + +use clap::ValueEnum; +use colored::Colorize; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumIter, IntoEnumIterator}; + +use crate::Error; + +#[derive( + Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum, +)] +pub enum Template { + #[default] + #[strum(to_string = "Saas App with server side rendering")] + SaasServerSideRendering, + #[strum(to_string = "Saas App with client side rendering")] + SaasClientSideRendering, + #[strum(to_string = "Rest API (with DB and user auth)")] + RestApi, + #[strum(to_string = "lightweight-service (minimal, only controllers and views)")] + Lightweight, + #[strum(to_string = "Advanced")] + Advanced, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub enum OptionsList { + #[serde(rename = "db")] + DB, + #[serde(rename = "bg")] + Background, + #[serde(rename = "assets")] + Assets, +} + +#[derive( + Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum, +)] +pub enum DBOption { + #[default] + #[serde(rename = "sqlite")] + Sqlite, + #[serde(rename = "pg")] + Postgres, + #[serde(rename = "none")] + None, +} + +impl DBOption { + #[must_use] + pub const fn enable(&self) -> bool { + !matches!(self, Self::None) + } + + #[must_use] + pub fn user_message(&self) -> Option { + match self { + Self::Postgres => Some(format!( + "{}: You've selected `{}` as your DB provider (you should have a postgres \ + instance to connect to)", + "database".underline(), + "postgres".yellow() + )), + Self::Sqlite | Self::None => None, + } + } + + #[must_use] + pub const fn endpoint_config(&self) -> &str { + match self { + Self::Sqlite => "sqlite://loco_app.sqlite?mode=rwc", + Self::Postgres => "postgres://loco:loco@localhost:5432/loco_app", + Self::None => "", + } + } +} + +#[derive( + Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum, +)] +pub enum BackgroundOption { + #[default] + #[strum(to_string = "Async (in-process tokio async tasks)")] + #[serde(rename = "BackgroundAsync")] + Async, + #[strum(to_string = "Queue (standalone workers using Redis)")] + #[serde(rename = "BackgroundQueue")] + Queue, + #[strum(to_string = "Blocking (run tasks in foreground)")] + #[serde(rename = "ForegroundBlocking")] + Blocking, + #[strum(to_string = "None")] + #[serde(rename = "none")] + None, +} + +impl BackgroundOption { + #[must_use] + pub const fn enable(&self) -> bool { + !matches!(self, Self::None) + } + + #[must_use] + pub fn user_message(&self) -> Option { + match self { + Self::Queue => Some(format!( + "{}: You've selected `{}` for your background worker configuration (you should \ + have a Redis/valkey instance to connect to)", + "workers".underline(), + "queue".yellow() + )), + Self::Blocking => Some(format!( + "{}: You've selected `{}` for your background worker configuration. Your workers \ + configuration will BLOCK REQUESTS until a task is done.", + "workers".underline(), + "blocking".yellow() + )), + Self::Async | Self::None => None, + } + } + + #[must_use] + pub const fn prompt_view(&self) -> &str { + match self { + Self::Async => "Async", + Self::Queue => "BackgroundQueue", + Self::Blocking => "ForegroundBlocking", + Self::None => "None", + } + } +} + +#[derive( + Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum, +)] +pub enum AssetsOption { + #[default] + #[strum(to_string = "Server (configures server-rendered views)")] + #[serde(rename = "server")] + Serverside, + #[strum(to_string = "Client (configures assets for frontend serving)")] + #[serde(rename = "client")] + Clientside, + #[strum(to_string = "None")] + #[serde(rename = "none")] + None, +} + +impl AssetsOption { + #[must_use] + pub const fn enable(&self) -> bool { + !matches!(self, Self::None) + } + + #[must_use] + pub fn user_message(&self) -> Option { + match self { + Self::Clientside => Some(format!( + "{}: You've selected `{}` for your asset serving configuration.\n\nNext step, \ + build your frontend:\n $ cd {}\n $ npm install && npm run build\n", + "assets".underline(), + "clientside".yellow(), + "frontend/".yellow() + )), + Self::Serverside | Self::None => None, + } + } +} + +#[derive(Debug, Clone, Default)] +/// Represents internal placeholders to be replaced. +pub struct ArgsPlaceholder { + pub db: Option, + pub bg: Option, + pub assets: Option, +} + +/// Holds the user's configuration selections. +pub struct Selections { + pub db: DBOption, + pub background: BackgroundOption, + pub asset: AssetsOption, +} + +impl Selections { + #[must_use] + pub fn message(&self) -> Vec { + let mut res = Vec::new(); + if let Some(m) = self.db.user_message() { + res.push(m); + } + if let Some(m) = self.background.user_message() { + res.push(m); + } + if let Some(m) = self.asset.user_message() { + res.push(m); + } + res + } +} + +/// Prompts the user to enter an application name, with optional pre-set name +/// input. Validates the name to ensure compliance with required naming rules. +/// +/// # Errors +/// when could not show user selection +pub fn app_name(name: Option) -> crate::Result { + if let Some(app_name) = name { + validate_app_name(app_name.as_str()).map_err(|err| Error::msg(err.to_string()))?; + Ok(app_name) + } else { + let res = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("❯ App name?") + .default("myapp".into()) + .validate_with(|input: &String| { + if let Err(err) = validate_app_name(input) { + Err(err.to_string()) + } else { + Ok(()) + } + }) + .interact_text()?; + Ok(res) + } +} + +/// Warns the user if the current directory is inside a Git repository and +/// prompts them to confirm whether they wish to proceed. If declined, an error +/// is returned. +/// +/// # Errors +/// when could not show user selection or user chose not continue +pub fn warn_if_in_git_repo() -> crate::Result<()> { + let answer = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("❯ You are inside a git repository. Do you wish to continue?") + .default(false) + .interact()?; + + if answer { + Ok(()) + } else { + Err(Error::msg( + "Aborted: You've chose not to continue.".to_string(), + )) + } +} + +/// Validates the application name. +fn validate_app_name(app_name: &str) -> Result<(), &str> { + if app_name.is_empty() { + return Err("app name could not be empty"); + } + + let mut chars = app_name.chars(); + if let Some(ch) = chars.next() { + if ch.is_ascii_digit() { + return Err("the name cannot start with a digit"); + } + if !(unicode_xid::UnicodeXID::is_xid_start(ch) || ch == '_') { + return Err( + "the first character must be a Unicode XID start character (most letters or `_`)", + ); + } + } + for ch in chars { + if !(unicode_xid::UnicodeXID::is_xid_continue(ch) || ch == '-') { + return Err( + "characters must be Unicode XID characters (numbers, `-`, `_`, or most letters)", + ); + } + } + Ok(()) +} + +/// Provides a selection menu to the user for choosing from a list of options. +/// Returns the selected option or a default if selection fails. +fn select_option(text: &str, options: &[T]) -> crate::Result +where + T: Default + ToString + Clone, +{ + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(text) + .default(0) + .items(options) + .interact()?; + Ok(options.get(selection).cloned().unwrap_or_default()) +} + +/// start wizard +/// +/// # Errors +/// when could not show user selection or user chose not continue +pub fn start(args: &ArgsPlaceholder) -> crate::Result { + // user provided everything via flags so no need to prompt, just return + if args.db.is_some() && args.bg.is_some() && args.assets.is_some() { + return Ok(Selections { + db: args.db.clone().unwrap(), + background: args.bg.clone().unwrap(), + asset: args.assets.clone().unwrap(), + }); + } + + let template = select_option( + "❯ What would you like to build?", + &Template::iter().collect::>(), + )?; + + match template { + Template::Lightweight => Ok(Selections { + db: DBOption::None, + background: BackgroundOption::None, + asset: AssetsOption::None, + }), + Template::RestApi => Ok(Selections { + db: select_db(args)?, + background: select_background(args)?, + asset: AssetsOption::None, + }), + Template::SaasServerSideRendering => Ok(Selections { + db: select_db(args)?, + background: select_background(args)?, + asset: AssetsOption::Serverside, + }), + Template::SaasClientSideRendering => Ok(Selections { + db: select_db(args)?, + background: select_background(args)?, + asset: AssetsOption::Clientside, + }), + Template::Advanced => Ok(Selections { + db: select_db(args)?, + background: select_background(args)?, + asset: select_asset(args)?, + }), + } +} + +/// Prompts the user to select a database option if none is provided in the +/// arguments. +fn select_db(args: &ArgsPlaceholder) -> crate::Result { + let dboption = if let Some(dboption) = args.db.clone() { + dboption + } else { + select_option( + "❯ Select a DB Provider", + &DBOption::iter().collect::>(), + )? + }; + Ok(dboption) +} + +/// Prompts the user to select a background worker option if none is provided in +/// the arguments. +fn select_background(args: &ArgsPlaceholder) -> crate::Result { + let bgopt = if let Some(bgopt) = args.bg.clone() { + bgopt + } else { + select_option( + "❯ Select your background worker type", + &BackgroundOption::iter().collect::>(), + )? + }; + Ok(bgopt) +} + +/// Prompts the user to select an asset configuration if none is provided in the +/// arguments. +fn select_asset(args: &ArgsPlaceholder) -> crate::Result { + let assetopt = if let Some(assetopt) = args.assets.clone() { + assetopt + } else { + select_option( + "❯ Select an asset serving configuration", + &AssetsOption::iter().collect::>(), + )? + }; + Ok(assetopt) +} diff --git a/loco-new/tests/assertion/mod.rs b/loco-new/tests/assertion/mod.rs new file mode 100644 index 000000000..4f5a5f5bf --- /dev/null +++ b/loco-new/tests/assertion/mod.rs @@ -0,0 +1,3 @@ +pub mod string; +pub mod toml; +pub mod yaml; diff --git a/loco-new/tests/assertion/string.rs b/loco-new/tests/assertion/string.rs new file mode 100644 index 000000000..585fe869a --- /dev/null +++ b/loco-new/tests/assertion/string.rs @@ -0,0 +1,23 @@ +#![allow(clippy::missing_panics_doc)] +use regex::Regex; + +pub fn assert_line_regex(content: &str, expected: &str) { + let re = Regex::new(expected).unwrap(); + + // Use assert! to check the regex match and panic if it fails + assert!( + // sanitize windows crlf + re.is_match(&content.replace('\r', "")), + "Assertion failed: The content did not match the expected string. Expected: '{expected}', \ + content:\n{content}" + ); +} + +pub fn assert_str_not_exists(content: &str, expected: &str) { + // Use assert! to check the regex match and panic if it fails + assert!( + !content.contains(expected), + "Assertion failed: The content matched the unexpected string. Expected string to not \ + exist: '{expected}', content in:\n{content}", + ); +} diff --git a/loco-new/tests/assertion/toml.rs b/loco-new/tests/assertion/toml.rs new file mode 100644 index 000000000..3c068e1de --- /dev/null +++ b/loco-new/tests/assertion/toml.rs @@ -0,0 +1,121 @@ +#![allow(clippy::missing_panics_doc)] +use std::path::PathBuf; + +use toml::Value; + +#[must_use] +pub fn load(path: PathBuf) -> toml::Value { + let s = std::fs::read_to_string(path).expect("could not open file"); + toml::from_str(&s).expect("invalid toml content") +} + +pub fn assert_path_value_eq_string(toml: &Value, path: &[&str], expected: &str) { + let expected_value = Value::String(expected.to_string()); + assert_path_value_eq(toml, path, &expected_value); +} + +pub fn eq_path_value_eq_bool(toml: &Value, path: &[&str], expected: bool) { + let expected_value = Value::Boolean(expected); + assert_path_value_eq(toml, path, &expected_value); +} + +pub fn assert_path_is_empty_array(toml: &Value, path: &[&str]) { + let actual = get_value_at_path(toml, path); + + assert!( + match actual { + Some(Value::Array(arr)) => arr.is_empty(), + None => true, + _ => false, + }, + "Assertion failed: Path {path:?} is not an empty array. Actual value: {actual:?}" + ); +} + +/// Assert that the value at the specified path is an array and matches the +/// expected array. +pub fn assert_path_value_eq_array(toml: &Value, path: &[&str], expected: &[Value]) { + let expected_value = Value::Array(expected.to_vec()); + assert_path_value_eq(toml, path, &expected_value); +} + +/// Assert that a TOML value contains a specific key path and that it matches +/// the expected value. +pub fn assert_path_value_eq(toml: &Value, path: &[&str], expected: &Value) { + let actual = get_value_at_path(toml, path); + assert!( + actual == Some(expected), + "Assertion failed: Path {path:?} does not match expected value. Expected: {expected:?}, \ + Actual: {actual:?}" + ); +} + +/// Assert that a TOML value contains a specific path, and that the value is an +/// object (table). +pub fn assert_path_is_object(toml: &Value, path: &[&str]) { + let actual = get_value_at_path(toml, path).unwrap(); + assert!( + matches!(actual, Value::Table(_)), + "Assertion failed: Path {path:?} is not an object. Actual value: {actual:?}" + ); +} + +/// Helper function to concatenate keys of a nested table to form a string. +#[must_use] +pub fn get_keys_concatenated_as_string(toml: &Value, path: &[&str]) -> Option { + let value_at_path = get_value_at_path(toml, path)?; + if let Value::Table(table) = value_at_path { + let mut concatenated_string = String::new(); + for key in table.keys() { + concatenated_string.push_str(key); + } + Some(concatenated_string) + } else { + None + } +} + +/// Assert that the TOML value at the given path is empty (either an empty table +/// or array). +pub fn assert_path_is_empty(toml: &Value, path: &[&str]) { + let actual = get_value_at_path(toml, path); + + assert!( + match actual { + Some(Value::Table(table)) => table.is_empty(), + Some(Value::Array(arr)) => arr.is_empty(), + None => true, + _ => false, + }, + "Assertion failed: Path {path:?} is not empty. Actual value: {actual:?}" + ); +} + +pub fn assert_path_exists(toml: &Value, path: &[&str]) { + let actual = get_value_at_path(toml, path); + + assert!( + actual.is_some(), + "Assertion failed: Path {path:?} does not exist. Actual value: {actual:?}" + ); +} + +/// Internal helper function to traverse a TOML structure and get the value at a +/// specific path. +#[must_use] +pub fn get_value_at_path<'a>(toml: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = toml; + for &key in path { + match current { + Value::Table(table) => { + current = table.get(key)?; + } + Value::Array(arr) => match key.parse::() { + Ok(index) => current = arr.get(index)?, + Err(_) => return None, + }, + _ => return None, + } + } + Some(current) +} diff --git a/loco-new/tests/assertion/yaml.rs b/loco-new/tests/assertion/yaml.rs new file mode 100644 index 000000000..202529b2d --- /dev/null +++ b/loco-new/tests/assertion/yaml.rs @@ -0,0 +1,145 @@ +#![allow(clippy::missing_panics_doc)] +use std::{fs::File, io::BufReader, path::PathBuf}; + +use serde_yaml::Value; + +#[must_use] +pub fn load(path: PathBuf) -> serde_yaml::Value { + let file = File::open(path).expect("could not open file"); + let reader = BufReader::new(file); + serde_yaml::from_reader(reader).expect("invalid yaml content") +} + +pub fn assert_path_value_eq_string(yml: &Value, path: &[&str], expected: &str) { + let expected_value = Value::String(expected.to_string()); + assert_path_value_eq(yml, path, &expected_value); +} + +/// Asserts that the YAML value at the specified path is equal to the expected +/// boolean value. +pub fn assert_path_value_eq_bool(yml: &Value, path: &[&str], expected: bool) { + let expected_value = Value::Bool(expected); + assert_path_value_eq(yml, path, &expected_value); +} + +/// Asserts that the YAML value at the specified path is equal to the expected +/// number value. +pub fn assert_path_value_eq_int(yml: &Value, path: &[&str], expected: i64) { + let expected_value = Value::Number(serde_yaml::Number::from(expected)); + assert_path_value_eq(yml, path, &expected_value); +} + +pub fn assert_path_value_eq_float(yml: &Value, path: &[&str], expected: f64) { + let expected_value = Value::Number(serde_yaml::Number::from(expected)); + assert_path_value_eq(yml, path, &expected_value); +} + +/// Asserts that the YAML mapping at the specified path contains the expected +/// number of keys. +pub fn assert_path_key_count(yml: &Value, path: &[&str], expected_count: usize) { + let actual = get_value_at_path(yml, path).expect("Path not found in YAML structure"); + assert!( + matches!(actual, Value::Mapping(map) if map.len() == expected_count), + "Assertion failed: Path {:?} does not contain the expected number of keys. Expected: {}, \ + Actual: {}", + path, + expected_count, + match actual { + Value::Mapping(map) => map.len(), + _ => 0, + } + ); +} + +/// Assert that a YAML value contains a specific key path and that it matches +/// the expected value. +pub fn assert_path_value_eq(yml: &Value, path: &[&str], expected: &Value) { + let actual = get_value_at_path(yml, path); + assert!( + actual == Some(expected), + "Assertion failed: Path {path:?} does not match expected value. Expected: {expected:?}, \ + Actual: {actual:?}" + ); +} + +// pub fn assert_path_value_eq_mapping(yml: &Value, path: &[&str], expected: +// &serde_yaml::Mapping) { let actual = get_value_at_path(yml, +// path).unwrap(); assert!( +// matches!(actual, Value::Mapping(map) if map == expected), +// "Assertion failed: Path {path:?} does not match expected mapping. +// Expected: {expected:?}, Actual: {actual:?}" ); +// } + +/// Assert that a YAML value contains a specific path, and that the value is an +/// object. +pub fn assert_path_is_object(yml: &Value, path: &[&str]) { + let actual = get_value_at_path(yml, path).unwrap(); + assert!( + matches!(actual, Value::Mapping(_)), + "Assertion failed: Path {path:?} is not an object. Actual value: {actual:?}" + ); +} + +/// Helper function to concatenate keys of a nested mapping to form a string. +#[must_use] +pub fn get_keys_concatenated_as_string(yml: &Value, path: &[&str]) -> Option { + let value_at_path = get_value_at_path(yml, path)?; + if let Value::Mapping(map) = value_at_path { + let mut concatenated_string = String::new(); + for key in map.keys() { + if let Value::String(key_str) = key { + concatenated_string.push_str(key_str); + } + } + Some(concatenated_string) + } else { + None + } +} + +/// Assert that the YAML value at the given path is empty (either an empty +/// object or sequence). +pub fn assert_path_is_empty(yml: &Value, path: &[&str]) { + let actual = get_value_at_path(yml, path); + + assert!( + match actual { + Some(Value::Mapping(map)) => map.is_empty(), + Some(Value::Sequence(seq)) => seq.is_empty(), + Some(Value::Null) | None => true, + _ => { + false + } + }, + "Assertion failed: Path {path:?} is not empty. Actual value: {actual:?}" + ); +} + +pub fn assert_path_value_eq_mapping(yml: &Value, path: &[&str], expected: &serde_yaml::Mapping) { + let actual = get_value_at_path(yml, path).expect("Path not found in YAML structure"); + assert!( + matches!(actual, Value::Mapping(map) if map == expected), + "Assertion failed: Path {path:?} does not match expected mapping. Expected: \ + {expected:#?}, Actual: {actual:#?}" + ); +} + +/// Internal helper function to traverse a YAML structure and get the value at a +/// specific path. +#[must_use] +pub fn get_value_at_path<'a>(yml: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = yml; + for &key in path { + match current { + Value::Mapping(map) => { + current = map.get(Value::String(key.to_string()))?; + } + Value::Sequence(seq) => match key.parse::() { + Ok(index) => current = seq.get(index)?, + Err(_) => return None, + }, + _ => return None, + } + } + Some(current) +} diff --git a/loco-new/tests/mod.rs b/loco-new/tests/mod.rs new file mode 100644 index 000000000..752541769 --- /dev/null +++ b/loco-new/tests/mod.rs @@ -0,0 +1,4 @@ +mod templates; +mod wizard; + +pub mod assertion; diff --git a/loco-new/tests/templates/asset.rs b/loco-new/tests/templates/asset.rs new file mode 100644 index 000000000..03ea18128 --- /dev/null +++ b/loco-new/tests/templates/asset.rs @@ -0,0 +1,94 @@ +use loco::{settings, wizard::AssetsOption}; +use rstest::rstest; + +use super::*; +use crate::assertion; + +pub fn run_generator(asset: AssetsOption) -> TestGenerator { + let settings = settings::Settings { + asset: asset.into(), + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[rstest] +fn test_config_file_middleware_when_asset_empty( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator: TestGenerator = run_generator(AssetsOption::None); + let content = assertion::yaml::load(generator.path(config_file)); + + assertion::yaml::assert_path_is_empty(&content, &["server", "middlewares"]); +} + +#[rstest] +fn test_config_file_middleware_asset_server( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator: TestGenerator = run_generator(AssetsOption::Serverside); + let content = assertion::yaml::load(generator.path(config_file)); + + assertion::yaml::assert_path_is_object(&content, &["server", "middlewares", "static"]); + + let expected: serde_yaml::Value = serde_yaml::from_str( + r" +enable: true +must_exist: true +precompressed: false +folder: + uri: /static + path: assets/static +fallback: assets/static/404.html +", + ) + .unwrap(); + assertion::yaml::assert_path_value_eq( + &content, + &["server", "middlewares", "static"], + &expected, + ); +} + +#[rstest] +fn test_config_file_middleware_asset_client( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator: TestGenerator = run_generator(AssetsOption::Clientside); + let content = assertion::yaml::load(generator.path(config_file)); + + assertion::yaml::assert_path_is_object(&content, &["server", "middlewares", "static"]); + + let expected: serde_yaml::Value = serde_yaml::from_str( + r" +enable: true +must_exist: true +precompressed: false +folder: + uri: / + path: frontend/dist +fallback: frontend/dist/index.html +", + ) + .unwrap(); + assertion::yaml::assert_path_value_eq( + &content, + &["server", "middlewares", "static"], + &expected, + ); +} + +#[rstest] +fn test_cargo_toml( + #[values(AssetsOption::None, AssetsOption::Serverside, AssetsOption::Clientside)] + asset: AssetsOption, +) { + let generator = run_generator(asset.clone()); + let content = assertion::toml::load(generator.path("Cargo.toml")); + + insta::assert_snapshot!( + format!("cargo_dependencies_{:?}", asset), + content.get("dependencies").unwrap() + ); +} diff --git a/loco-new/tests/templates/auth.rs b/loco-new/tests/templates/auth.rs new file mode 100644 index 000000000..8da273360 --- /dev/null +++ b/loco-new/tests/templates/auth.rs @@ -0,0 +1,106 @@ +use super::*; + +use crate::assertion; +use loco::settings; +use rstest::rstest; + +pub fn run_generator(enable_auth: bool) -> TestGenerator { + let settings = settings::Settings { + package_name: "loco-app-test".to_string(), + module_name: "loco_app_test".to_string(), + auth: enable_auth, + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[rstest] +fn test_config_file_without_auth( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator = run_generator(false); + let content = assertion::yaml::load(generator.path(config_file)); + assertion::yaml::assert_path_is_empty(&content, &["auth"]); +} + +#[rstest] +fn test_config_file_with_auth( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator = run_generator(true); + let content = assertion::yaml::load(generator.path(config_file)); + assertion::yaml::assert_path_key_count(&content, &["auth"], 1); + + assertion::yaml::assert_path_key_count(&content, &["auth", "jwt"], 2); +} + +#[test] +fn test_config_file_development_rand_secret() { + let generator = run_generator(true); + let content = assertion::yaml::load(generator.path("config/development.yaml")); + assertion::yaml::assert_path_value_eq_string( + &content, + &["auth", "jwt", "secret"], + "IhPi3oZCnaWvL2oIeA07", + ); +} + +#[test] +fn test_config_file_test_rand_secret() { + let generator = run_generator(true); + let content = assertion::yaml::load(generator.path("config/test.yaml")); + assertion::yaml::assert_path_value_eq_string( + &content, + &["auth", "jwt", "secret"], + "mg3ZtJzh0NoAKhdDqpQ2", + ); +} + +#[rstest] +fn test_app_rs(#[values(true, false)] auth: bool) { + let generator = run_generator(auth); + insta::assert_snapshot!( + format!("src_app_rs_auth_{:?}", auth), + std::fs::read_to_string(generator.path("src/app.rs")).expect("could not open file") + ); +} + +#[rstest] +fn test_src_controllers_mod_rs(#[values(true, false)] auth: bool) { + let generator = run_generator(auth); + let content = std::fs::read_to_string(generator.path("src/controllers/mod.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^pub mod auth;$"); + } else { + assertion::string::assert_line_regex(&content, "(?m)^pub mod home;$"); + } +} + +#[rstest] +fn test_src_views_mod_rs(#[values(true, false)] auth: bool) { + let generator = run_generator(auth); + let content = + std::fs::read_to_string(generator.path("src/views/mod.rs")).expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^pub mod auth;$"); + } else { + assertion::string::assert_line_regex(&content, "(?m)^pub mod home;$"); + } +} +#[rstest] +fn test_tests_requests_mod_rs(#[values(true, false)] auth: bool) { + let generator = run_generator(auth); + let content = std::fs::read_to_string(generator.path("tests/requests/mod.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^mod auth;$"); + assertion::string::assert_line_regex(&content, "(?m)^mod prepare_data;$"); + } else { + assertion::string::assert_line_regex(&content, "(?m)^mod home;$"); + } +} diff --git a/loco-new/tests/templates/background.rs b/loco-new/tests/templates/background.rs new file mode 100644 index 000000000..adcab5cba --- /dev/null +++ b/loco-new/tests/templates/background.rs @@ -0,0 +1,166 @@ +use loco::{settings, wizard::BackgroundOption}; +use rstest::rstest; + +use super::*; +use crate::assertion; + +pub fn run_generator(background: BackgroundOption) -> TestGenerator { + let settings = settings::Settings { + background: background.into(), + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[rstest] +fn test_config_file_queue( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, + #[values( + BackgroundOption::None, + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking + )] + background: BackgroundOption, +) { + let generator = run_generator(background.clone()); + let content = assertion::yaml::load(generator.path(config_file)); + + if background == BackgroundOption::Queue { + assertion::yaml::assert_path_is_object(&content, &["queue"]); + assertion::yaml::assert_path_key_count(&content, &["queue"], 3); + assertion::yaml::assert_path_value_eq_string(&content, &["queue", "kind"], "Redis"); + assertion::yaml::assert_path_value_eq_bool( + &content, + &["queue", "dangerously_flush"], + false, + ); + + let mut inner_uri = serde_yaml::Mapping::new(); + inner_uri.insert( + serde_yaml::Value::String("get_env(name=\"REDIS_URL\"".to_string()), + serde_yaml::Value::Null, + ); + inner_uri.insert( + serde_yaml::Value::String("default=\"redis://127.0.0.1\")".to_string()), + serde_yaml::Value::Null, + ); + let mut uri = serde_yaml::Mapping::new(); + uri.insert( + serde_yaml::Value::Mapping(inner_uri), + serde_yaml::Value::Null, + ); + + assertion::yaml::assert_path_value_eq_mapping(&content, &["queue", "uri"], &uri); + } else { + assertion::yaml::assert_path_is_empty(&content, &["queue"]); + } +} + +#[rstest] +fn test_config_file_workers( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, + #[values( + BackgroundOption::None, + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking + )] + background: BackgroundOption, +) { + let generator = run_generator(background.clone()); + let content = assertion::yaml::load(generator.path(config_file)); + + match background { + BackgroundOption::Async => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "BackgroundAsync", + ); + } + BackgroundOption::Queue => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "BackgroundQueue", + ); + } + BackgroundOption::Blocking => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "ForegroundBlocking", + ); + } + BackgroundOption::None => { + assertion::yaml::assert_path_is_empty(&content, &["workers"]); + } + }; + + if background.enable() { + assertion::yaml::assert_path_key_count(&content, &["workers"], 1); + } +} + +#[rstest] +fn test_app_rs( + #[values( + BackgroundOption::None, + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking + )] + background: BackgroundOption, +) { + let generator = run_generator(background.clone()); + insta::assert_snapshot!( + format!("src_app_rs_{:?}", background), + std::fs::read_to_string(generator.path("src/app.rs")).expect("could not open file") + ); +} + +#[rstest] +fn test_src_lib_rs( + #[values( + BackgroundOption::None, + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking + )] + background: BackgroundOption, +) { + let generator = run_generator(background.clone()); + + let content = + std::fs::read_to_string(generator.path("src/lib.rs")).expect("could not open file"); + + if background.enable() { + assertion::string::assert_line_regex(&content, "(?m)^pub mod workers;$"); + } else { + assertion::string::assert_str_not_exists(&content, "pub mod workers;"); + } +} + +#[rstest] +fn test_tests_mod_rs( + #[values( + BackgroundOption::None, + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking + )] + background: BackgroundOption, +) { + let generator = run_generator(background.clone()); + + let content = + std::fs::read_to_string(generator.path("tests/mod.rs")).expect("could not open file"); + + if background.enable() { + assertion::string::assert_line_regex(&content, "(?m)^mod workers;$"); + } else { + assertion::string::assert_str_not_exists(&content, "mod workers;"); + } +} diff --git a/loco-new/tests/templates/db.rs b/loco-new/tests/templates/db.rs new file mode 100644 index 000000000..6fc166407 --- /dev/null +++ b/loco-new/tests/templates/db.rs @@ -0,0 +1,166 @@ +use loco::{settings, wizard::DBOption}; +use rstest::rstest; + +use super::*; +use crate::assertion; + +pub fn run_generator(db: DBOption) -> TestGenerator { + let settings = settings::Settings { + package_name: "loco-app-test".to_string(), + module_name: "loco_app_test".to_string(), + db: db.into(), + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[rstest] +fn test_config_file_no_db( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator = run_generator(DBOption::None); + let content = assertion::yaml::load(generator.path(config_file)); + assertion::yaml::assert_path_is_empty(&content, &["database"]); +} + +#[rstest] +fn test_config_with_sqlite( + #[values(DBOption::Sqlite, DBOption::Postgres)] db: DBOption, + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator = run_generator(db.clone()); + let content = assertion::yaml::load(generator.path(config_file)); + + insta::assert_snapshot!( + format!( + "{}_config_database_{:?}", + config_file.replace(['/', '.'], "_"), + db + ), + format!( + "{:#?}", + assertion::yaml::get_value_at_path(&content, &["database"]).unwrap() + ) + ); +} + +#[rstest] +fn test_cargo_toml(#[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption) { + let generator = run_generator(db.clone()); + let content = assertion::toml::load(generator.path("Cargo.toml")); + + insta::assert_snapshot!( + format!("cargo_dependencies_{:?}", db), + content.get("dependencies").unwrap() + ); +} + +#[rstest] +fn test_app_rs(#[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption) { + let generator = run_generator(db.clone()); + insta::assert_snapshot!( + format!("src_app_rs_{:?}", db), + std::fs::read_to_string(generator.path("src/app.rs")).expect("could not open file") + ); +} + +#[rstest] +fn test_src_lib_rs(#[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption) { + let generator = run_generator(db.clone()); + + let content = + std::fs::read_to_string(generator.path("src/lib.rs")).expect("could not open file"); + + if db.enable() { + assertion::string::assert_line_regex(&content, "(?m)^pub mod models;$"); + } else { + assertion::string::assert_str_not_exists(&content, "pub mod models;"); + } +} + +#[rstest] +fn test_src_bin_main_rs( + #[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption, +) { + let generator = run_generator(db.clone()); + + let content = + std::fs::read_to_string(generator.path("src/bin/main.rs")).expect("could not open file"); + + if db.enable() { + assertion::string::assert_line_regex(&content, "(?m)^use migration::Migrator;$"); + assertion::string::assert_line_regex( + &content, + r"(?m)^ cli::main::\(\).await$", + ); + } else { + assertion::string::assert_str_not_exists(&content, "(?m)^use migration::Migrator;$"); + assertion::string::assert_line_regex(&content, r"(?m)^ cli::main::\(\).await"); + } +} + +#[rstest] +fn test_src_bin_tool_rs( + #[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption, +) { + let generator = run_generator(db.clone()); + + let content = + std::fs::read_to_string(generator.path("src/bin/tool.rs")).expect("could not open file"); + + if db.enable() { + assertion::string::assert_line_regex(&content, "(?m)^use migration::Migrator;$"); + assertion::string::assert_line_regex( + &content, + r"(?m)^ cli::main::\(\).await$", + ); + } else { + assertion::string::assert_str_not_exists(&content, "(?m)^use migration::Migrator;$"); + assertion::string::assert_line_regex(&content, r"(?m)^ cli::main::\(\).await"); + } +} + +#[rstest] +fn test_tasks_mod_rs(#[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption) { + let generator = run_generator(db.clone()); + + let content = + std::fs::read_to_string(generator.path("src/tasks/mod.rs")).expect("could not open file"); + + if db.enable() { + assertion::string::assert_line_regex(&content, "(?m)^pub mod seed;$"); + } else { + assertion::string::assert_str_not_exists(&content, "pub mod seed"); + } +} + +#[rstest] +fn test_tests_mod_rs(#[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption) { + let generator = run_generator(db.clone()); + + let content = + std::fs::read_to_string(generator.path("tests/mod.rs")).expect("could not open file"); + + if db.enable() { + assertion::string::assert_line_regex(&content, "(?m)^mod models;$"); + } else { + assertion::string::assert_str_not_exists(&content, "mod models;"); + } +} + +#[rstest] +fn test_tests_tasks_mod_rs( + #[values(DBOption::None, DBOption::Sqlite, DBOption::Postgres)] db: DBOption, +) { + let generator = run_generator(db.clone()); + + let content = + std::fs::read_to_string(generator.path("tests/tasks/mod.rs")).expect("could not open file"); + + if db.enable() { + assertion::string::assert_line_regex(&content, "(?m)^pub mod seed;$"); + } else { + assertion::string::assert_str_not_exists(&content, "pub mod seed"); + } +} diff --git a/loco-new/tests/templates/features.rs b/loco-new/tests/templates/features.rs new file mode 100644 index 000000000..044f4ecb6 --- /dev/null +++ b/loco-new/tests/templates/features.rs @@ -0,0 +1,59 @@ +use super::*; + +use crate::assertion; +use loco::settings; + +pub fn run_generator(default_features: bool, names: &[&str]) -> TestGenerator { + let settings = settings::Settings { + features: settings::Features { + default_features, + names: names.iter().map(std::string::ToString::to_string).collect(), + }, + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[test] +fn test_cargo_toml_with_default_features_and_empty_names() { + let generator = run_generator(true, &[]); + let content = assertion::toml::load(generator.path("Cargo.toml")); + assertion::toml::assert_path_exists(&content, &["workspace", "dependencies", "loco-rs"]); + assertion::toml::assert_path_is_empty( + &content, + &["workspace", "dependencies", "loco-rs", "default-features"], + ); +} + +#[test] +fn test_cargo_toml_without_default_features_and_empty_names() { + let generator = run_generator(false, &[]); + let content = assertion::toml::load(generator.path("Cargo.toml")); + assertion::toml::eq_path_value_eq_bool( + &content, + &["workspace", "dependencies", "loco-rs", "default-features"], + false, + ); +} + +#[test] +fn test_cargo_toml_with_features() { + let generator = run_generator(false, &["foo", "bar"]); + let content = assertion::toml::load(generator.path("Cargo.toml")); + assertion::toml::assert_path_value_eq_array( + &content, + &["dependencies", "loco-rs", "features"], + &[ + toml::Value::String("foo".to_string()), + toml::Value::String("bar".to_string()), + ], + ); +} + +#[test] +fn test_cargo_toml_without_features() { + let generator = run_generator(false, &[]); + let content = assertion::toml::load(generator.path("Cargo.toml")); + assertion::toml::assert_path_is_empty(&content, &["dependencies", "loco-rs", "features"]); +} diff --git a/loco-new/tests/templates/initializers.rs b/loco-new/tests/templates/initializers.rs new file mode 100644 index 000000000..6a08c51a6 --- /dev/null +++ b/loco-new/tests/templates/initializers.rs @@ -0,0 +1,45 @@ +use super::*; + +use crate::assertion; +use loco::settings; +use rstest::rstest; + +pub fn run_generator(initializers: Option) -> TestGenerator { + let settings = settings::Settings { + initializers, + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[test] +fn test_app_rs_with_initializers() { + let generator = run_generator(Some(settings::Initializers { view_engine: true })); + insta::assert_snapshot!( + "src_app_rs_without_initializers", + std::fs::read_to_string(generator.path("src/app.rs")).expect("could not open file") + ); +} + +#[test] +fn test_app_rs_without_view_engine() { + let generator = run_generator(None); + insta::assert_snapshot!( + "src_app_rs_with_initializers", + std::fs::read_to_string(generator.path("src/app.rs")).expect("could not open file") + ); +} + +#[rstest] +fn test_src_initializers_mod_rs_view_engine(#[values(true, false)] view_engine: bool) { + let generator = run_generator(Some(settings::Initializers { view_engine })); + + let content = std::fs::read_to_string(generator.path("src/initializers/mod.rs")) + .expect("could not open file"); + if view_engine { + assertion::string::assert_line_regex(&content, "(?m)^pub mod view_engine;$"); + } else { + assertion::string::assert_str_not_exists(&content, "pub mod view_engine"); + } +} diff --git a/loco-new/tests/templates/mailer.rs b/loco-new/tests/templates/mailer.rs new file mode 100644 index 000000000..3f8d51b98 --- /dev/null +++ b/loco-new/tests/templates/mailer.rs @@ -0,0 +1,66 @@ +use super::*; + +use crate::assertion; +use loco::settings; +use rstest::rstest; + +pub fn run_generator(enable_mailer: bool) -> TestGenerator { + let settings = settings::Settings { + mailer: enable_mailer, + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[rstest] +fn test_config_file_without_mailer( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator = run_generator(false); + let content = assertion::yaml::load(generator.path(config_file)); + assertion::yaml::assert_path_is_empty(&content, &["mailer"]); +} + +#[rstest] +fn test_config_file_with_mailer( + #[values("config/development.yaml", "config/test.yaml")] config_file: &str, +) { + let generator = run_generator(true); + let content = assertion::yaml::load(generator.path(config_file)); + assertion::yaml::assert_path_key_count(&content, &["mailer"], 1); + assertion::yaml::assert_path_key_count(&content, &["mailer", "smtp"], 4); + assertion::yaml::assert_path_value_eq_bool(&content, &["mailer", "smtp", "enable"], true); + assertion::yaml::assert_path_value_eq_int(&content, &["mailer", "smtp", "port"], 1025); + assertion::yaml::assert_path_value_eq_bool(&content, &["mailer", "smtp", "secure"], false); + assertion::yaml::assert_path_value_eq_string( + &content, + &["mailer", "smtp", "host"], + "localhost", + ); +} + +#[rstest] +fn test_cargo_toml(#[values(true, false)] mailer: bool) { + let generator = run_generator(mailer); + let content = assertion::toml::load(generator.path("Cargo.toml")); + + insta::assert_snapshot!( + format!("cargo_dependencies_mailer_{:?}", mailer), + content.get("dependencies").unwrap() + ); +} + +#[rstest] +fn test_src_lib_rs(#[values(true, false)] mailer: bool) { + let generator = run_generator(mailer); + + let content = + std::fs::read_to_string(generator.path("src/lib.rs")).expect("could not open file"); + + if mailer { + assertion::string::assert_line_regex(&content, "(?m)^pub mod mailers;$"); + } else { + assertion::string::assert_str_not_exists(&content, "pub mod mailers;;"); + } +} diff --git a/loco-new/tests/templates/mod.rs b/loco-new/tests/templates/mod.rs new file mode 100644 index 000000000..c09b00408 --- /dev/null +++ b/loco-new/tests/templates/mod.rs @@ -0,0 +1,49 @@ +use loco::{ + generator::{self, executer::FileSystem, template}, + settings, +}; +use rand::{rngs::StdRng, SeedableRng}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +mod asset; +mod auth; +mod background; +mod db; +mod features; +mod initializers; +mod mailer; +mod module_name; + +pub struct TestGenerator { + tree: tree_fs::Tree, +} + +impl TestGenerator { + pub fn generate(settings: settings::Settings) -> Self { + let tree = tree_fs::TreeBuilder::default() + .drop(true) + .create() + .expect("create tree fs"); + + let template_engine = template::Template::new(StdRng::seed_from_u64(42)); + + let fs: FileSystem = FileSystem::with_template_engine( + Path::new("base_template"), + tree.root.as_path(), + template_engine, + ); + + generator::Generator::new(Arc::new(fs), settings) + .run() + .expect("run generate"); + + Self { tree } + } + + pub fn path(&self, path: &str) -> PathBuf { + self.tree.root.join(path) + } +} diff --git a/loco-new/tests/templates/module_name.rs b/loco-new/tests/templates/module_name.rs new file mode 100644 index 000000000..2b015617c --- /dev/null +++ b/loco-new/tests/templates/module_name.rs @@ -0,0 +1,72 @@ +use loco::{settings, wizard::DBOption}; +use rstest::rstest; + +use super::*; +use crate::assertion; + +pub fn run_generator() -> TestGenerator { + let settings = settings::Settings { + package_name: "loco-app-test".to_string(), + module_name: "loco_app_test".to_string(), + ..Default::default() + }; + + TestGenerator::generate(settings) +} + +#[test] +fn test_cargo_toml() { + let generator = run_generator(); + + let content = assertion::toml::load(generator.path("Cargo.toml")); + + assertion::toml::assert_path_value_eq_string(&content, &["package", "name"], "loco-app-test"); + assertion::toml::assert_path_value_eq_string( + &content, + &["package", "default-run"], + "loco_app_test-cli", + ); + + let bin = content + .get("bin") + .expect("bin") + .get(0) + .expect("get first bin"); + assertion::toml::assert_path_value_eq_string(bin, &["name"], "loco_app_test-cli"); +} + +#[rstest] +fn test_use_name( + #[values("src/bin/main.rs", "src/bin/tool.rs", "tests/requests/home.rs")] file: &str, +) { + let generator = run_generator(); + + let content = std::fs::read_to_string(generator.path(file)).expect("could not open file"); + + assertion::string::assert_line_regex(&content, "(?m)^use loco_app_test::"); +} + +#[rstest] +fn test_use_name_with_db( + #[values( + "tests/models/users.rs", + "tests/requests/prepare_data.rs", + "tests/tasks/seed.rs" + )] + file: &str, +) { + let generator = super::db::run_generator(DBOption::Sqlite); + + let content = std::fs::read_to_string(generator.path(file)).expect("could not open file"); + + assertion::string::assert_line_regex(&content, "(?m)^use loco_app_test::"); +} + +#[rstest] +fn test_use_name_with_auth(#[values("tests/requests/auth.rs")] file: &str) { + let generator = super::auth::run_generator(true); + + let content = std::fs::read_to_string(generator.path(file)).expect("could not open file"); + + assertion::string::assert_line_regex(&content, "(?m)^use loco_app_test::"); +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap new file mode 100644 index 000000000..fbfcb3e2f --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/asset.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", unic-langid = "0.9.4", fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap new file mode 100644 index 000000000..567b40792 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/asset.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap new file mode 100644 index 000000000..fbfcb3e2f --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/asset.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", unic-langid = "0.9.4", fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap new file mode 100644 index 000000000..e2bd75359 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap @@ -0,0 +1,55 @@ +--- +source: loco-new/tests/templates/auth.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap new file mode 100644 index 000000000..04fb5b701 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap @@ -0,0 +1,55 @@ +--- +source: loco-new/tests/templates/auth.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::auth::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap new file mode 100644 index 000000000..c25b37010 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap @@ -0,0 +1,58 @@ +--- +source: loco-new/tests/templates/background.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + BackgroundWorker, + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers + , workers::downloader::DownloadWorker, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { + queue.register(DownloadWorker::build(ctx)).await?; + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap new file mode 100644 index 000000000..c25b37010 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap @@ -0,0 +1,58 @@ +--- +source: loco-new/tests/templates/background.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + BackgroundWorker, + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers + , workers::downloader::DownloadWorker, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { + queue.register(DownloadWorker::build(ctx)).await?; + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap new file mode 100644 index 000000000..0cc81dc7f --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap @@ -0,0 +1,55 @@ +--- +source: loco-new/tests/templates/background.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap new file mode 100644 index 000000000..c25b37010 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap @@ -0,0 +1,58 @@ +--- +source: loco-new/tests/templates/background.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + BackgroundWorker, + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers + , workers::downloader::DownloadWorker, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { + queue.register(DownloadWorker::build(ctx)).await?; + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap new file mode 100644 index 000000000..9f6247be3 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap new file mode 100644 index 000000000..1049a56a8 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", chrono = "0.4", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, migration = { path = "migration" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.18" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap new file mode 100644 index 000000000..1049a56a8 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", chrono = "0.4", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, migration = { path = "migration" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.18" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_development_yaml_config_database_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_development_yaml_config_database_Postgres.snap new file mode 100644 index 000000000..19250776b --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_development_yaml_config_database_Postgres.snap @@ -0,0 +1,40 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "format!(\"{:#?}\",\n assertion::yaml::get_value_at_path(&content, &[\"database\"]).unwrap())" +--- +Mapping { + "uri": Mapping { + Mapping { + "get_env(name=\"DATABASE_URL\"": Null, + "default=\"postgres://loco:loco@localhost:5432/loco_app\")": Null, + }: Null, + }, + "enable_logging": Bool(false), + "connect_timeout": Mapping { + Mapping { + "get_env(name=\"DB_CONNECT_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "idle_timeout": Mapping { + Mapping { + "get_env(name=\"DB_IDLE_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "min_connections": Mapping { + Mapping { + "get_env(name=\"DB_MIN_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "max_connections": Mapping { + Mapping { + "get_env(name=\"DB_MAX_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "auto_migrate": Bool(true), + "dangerously_truncate": Bool(false), + "dangerously_recreate": Bool(false), +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_development_yaml_config_database_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_development_yaml_config_database_Sqlite.snap new file mode 100644 index 000000000..96f4bc240 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_development_yaml_config_database_Sqlite.snap @@ -0,0 +1,40 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "format!(\"{:#?}\",\n assertion::yaml::get_value_at_path(&content, &[\"database\"]).unwrap())" +--- +Mapping { + "uri": Mapping { + Mapping { + "get_env(name=\"DATABASE_URL\"": Null, + "default=\"sqlite://loco_app.sqlite?mode=rwc\")": Null, + }: Null, + }, + "enable_logging": Bool(false), + "connect_timeout": Mapping { + Mapping { + "get_env(name=\"DB_CONNECT_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "idle_timeout": Mapping { + Mapping { + "get_env(name=\"DB_IDLE_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "min_connections": Mapping { + Mapping { + "get_env(name=\"DB_MIN_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "max_connections": Mapping { + Mapping { + "get_env(name=\"DB_MAX_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "auto_migrate": Bool(true), + "dangerously_truncate": Bool(false), + "dangerously_recreate": Bool(false), +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Postgres.snap new file mode 100644 index 000000000..757ac0205 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Postgres.snap @@ -0,0 +1,40 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "format!(\"{:#?}\",\n assertion::yaml::get_value_at_path(&content, &[\"database\"]).unwrap())" +--- +Mapping { + "uri": Mapping { + Mapping { + "get_env(name=\"DATABASE_URL\"": Null, + "default=\"postgres://loco:loco@localhost:5432/loco_app\")": Null, + }: Null, + }, + "enable_logging": Bool(false), + "connect_timeout": Mapping { + Mapping { + "get_env(name=\"DB_CONNECT_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "idle_timeout": Mapping { + Mapping { + "get_env(name=\"DB_IDLE_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "min_connections": Mapping { + Mapping { + "get_env(name=\"DB_MIN_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "max_connections": Mapping { + Mapping { + "get_env(name=\"DB_MAX_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "auto_migrate": Bool(true), + "dangerously_truncate": Bool(true), + "dangerously_recreate": Bool(false), +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Sqlite.snap new file mode 100644 index 000000000..117d7e173 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Sqlite.snap @@ -0,0 +1,40 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "format!(\"{:#?}\",\n assertion::yaml::get_value_at_path(&content, &[\"database\"]).unwrap())" +--- +Mapping { + "uri": Mapping { + Mapping { + "get_env(name=\"DATABASE_URL\"": Null, + "default=\"sqlite://loco_app.sqlite?mode=rwc\")": Null, + }: Null, + }, + "enable_logging": Bool(false), + "connect_timeout": Mapping { + Mapping { + "get_env(name=\"DB_CONNECT_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "idle_timeout": Mapping { + Mapping { + "get_env(name=\"DB_IDLE_TIMEOUT\"": Null, + "default=\"500\")": Null, + }: Null, + }, + "min_connections": Mapping { + Mapping { + "get_env(name=\"DB_MIN_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "max_connections": Mapping { + Mapping { + "get_env(name=\"DB_MAX_CONNECTIONS\"": Null, + "default=\"1\")": Null, + }: Null, + }, + "auto_migrate": Bool(true), + "dangerously_truncate": Bool(true), + "dangerously_recreate": Bool(false), +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap new file mode 100644 index 000000000..8a600e650 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap @@ -0,0 +1,55 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap new file mode 100644 index 000000000..b28d56da5 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap @@ -0,0 +1,72 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use std::path::Path; +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + db::{self, truncate_table}, + environment::Environment, + task::Tasks, + Result, +}; +use migration::Migrator; +use sea_orm::DatabaseConnection; + +use crate::{ + controllers + ,tasks + , models::_entities::users, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(tasks: &mut Tasks) { + tasks.register(tasks::seed::SeedData); + } + async fn truncate(db: &DatabaseConnection) -> Result<()> { + truncate_table(db, users::Entity).await?; + Ok(()) + } + + async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { + db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + Ok(()) + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap new file mode 100644 index 000000000..b28d56da5 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap @@ -0,0 +1,72 @@ +--- +source: loco-new/tests/templates/db.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use std::path::Path; +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + db::{self, truncate_table}, + environment::Environment, + task::Tasks, + Result, +}; +use migration::Migrator; +use sea_orm::DatabaseConnection; + +use crate::{ + controllers + ,tasks + , models::_entities::users, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(tasks: &mut Tasks) { + tasks.register(tasks::seed::SeedData); + } + async fn truncate(db: &DatabaseConnection) -> Result<()> { + truncate_table(db, users::Entity).await?; + Ok(()) + } + + async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { + db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + Ok(()) + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap new file mode 100644 index 000000000..0c24a44cd --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap @@ -0,0 +1,55 @@ +--- +source: loco-new/tests/templates/initializers.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap new file mode 100644 index 000000000..e97f3c0a1 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap @@ -0,0 +1,55 @@ +--- +source: loco-new/tests/templates/initializers.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; + +use crate::{ + controllers, initializers, +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![Box::new(initializers::view_engine::ViewEngineInitializer)]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + fn register_tasks(_tasks: &mut Tasks) { + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap new file mode 100644 index 000000000..650bcff34 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/mailer.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap new file mode 100644 index 000000000..5e1a0f8d3 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap @@ -0,0 +1,5 @@ +--- +source: loco-new/tests/templates/mailer.rs +expression: "content.get(\"dependencies\").unwrap()" +--- +{ async-trait = "0.1.74", axum = "0.7.5", include_dir = "0.7", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/wizard/mod.rs b/loco-new/tests/wizard/mod.rs new file mode 100644 index 000000000..b8c25d565 --- /dev/null +++ b/loco-new/tests/wizard/mod.rs @@ -0,0 +1 @@ +mod new; diff --git a/loco-new/tests/wizard/new.rs b/loco-new/tests/wizard/new.rs new file mode 100644 index 000000000..4cce5d6aa --- /dev/null +++ b/loco-new/tests/wizard/new.rs @@ -0,0 +1,118 @@ +use std::{fs, path::PathBuf, sync::Arc}; + +use duct::cmd; +use loco::{ + generator::{executer::FileSystem, Generator}, + settings, wizard, + wizard::{AssetsOption, BackgroundOption, DBOption}, +}; +use uuid::Uuid; + +struct TestDir { + pub path: PathBuf, +} + +impl TestDir { + fn new() -> Self { + let path = std::env::temp_dir() + .join("loco-test-generator") + .join(Uuid::new_v4().to_string()); + + fs::create_dir_all(&path).unwrap(); + Self { path } + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[cfg(feature = "test-wizard")] +#[rstest::rstest] +fn test_all_combinations( + #[values(DBOption::None, DBOption::Sqlite)] db: DBOption, + #[values( + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking, + BackgroundOption::None + )] + background: BackgroundOption, + #[values(AssetsOption::Serverside, AssetsOption::Clientside, AssetsOption::None)] + asset: AssetsOption, +) { + test_combination(db, background, asset); +} + +#[test] +fn test_starter_combinations() { + // lightweight service + test_combination(DBOption::None, BackgroundOption::None, AssetsOption::None); + // REST API + test_combination( + DBOption::Sqlite, + BackgroundOption::Async, + AssetsOption::None, + ); + // SaaS, serverside + test_combination( + DBOption::Sqlite, + BackgroundOption::Async, + AssetsOption::Serverside, + ); + // SaaS, clientside + test_combination( + DBOption::Sqlite, + BackgroundOption::Async, + AssetsOption::Clientside, + ); +} + +fn test_combination(db: DBOption, background: BackgroundOption, asset: AssetsOption) { + use std::collections::HashMap; + + let test_dir = TestDir::new(); + + let executor = FileSystem::new(&PathBuf::from("base_template"), &test_dir.path); + + let wizard_selection = wizard::Selections { + db, + background, + asset, + }; + let settings = settings::Settings::from_wizard("test-loco-template", &wizard_selection); + + let res = Generator::new(Arc::new(executor), settings).run(); + assert!(res.is_ok()); + + let mut env_map: HashMap<_, _> = std::env::vars().collect(); + env_map.insert("RUSTFLAGS".into(), "-D warnings".into()); + assert!(cmd!( + "cargo", + "clippy", + "--quiet", + "--", + "-W", + "clippy::pedantic", + "-W", + "clippy::nursery", + "-W", + "rust-2018-idioms" + ) + .full_env(&env_map) + // .stdout_null() + // .stderr_null() + .dir(test_dir.path.as_path()) + .run() + .is_ok()); + + cmd!("cargo", "test") + // .stdout_null() + // .stderr_null() + .full_env(&env_map) + .dir(test_dir.path.as_path()) + .run() + .expect("run test"); +} diff --git a/snipdoc.yml b/snipdoc.yml index 4e7b62c7d..4d26d1fea 100644 --- a/snipdoc.yml +++ b/snipdoc.yml @@ -13,7 +13,7 @@ snippets: path: ./snipdoc.yml quick-installation-command: content: |- - cargo install loco-cli + cargo install loco cargo install sea-orm-cli # Only when DB is needed path: ./snipdoc.yml loco-cli-new-from-template: diff --git a/src/bgworker/mod.rs b/src/bgworker/mod.rs index 85b6edfc3..c5a765eda 100644 --- a/src/bgworker/mod.rs +++ b/src/bgworker/mod.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use async_trait::async_trait; use serde::Serialize; -use tokio_util::sync::CancellationToken; use tracing::{debug, error}; #[cfg(feature = "bg_pg")] pub mod pg; @@ -26,7 +25,7 @@ pub enum Queue { Redis( bb8::Pool, Arc>, - CancellationToken, + tokio_util::sync::CancellationToken, ), #[cfg(feature = "bg_pg")] Postgres( @@ -49,6 +48,7 @@ impl Queue { /// # Errors /// /// This function will return an error if fails + #[allow(unused_variables)] pub async fn enqueue( &self, class: String, @@ -95,6 +95,7 @@ impl Queue { /// # Errors /// /// This function will return an error if fails + #[allow(unused_variables)] pub async fn register< A: Serialize + Send + Sync + 'static + for<'de> serde::Deserialize<'de>, W: BackgroundWorker + 'static, @@ -250,8 +251,8 @@ impl Queue { /// # Errors /// - /// Does not currently return an error, but the postgres or other future queue implementations - /// might, so using Result here as return type. + /// Does not currently return an error, but the postgres or other future + /// queue implementations might, so using Result here as return type. pub fn shutdown(&self) -> Result<()> { println!("waiting for running jobs to finish..."); match self { diff --git a/src/doctor.rs b/src/doctor.rs index 67707992f..a25452a53 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -10,16 +10,14 @@ use semver::Version; use crate::{ bgworker, - config::{self, Config, Database}, - db, depcheck, Error, Result, + config::{self, Config}, + depcheck, Error, Result, }; const SEAORM_INSTALLED: &str = "SeaORM CLI is installed"; const SEAORM_NOT_INSTALLED: &str = "SeaORM CLI was not found"; const SEAORM_NOT_FIX: &str = r"To fix, run: $ cargo install sea-orm-cli"; -const DB_CONNECTION_FAILED: &str = "DB connection: fails"; -const DB_CONNECTION_SUCCESS: &str = "DB connection: success"; const QUEUE_CONN_OK: &str = "queue connection: success"; const QUEUE_CONN_FAILED: &str = "queue connection: failed"; const QUEUE_NOT_CONFIGURED: &str = "queue not configured?"; @@ -117,7 +115,12 @@ impl std::fmt::Display for Check { /// # Errors /// Error when one of the checks fail pub async fn run_all(config: &Config, production: bool) -> Result> { - let mut checks = BTreeMap::from([(Resource::Database, check_db(&config.database).await)]); + let mut checks = BTreeMap::from( + #[cfg(feature = "with-db")] + [(Resource::Database, check_db(&config.database).await)], + #[cfg(not(feature = "with-db"))] + [], + ); if config.workers.mode == config::WorkerMode::BackgroundQueue { checks.insert(Resource::Queue, check_queue(config).await); @@ -172,30 +175,33 @@ pub fn check_deps() -> Result { } /// Checks the database connection. -pub async fn check_db(config: &Database) -> Check { - match db::connect(config).await { +#[cfg(feature = "with-db")] +pub async fn check_db(config: &crate::config::Database) -> Check { + let db_connection_failed = "DB connection: fails"; + let db_connection_success = "DB connection: success"; + match crate::db::connect(config).await { Ok(conn) => match conn.ping().await { - Ok(()) => match db::verify_access(&conn).await { + Ok(()) => match crate::db::verify_access(&conn).await { Ok(()) => Check { status: CheckStatus::Ok, - message: DB_CONNECTION_SUCCESS.to_string(), + message: db_connection_success.to_string(), description: None, }, Err(err) => Check { status: CheckStatus::NotOk, - message: DB_CONNECTION_FAILED.to_string(), + message: db_connection_failed.to_string(), description: Some(err.to_string()), }, }, Err(err) => Check { status: CheckStatus::NotOk, - message: DB_CONNECTION_FAILED.to_string(), + message: db_connection_failed.to_string(), description: Some(err.to_string()), }, }, Err(err) => Check { status: CheckStatus::NotOk, - message: DB_CONNECTION_FAILED.to_string(), + message: db_connection_failed.to_string(), description: Some(err.to_string()), }, } diff --git a/src/lib.rs b/src/lib.rs index 40c1da358..a7b20816d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,6 @@ mod depcheck; pub mod initializers; pub mod prelude; -#[cfg(feature = "with-db")] pub mod doctor; #[cfg(feature = "with-db")]