Merge branch 'clean'

This commit is contained in:
Kristofers Solo 2025-02-11 11:16:32 +02:00
commit 9f9960bb3b
69 changed files with 689 additions and 5574 deletions

8
.dockerfile Normal file
View File

@ -0,0 +1,8 @@
.env
target/
tests/
Dockerfile
README.md
LICENSE
scripts/
migrations/

1
.env.example Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=postgres://postgres:password@localhost:5432/echoes-of-ascension

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO \"user\" (username, code)\n VALUES ($1, $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "76811378181edbd741685f253a2628f4ff2e8623c00e0675f2b5866cda7c49bf"
}

4340
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,69 @@
[workspace] [package]
members = ["frontend", "backend"] name = "echoes-of-ascension"
resolver = "2" version = "0.1.0"
edition = "2021"
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
[workspace.dependencies] [lib]
cfg-if = "1" path = "src/lib.rs"
http = "1"
thiserror = "2.0" [[bin]]
wasm-bindgen = "=0.2.100" path = "src/main.rs"
tokio = { version = "1.43", features = ["rt", "macros", "tracing"] } name = "echoes-of-ascension"
[dependencies]
axum = "0.8"
chrono = { version = "0.4", features = ["serde", "clock"] }
config = { version = "0.15", features = ["toml"], default-features = false }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tower = { version = "0.5", features = ["full"] } serde_json = "1"
tower-http = { version = "0.6", features = ["full"] } sqlx = { version = "0.8", default-features = false, features = [
uuid = { version = "1.12", features = ["v4", "serde"] } "runtime-tokio",
"tls-rustls",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
tokio = { version = "1.39", features = [
"rt",
"macros",
"tracing",
"rt-multi-thread",
] }
uuid = { version = "1.13", features = ["v4", "serde"] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing-bunyan-formatter = "0.3"
tracing-log = "0.2"
secrecy = { version = "0.10", features = ["serde"] }
serde-aux = "4"
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
askama = { version = "0.12", features = ["with-axum"] }
validator = "0.20"
unicode-segmentation = "1"
rand = "0.8"
argon2 = "0.5"
password-hash = "0.5"
hex = "0.4"
anyhow = "1"
thiserror = "2"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
[workspace.lints.clippy] [dev-dependencies]
once_cell = "1.19"
fake = "3.1"
quickcheck = "1.0"
quickcheck_macros = "1.0"
wiremock = "0.6"
serde_json = "1"
[lints.clippy]
pedantic = "warn"
nursery = "warn" nursery = "warn"
unwrap_used = "warn" unwrap_used = "warn"
style = "warn"
perf = "warn"

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM lukemathwalker/cargo-chef:latest-rust-1.77.0 AS chef
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install lld clang -y
FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
ENV SQLX_OFFLINE true
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# openssl - it is dynamically linked by some dependencies
# ca-certificates - it is needed to verify TLS certificates when establishing HTTPS connections
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/echoes-of-ascension echoes-of-ascension
COPY config config
ENV APP_ENVIRONMENT production
ENTRYPOINT ["./echoes-of-ascension"]

17
README.md Normal file
View File

@ -0,0 +1,17 @@
# Axum Template
This repository contains templates for bootstrapping a Rust Web application with Axum.
## Getting Started
1. Install [`cargo-generate`](https://github.com/cargo-generate/cargo-generate#installation)
```shell
cargo install cargo-generate
```
2. Create a new app based on this repository:
```shell
cargo generate kristoferssolo/axum-template
```

View File

@ -1,42 +0,0 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
thiserror.workspace = true
axum = "0.8"
tokio = { workspace = true, features = ["rt-multi-thread"] }
tower.workspace = true
tower-http = { workspace = true, features = ["cors"] }
serde.workspace = true
serde_json = "1"
uuid.workspace = true
sqlx = { version = "0.8", features = [
"runtime-tokio",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
chrono = { version = "0.4", features = ["serde", "clock"] }
secrecy = { version = "0.10", features = ["serde"] }
validator = "0.20"
config = { version = "0.15", features = ["toml"], default-features = false }
serde-aux = "4"
unicode-segmentation = "1"
rand = "0.8"
argon2 = "0.5"
password-hash = "0.5"
hex = "0.4"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = { version = "0.3", default-features = false }
tracing-log = "0.2"
anyhow = "1"
[lints]
workspace = true

View File

@ -1,116 +0,0 @@
use std::{fmt::Display, str::FromStr};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::{
postgres::{PgConnectOptions, PgSslMode},
ConnectOptions,
};
#[derive(Debug, Deserialize, Clone)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseSettings {
pub username: String,
pub password: SecretString,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
pub require_ssl: bool,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
}
#[derive(Debug, Clone)]
pub enum Environment {
Local,
Production,
}
impl DatabaseSettings {
#[must_use]
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
PgSslMode::Prefer
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(self.password.expose_secret())
.port(self.port)
.ssl_mode(ssl_mode)
}
#[must_use]
pub fn with_db(&self) -> PgConnectOptions {
self.without_db()
.database(&self.database_name)
.log_statements(tracing_log::log::LevelFilter::Trace)
}
}
pub fn get_config() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine current directory");
let config_directory = base_path.join("backend").join("config");
let env = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.parse()
.unwrap_or(Environment::Local);
let env_filename = format!("{}.toml", &env);
let settings = config::Config::builder()
.add_source(config::File::from(config_directory.join("base.toml")))
.add_source(config::File::from(config_directory.join(env_filename)))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
impl Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Local => write!(f, "local"),
Self::Production => write!(f, "production"),
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_str(&value)
}
}
impl FromStr for Environment {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{other} is not supported environment. \
Use either `local` or `production`."
)),
}
}
}

View File

@ -1 +0,0 @@
pub mod users;

View File

@ -1 +0,0 @@
pub mod app;

View File

@ -1,8 +0,0 @@
pub mod config;
pub mod db;
pub mod domain;
pub mod error;
pub mod routes;
pub mod server;
pub mod startup;
pub mod telemetry;

View File

@ -1,18 +0,0 @@
use backend::{
config::get_config,
server::Server,
telemetry::{get_subscriber, init_subscriber},
};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Generate the list of routes in your Leptos App
let subscriber = get_subscriber("echoes-of-ascension-server", "info", std::io::stdout);
init_subscriber(subscriber);
let config = get_config().expect("Failed to read configuation.");
//
let application = Server::build(config).await?;
application.run_until_stopped().await?;
Ok(())
}

View File

@ -1,38 +0,0 @@
use tokio::{net::TcpListener, task::JoinHandle};
use crate::{
config::Settings,
routes::route,
startup::{get_connection_pool, App},
};
#[derive(Debug)]
pub struct Server {
port: u16,
server: JoinHandle<Result<(), std::io::Error>>,
}
impl Server {
pub async fn build(config: Settings) -> Result<Self, std::io::Error> {
let pool = get_connection_pool(&config.database);
// Use application's address configuration
let addr = format!("{}:{}", config.application.host, config.application.port);
let listener = TcpListener::bind(addr).await?;
let port = listener.local_addr()?.port();
let app_state = App { pool }.into();
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
Ok(Self { port, server })
}
#[must_use]
#[inline]
pub const fn port(&self) -> u16 {
self.port
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
self.server.await?
}
}

View File

@ -1,17 +0,0 @@
use sqlx::{postgres::PgPoolOptions, PgPool};
use std::sync::Arc;
use crate::config::DatabaseSettings;
pub type AppState = Arc<App>;
#[derive(Debug)]
pub struct App {
pub pool: PgPool,
}
#[must_use]
#[inline]
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}

View File

@ -6,4 +6,4 @@ host = "127.0.0.1"
port = 5432 port = 5432
username = "postgres" username = "postgres"
password = "password" password = "password"
database_name = "maze_ascension" database_name = "echoes-of-ascension"

14
frontend/.gitignore vendored
View File

@ -1,14 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

View File

@ -1,105 +0,0 @@
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.7", features = ["nightly"] }
leptos_router = { version = "0.7", features = ["nightly"] }
axum = { version = "0.7", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
leptos_axum = { version = "0.7", optional = true }
leptos_meta = { version = "0.7" }
tokio = { workspace = true, features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "=0.2.100", optional = true }
reqwest = { version = "0.12", features = ["json"] }
serde.workspace = true
thiserror.workspace = true
[features]
hydrate = ["leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen"]
ssr = [
"dep:axum",
"dep:tokio",
"dep:leptos_axum",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "frontend"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"
[lints]
workspace = true

View File

@ -1,91 +0,0 @@
<picture>
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Axum Starter Template
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool using [Axum](https://github.com/tokio-rs/axum).
## Creating your template repo
If you don't have `cargo-leptos` installed you can install it with
```bash
cargo install cargo-leptos --locked
```
Then run
```bash
cargo leptos new --git https://github.com/leptos-rs/start-axum-0.7
```
to generate a new project template.
```bash
cd frontend
```
to go to your newly created project.
Feel free to explore the project structure, but the best place to start with your application code is in `src/app.rs`.
Addtionally, Cargo.toml may need updating as new versions of the dependencies are released, especially if things are not working after a `cargo update`.
## Running your project
```bash
cargo leptos watch
```
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future
5. Run `npm install` in end2end subdirectory before test
## Compiling for Release
```bash
cargo leptos build --release
```
Will generate your server binary in target/server/release and your site package in target/site
## Testing Your Project
```bash
cargo leptos end-to-end
```
```bash
cargo leptos end-to-end --release
```
Cargo-leptos uses Playwright as the end-to-end test tool.
Tests are located in end2end/tests directory.
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
frontend
site/
```
Set the following environment variables (updating for your project as needed):
```sh
export LEPTOS_OUTPUT_NAME="frontend"
export LEPTOS_SITE_ROOT="site"
export LEPTOS_SITE_PKG_DIR="pkg"
export LEPTOS_SITE_ADDR="127.0.0.1:3000"
export LEPTOS_RELOAD_PORT="3001"
```
Finally, run the server binary.
## Licensing
This template itself is released under the Unlicense. You should replace the LICENSE for your own application with an appropriate license if you plan to release it publicly.

View File

@ -1,3 +0,0 @@
node_modules
playwright-report
test-results

View File

@ -1,167 +0,0 @@
{
"name": "end2end",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "end2end",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.44.1",
"@types/node": "^20.12.12",
"typescript": "^5.4.5"
}
},
"node_modules/@playwright/test": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.44.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.44.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
}
},
"dependencies": {
"@playwright/test": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
"dev": true,
"requires": {
"playwright": "1.44.1"
}
},
"@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
}
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"playwright": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
"dev": true,
"requires": {
"fsevents": "2.3.2",
"playwright-core": "1.44.1"
}
},
"playwright-core": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
"dev": true
},
"typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true
},
"undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}

View File

@ -1,15 +0,0 @@
{
"name": "end2end",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.44.1",
"@types/node": "^20.12.12",
"typescript": "^5.4.5"
}
}

View File

@ -1,105 +0,0 @@
import type { PlaywrightTestConfig } from "@playwright/test";
import { devices, defineConfig } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
});

View File

@ -1,9 +0,0 @@
import { test, expect } from "@playwright/test";
test("homepage has title and heading text", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Welcome to Leptos");
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
});

View File

@ -1,109 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,33 +0,0 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
StaticSegment,
};
use crate::components::{homepage::HomePage, register::RegisterForm};
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/frontend.css" />
// sets the document title
<Title text="Welcome to Leptos" />
// content for this welcome page
<Router>
<main>
<Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage />
<Route path=StaticSegment("/register") view=RegisterForm />
</Routes>
</main>
</Router>
}
}

View File

@ -1,14 +0,0 @@
use leptos::prelude::*;
/// Renders the home page of your application.
#[component]
pub fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let count = RwSignal::new(0);
let on_click = move |_| *count.write() += 1;
view! {
<h1>"Welcome to Leptos!"</h1>
<button on:click=on_click>"Click Me: " {count}</button>
}
}

View File

@ -1,25 +0,0 @@
mod app;
mod homepage;
mod register;
pub use app::App;
use leptos::prelude::*;
use leptos_meta::MetaTags;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}

View File

@ -1,115 +0,0 @@
use leptos::{ev::SubmitEvent, prelude::*};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormData {
username: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseData {
username: String,
code: String,
}
#[server]
async fn register(payload: FormData) -> Result<ResponseData, ServerFnError> {
let client = Client::new();
let response = client
.post("http://localhost:8000/api/v1/register")
.json(&payload)
.send()
.await?;
match response.status() {
StatusCode::CREATED => {
let response_data = response.json::<ResponseData>().await?;
Ok(response_data)
}
status => {
let error_msg = response.text().await?;
Err(ServerFnError::ServerError(format!(
"Registration failed: {} - {}",
status, error_msg
)))
}
}
}
#[component]
pub fn RegisterForm() -> impl IntoView {
let username = RwSignal::new(String::new());
let register_action = Action::new(|input: &FormData| {
let input = input.clone();
async move { register(input).await }
});
let on_submit = move |ev: SubmitEvent| {
ev.prevent_default();
let form_data = FormData {
username: username.get(),
};
register_action.dispatch(form_data);
};
let is_submitting = move || register_action.pending().get();
view! {
<form on:submit=on_submit>
<div>
<label>"Username"</label>
<input
type="text"
on:input=move |ev| username.set(event_target_value(&ev))
prop:value=username
/>
</div>
<button type="submit" disabled=is_submitting>
{move || { if is_submitting() { "Registering..." } else { "Register" } }}
</button>
<ErrorBoundary fallback=move |errors| {
view! {
<div class="error-container">
<p class="error-title">"Registration Error:"</p>
<ul class="error-list">
{move || {
errors
.get()
.into_iter()
.map(|(_, e)| {
view! { <li class="error-item">{e.to_string()}</li> }
})
.collect_view()
}}
</ul>
</div>
}
}>
{move || {
register_action
.value()
.get()
.map(|result| {
match result {
Ok(response) => {
view! {
<div class="success-container">
<p class="success-message">"Registration successful!"</p>
<div class="registration-details">
<p>"Username: " {response.username}</p>
<p>"Your Code: " {response.code}</p>
<p class="code-notice">
"Please save this code. You will need it to login."
</p>
</div>
</div>
}
.into_any()
}
Err(e) => view! { <span>{e.to_string()}</span> }.into_any(),
}
})
}}
</ErrorBoundary>
</form>
}
}

View File

@ -1,9 +0,0 @@
pub mod components;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::components::*;
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}

View File

@ -1,38 +0,0 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use frontend::components::*;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for pure client-side testing
// see lib.rs for hydration function instead
}

View File

@ -1,5 +0,0 @@
body {
font-family: sans-serif;
text-align: center;
}

153
justfile
View File

@ -1,106 +1,83 @@
set dotenv-load set dotenv-load
PROJECT_NAME := "echoes-of-ascension"
# List all available commands # List all available commands
default: default:
@just --list @just --list
# Install required tools and dependencies # Format code
setup: fmt:
just db-setup cargo fmt
rustup toolchain install nightly
rustup default nightly
rustup target add wasm32-unknown-unknown
cargo install cargo-leptos
cargo install cargo-watch
cargo install just
# Development Commands # Lint code
lint:
# Start development server with hot reload cargo clippy -- -D warnings
dev: kill-server db-migrate
#!/usr/bin/env bash
(RUSTC_WRAPPER=sccache cargo watch -x "run -p backend" | bunyan) & \
(RUSTC_WRAPPER=sccache cargo leptos watch | bunyan) & \
wait
# Run cargo check on both native and wasm targets
check:
cargo check --all-targets
cargo check --all-targets --target wasm32-unknown-unknown
# Run tests # Run tests
test: test:
cargo test --all-targets cargo test
cargo test --all-targets --target wasm32-unknown-unknown
# Format code # Build the application (debug)
fmt: build:
cargo fmt --all cargo build
# Run clippy lints # Build the application (release)
lint: build-release:
cargo clippy --all-targets -- -D warnings cargo build --release
cargo clippy --all-targets --target wasm32-unknown-unknown -- -D warnings
# Clean build artifacts # Run the application (debug)
run:
cargo run
# Run the application (release)
run-release:
cargo run --release
# Run migrations
migrate:
cargo sqlx migrate run
# Revert migrations
migrate-revert:
cargo sqlx migrate revert
# Create a new migration
migrate-create name:
cargo sqlx migrate add $(name)
# Check migrations
migrate-status:
cargo sqlx migrate status
# Watch for changes and run tests/linting/run (for development)
dev:
cargo watch -x clippy -x test -x run | bunyan
# Build, migrate, and run (release)
deploy:
just build-release
just migrate
just run-release
# Generate documentation
doc:
cargo doc --open
# Clean the project
clean: clean:
cargo clean cargo clean
rm -rf dist
rm -rf target
# Build Commands # Analyze binary size
analyze-size:
cargo build --release
cargo install cargo-bloat
cargo bloat --release --all-features --crates
# Build for development # Check dependencies for security vulnerabilities
build-dev: audit:
cargo build cargo audit
cargo leptos build
# Build for production
build-prod:
cargo leptos build --release
# Build WASM only
build-wasm:
cargo leptos build-only-wasm
# Build server only
build-server:
cargo leptos build-only-server
# Deployment Commands
deploy:
echo "Add deployment commands here"
# Combined commands
check-all: fmt lint check test
# Start production server
serve-prod:
cargo leptos serve --release
kill-server:
#!/usr/bin/env sh
pkill -f "target/debug/server" || true
pkill -f "cargo-leptos" || true
# Database Commands
# Setup the database
db-setup:
./scripts/init_db
alias migrate:=db-migrate
alias m:=db-migrate
# Migrate
db-migrate:
sqlx migrate run --source backend/migrations
# Generate sqlx prepare check files
db-prepare:
sqlx prepare
alias migrations:=db-new-migration
# Create new migration
db-new-migration name:
sqlx migrate add -r {{name}}
# Check for outdated dependencies
outdated:
cargo outdated

View File

@ -1,4 +0,0 @@
[toolchain]
channel = "nightly"
components = ["rustfmt", "clippy", "rust-analyzer"]
targets = ["wasm32-unknown-unknown"]

View File

@ -16,7 +16,7 @@ fi
DB_USER="${POSTGRES_USER:=postgres}" DB_USER="${POSTGRES_USER:=postgres}"
DB_PASSWORD="${POSTGRES_PASSWORD:=password}" DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
DB_NAME="${POSTGRES_DB:=maze_ascension}" DB_NAME="${POSTGRES_DB:=echoes-of-ascension}"
DB_PORT="${POSTGRES_PORT:=5432}" DB_PORT="${POSTGRES_PORT:=5432}"
DB_HOST="${POSTGRES_HOST:=localhost}" DB_HOST="${POSTGRES_HOST:=localhost}"
@ -43,4 +43,4 @@ done
DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
export DATABASE_URL export DATABASE_URL
sqlx database create sqlx database create
sqlx migrate run --source backend/migrations sqlx migrate run

View File

@ -0,0 +1,9 @@
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
#[derive(Debug, Clone, Deserialize)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
}

43
src/config/database.rs Normal file
View File

@ -0,0 +1,43 @@
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::{
postgres::{PgConnectOptions, PgSslMode},
ConnectOptions,
};
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: SecretString,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
pub require_ssl: bool,
}
impl DatabaseSettings {
#[must_use]
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
PgSslMode::Prefer
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(self.password.expose_secret())
.port(self.port)
.ssl_mode(ssl_mode)
}
#[must_use]
pub fn with_db(&self) -> PgConnectOptions {
self.without_db()
.database(&self.database_name)
.log_statements(tracing_log::log::LevelFilter::Trace)
}
}

38
src/config/environment.rs Normal file
View File

@ -0,0 +1,38 @@
use std::{fmt::Display, str::FromStr};
#[derive(Debug)]
pub enum Environment {
Local,
Production,
}
impl Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Local => "local",
Self::Production => "production",
};
write!(f, "{s}")
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_str(&value)
}
}
impl FromStr for Environment {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{other} is not supported environment. \
Use either `local` or `production`.",
)),
}
}
}

53
src/config/mod.rs Normal file
View File

@ -0,0 +1,53 @@
pub mod application;
pub mod database;
pub mod environment;
use application::ApplicationSettings;
pub use database::DatabaseSettings;
use environment::Environment;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
/// Get the configuration settings for the application.
///
/// # Panics
///
/// This function may panic in the following cases:
///
/// - If the current directory cannot be determined. This is highly unusual.
/// - If the `APP_ENVIRONMENT` environment variable is set to an invalid value
/// that cannot be converted to an `Environment` enum variant.
///
/// # Errors
///
/// This function returns an error if:
///
/// - Any of the configuration files (`base.toml`, `{environment}.toml`) cannot be read or parsed.
/// - Environment variables prefixed with `APP_` cannot be read or parsed.
/// - The resulting configuration cannot be deserialized into the `Settings` struct.
pub fn get_config() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine current directory");
let config_directory = base_path.join("config");
let env: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let env_filename = format!("{}.toml", &env);
let settings = config::Config::builder()
.add_source(config::File::from(config_directory.join("base.toml")))
.add_source(config::File::from(config_directory.join(env_filename)))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}

26
src/domain/mod.rs Normal file
View File

@ -0,0 +1,26 @@
//! # Domain
//!
//! This module defines the core business logic and data structures of the application,
//! independent of any specific implementation details such as databases or external APIs.
//!
//! It contains value objects, entities, and domain services that represent the fundamental
//! concepts and rules of the application's problem domain.
//!
//! Example:
//!
//! ```
//! // A value object representing an email address
//! #[derive(Debug, Clone)]
//! pub struct EmailAddress(pub String);
//!
//! // An entity representing a User
//! pub struct User {
//! pub id: UserId,
//! pub email: EmailAddress,
//! pub name: String,
//! }
//!
//! pub struct UserId(pub String);
//! ```
pub mod user;

View File

@ -1,4 +1,3 @@
pub mod error;
pub mod new_user; pub mod new_user;
mod user_code; mod user_code;
mod username; mod username;

View File

@ -5,7 +5,7 @@ use std::ops::Deref;
use rand::{rngs::OsRng, thread_rng, Rng}; use rand::{rngs::OsRng, thread_rng, Rng};
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use super::error::UserError; use crate::errors::user::UserError;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UserCode(SecretString); pub struct UserCode(SecretString);

View File

@ -2,7 +2,7 @@ use rand::{seq::SliceRandom, thread_rng, Rng};
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use super::error::UserError; use crate::errors::user::UserError;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Username(String); pub struct Username(String);

View File

@ -1 +1,2 @@
pub mod app;
pub mod user; pub mod user;

9
src/lib.rs Normal file
View File

@ -0,0 +1,9 @@
pub mod config;
pub mod domain;
pub mod errors;
pub mod middleware;
pub mod models;
pub mod repositories;
pub mod routes;
pub mod services;
pub mod startup;

15
src/main.rs Normal file
View File

@ -0,0 +1,15 @@
use echoes_of_ascension::{
config::get_config,
middleware::telemetry::{get_subscriber, init_subscriber},
startup::Application,
};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = get_subscriber("echoes_of_ascension", "info", std::io::stdout);
init_subscriber(subscriber);
let config = get_config().expect("Failed to read configuation.");
let application = Application::build(config).await?;
application.run_until_stopped().await?;
Ok(())
}

1
src/middleware/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod telemetry;

View File

@ -3,6 +3,12 @@ use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer; use tracing_log::LogTracer;
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry}; use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
/// Create a new tracing subscriber.
///
/// # Panics
///
/// This function may panic if there is a bug in the `EnvFilter::from` implementation,
/// causing the `env_filter.into()` conversion to fail. This is highly unlikely.
pub fn get_subscriber<Sink>( pub fn get_subscriber<Sink>(
name: &str, name: &str,
env_filter: &str, env_filter: &str,
@ -19,6 +25,16 @@ where
.with(formatting_layer) .with(formatting_layer)
} }
/// Initialize a global subscriber for tracing and logging.
///
/// # Panics
///
/// This function may panic in the following cases:
///
/// - If `LogTracer::init()` fails because the global logger has already been initialized.
/// This typically happens if `init_subscriber` is called more than once.
/// - If `set_global_default(subscriber)` fails because another subscriber has already been set,
/// or if there's an issue with the provided subscriber.
pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) { pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) {
LogTracer::init().expect("Failed to set logger"); LogTracer::init().expect("Failed to set logger");
set_global_default(subscriber).expect("Failed to set subscriber."); set_global_default(subscriber).expect("Failed to set subscriber.");

20
src/models/mod.rs Normal file
View File

@ -0,0 +1,20 @@
//! # Models
//!
//! This module defines the data structures (models or entities) that represent the core concepts
//! of the application's domain. These models are database-agnostic and primarily focus on
//! data representation.
//!
//! They are used to define the shape of the data as it exists within the application,
//! independent of how it's stored or retrieved.
//!
//! Example:
//!
//! ```
//! // A simple model for a Subscriber
//! #[derive(Debug, Clone)]
//! pub struct Subscriber {
//! pub id: i32,
//! pub email: String,
//! pub name: String,
//! }
//! ```

27
src/repositories/mod.rs Normal file
View File

@ -0,0 +1,27 @@
//! # Repositories
//!
//! This module provides an abstraction layer for data access. Repositories encapsulate the
//! logic for interacting with the database, providing a consistent interface for creating,
//! reading, updating, and deleting data.
//!
//! They are responsible for translating between the application's domain models and the
//! database's data structures. The database connection pool is passed as a parameter to
//! the repository methods.
//!
//! Example:
//!
//! ```rust
//! use sqlx::PgPool;
//! use sqlx::Error;
//!
//! // A repository for managing Subscribers
//! #[derive(Debug, Clone, Copy)]
//! pub struct SubscriberRepository;
//!
//! pub async fn create_subscriber(pool: &PgPool, email: &str, name: &str) -> Result<(), Error> {
//! // ... database interaction logic using the pool
//! Ok(())
//! }
//! ```
pub mod user;

View File

@ -1,7 +1,7 @@
use sqlx::PgPool; use sqlx::PgPool;
use thiserror::Error; use thiserror::Error;
use crate::domain::user::{error::UserError, new_user::NewUser}; use crate::{domain::user::new_user::NewUser, errors::user::UserError};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ServerUserError { pub enum ServerUserError {

View File

@ -1,15 +1,14 @@
use crate::{
domain::user::new_user::NewUser,
errors::{app::AppError, user::UserError},
repositories::user::{insert_user, ServerUserError},
startup::AppState,
};
use anyhow::anyhow; use anyhow::anyhow;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use secrecy::ExposeSecret; use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{
db::users::{insert_user, ServerUserError},
domain::user::{error::UserError, new_user::NewUser},
error::app::AppError,
startup::AppState,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct FormData { pub struct FormData {
pub username: String, pub username: String,

View File

@ -1,28 +1,27 @@
mod api; mod api;
mod health_check; mod health_check;
use axum::{routing::get, Router};
use crate::startup::AppState;
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::MatchedPath, extract::MatchedPath,
http::{HeaderMap, Request}, http::{HeaderMap, Request},
response::Response, response::Response,
routing::get,
Router,
}; };
use health_check::health_check; pub use health_check::*;
use std::time::Duration; use std::time::Duration;
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer}; use tower_http::classify::ServerErrorsFailureClass;
use tracing::{info_span, Span}; use tower_http::trace::TraceLayer;
use tracing::{info, info_span, Span};
use uuid::Uuid; use uuid::Uuid;
use crate::startup::AppState;
pub fn route(state: AppState) -> Router { pub fn route(state: AppState) -> Router {
Router::new() Router::new()
.route("/health_check", get(health_check)) .route("/health_check", get(health_check))
.nest("/api", api::routes()) .nest("/api", api::routes())
.with_state(state) .with_state(state)
// Tracing layer
.layer( .layer(
TraceLayer::new_for_http() TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| { .make_span_with(|request: &Request<_>| {
@ -33,13 +32,29 @@ pub fn route(state: AppState) -> Router {
info_span!( info_span!(
"http-request", "http-request",
method = ?request.method(), method = ?request.method(),
uri = %request.uri(),
matched_path, matched_path,
some_other_field = tracing::field::Empty,
request_id=%Uuid::new_v4(), request_id=%Uuid::new_v4(),
) )
}) })
.on_request(|_request: &Request<_>, _span: &Span| {}) .on_request(|request: &Request<_>, span: &Span| {
.on_response(|_response: &Response<_>, _latency: Duration, _span: &Span| {}) info!(
target: "http_requests",
parent: span,
method = ?request.method(),
uri = %request.uri(),
"Incoming request"
);
})
.on_response(|response: &Response<_>, latency: Duration, span: &Span| {
info!(
target: "http_responses",
parent: span,
status = response.status().as_u16(),
latency = ?latency,
"Outgoing response"
);
})
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {}) .on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {})
.on_eos( .on_eos(
|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {}, |_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {},
@ -48,4 +63,5 @@ pub fn route(state: AppState) -> Router {
|_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {}, |_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {},
), ),
) )
// .layer(create_telemetry_layer())
} }

25
src/services/mod.rs Normal file
View File

@ -0,0 +1,25 @@
//! # Services
//!
//! This module contains the business logic of the application. Services orchestrate the
//! interaction between models, repositories, and other components to fulfill specific use
//! cases.
//!
//! They define the core operations that the application performs and are responsible for
//! enforcing business rules and ensuring data consistency.
//!
//! Example:
//!
//! ```
//! // A service for managing newsletter subscriptions
//! pub struct NewsletterService {
//! // ... dependencies (e.g., SubscriberRepository, EmailClient)
//! }
//!
//! impl NewsletterService {
//! // Method to subscribe a user to the newsletter
//! pub async fn subscribe_user(&self, email: &str, name: &str) -> Result<(), String> {
//! // ... business logic (e.g., validate email, create subscriber, send confirmation email)
//! Ok(())
//! }
//! }
//! ```

69
src/startup.rs Normal file
View File

@ -0,0 +1,69 @@
use sqlx::{postgres::PgPoolOptions, PgPool};
use std::sync::Arc;
use tokio::{net::TcpListener, task::JoinHandle};
use crate::{
config::{DatabaseSettings, Settings},
routes::route,
};
pub struct App {
pub pool: PgPool,
}
pub type AppState = Arc<App>;
pub struct Application {
port: u16,
server: JoinHandle<Result<(), std::io::Error>>,
}
impl Application {
/// Builds and starts the application server.
///
/// # Errors
///
/// - Returns `std::io::Error` if:
/// - It fails to bind to the specified address.
///
/// # Panics
///
/// - Panics if `listener.local_addr()` returns `None`. This should only occur if the
/// listener is not properly bound to an address, which is considered a critical
/// failure during application startup.
pub async fn build(config: Settings) -> Result<Self, std::io::Error> {
let pool = get_connection_pool(&config.database);
let addr = format!("{}:{}", config.application.host, config.application.port);
let listener = TcpListener::bind(addr).await?;
let port = listener
.local_addr()
.expect("Listener should have a local address")
.port();
let app_state = App { pool }.into();
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
Ok(Self { port, server })
}
#[must_use]
#[inline]
pub const fn port(&self) -> u16 {
self.port
}
/// Runs the application until it is stopped.
///
/// # Errors
///
/// - Returns `std::io::Error` if the server task encounters an error.
#[inline]
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
self.server.await?
}
}
#[must_use]
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}

17
tests/api/health_check.rs Normal file
View File

@ -0,0 +1,17 @@
use crate::helpers::spawn_app;
use reqwest::Client;
#[tokio::test]
async fn health_check() {
let app = spawn_app().await;
let url = format!("{}/health_check", &app.address);
let client = Client::new();
let response = client
.get(&url)
.send()
.await
.expect("Failed to execute request");
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
}

79
tests/api/helpers.rs Normal file
View File

@ -0,0 +1,79 @@
use echoes_of_ascension::{
config::{get_config, DatabaseSettings},
middleware::telemetry::{get_subscriber, init_subscriber},
startup::{get_connection_pool, Application},
};
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid;
static TRACING: Lazy<()> = Lazy::new(|| {
let default_filter_level = "trace";
let subscriber_name = "test";
if std::env::var("TEST_LOG").is_ok() {
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
init_subscriber(subscriber);
} else {
let subscriber = get_subscriber(default_filter_level, subscriber_name, std::io::sink);
init_subscriber(subscriber);
}
});
pub struct TestApp {
pub address: String,
pub pool: PgPool,
}
pub async fn spawn_app() -> TestApp {
Lazy::force(&TRACING);
let config = {
let mut c = get_config().expect("Failed to read configuration.");
c.database.database_name = Uuid::new_v4().to_string();
c.application.port = 0;
c
};
configure_database(&config.database).await;
let application = Application::build(config.clone())
.await
.expect("Failed to build application.");
let address = format!("http://127.0.0.1:{}", application.port());
let _ = tokio::spawn(application.run_until_stopped());
TestApp {
address,
pool: get_connection_pool(&config.database),
}
}
async fn configure_database(config: &DatabaseSettings) -> PgPool {
let mut connection = PgConnection::connect_with(&config.without_db())
.await
.expect("Failed to connect to Postgres.");
connection
.execute(
format!(
r#"
CREATE DATABASE "{}"
"#,
config.database_name
)
.as_str(),
)
.await
.expect("Failed to create database.");
let pool = PgPool::connect_with(config.with_db())
.await
.expect("Failed to connect to Postgres.");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to migrate database");
pool
}

2
tests/api/main.rs Normal file
View File

@ -0,0 +1,2 @@
mod health_check;
mod helpers;