Skip to content

Commit

Permalink
feat: Switch from axum to Rocket for better ergonomics and correctnes…
Browse files Browse the repository at this point in the history
…s with blocking/sync libraries
  • Loading branch information
johnbcodes committed Feb 8, 2024
1 parent 7e1f2ef commit e7b4fe8
Show file tree
Hide file tree
Showing 26 changed files with 2,000 additions and 1,475 deletions.
1,337 changes: 759 additions & 578 deletions Cargo.lock

Large diffs are not rendered by default.

15 changes: 3 additions & 12 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,17 @@ lto = true

[dependencies]
anyhow = "1.0"
axum = "0.7"
currency_rs = { git = "https://github.com/johnbcodes/currency_rs", branch = "feature/db-diesel2-sqlite", version = "1.1", features = [ "db-diesel2-sqlite" ] }
diesel = { version = "2.1", features = ["r2d2", "sqlite", "time"] }
diesel = { version = "2.1", features = ["sqlite", "time"] }
diesel_migrations = "2.1"
dotenvy = "0.15"
hotwire-turbo = "0.1"
hotwire-turbo-axum = "0.1"
itertools = "0.12"
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
markup = "0.15"
mime_guess = "2"
once_cell = "1"
regex = "1"
rocket = "0.5"
rocket_sync_db_pools = { version = "0.1", features = ["diesel_sqlite_pool"]}
rust-embed = { version = "8", features = ["interpolate-folder-path"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ulid = "1.1"
validator = { version = "0.16", features = ["derive"] }
10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ RUN USER=root cargo new --bin app
WORKDIR /app

# copy over infrequently changing files
COPY package.json package-lock.json Cargo.lock Cargo.toml ./
# copy your source tree, ordered again by infrequent to frequently changed files
COPY tailwind.config.js ./
COPY build.rs ./
COPY Rocket.toml ./
COPY package.json package-lock.json Cargo.lock Cargo.toml ./
# copy your source tree, ordered again by infrequent to frequently changed files
COPY ./migrations ./migrations
COPY ./ui ./ui
COPY ./src ./src
Expand All @@ -38,7 +39,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
## Deploy locally
FROM debug as dev

ENV DATABASE_URL=sqlite://data/demo.db
ENV ROCKET_PROFILE=docker

EXPOSE 8080

Expand All @@ -65,9 +66,10 @@ WORKDIR /

RUN mkdir data

COPY --from=release /app/Rocket.toml .
COPY --from=release /usr/local/cargo/bin/demo .

ENV DATABASE_URL=sqlite://data/demo.db
ENV ROCKET_PROFILE=docker

EXPOSE 8080

Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

> Rust implementation of the quote editor from [Turbo Rails Tutorial](https://www.hotrails.dev/turbo-rails).
### TODO
* Group queries in run block where possible

### Motivation and caveats

The main motivation is learning to develop web applications with Rust and JavaScript combined. It now includes
the following stack:

* [htmx](https://htmx.org/)
* [hyperscript](https://hyperscript.org/)
* [Axum](https://github.com/tokio-rs/axum)
* [Rocket](https://rocket.rs/)
* [Diesel](https://diesel.rs/)
* [markup.rs](https://github.com/utkarshkukreti/markup.rs)
* Custom Rust / NPM build integration
Expand All @@ -18,6 +21,7 @@ the following stack:
In the past it included these technologies:

* [Hotwire Turbo](https://turbo.hotwired.dev/)
* [Axum](https://github.com/tokio-rs/axum)
* [Rusqlite](https://github.com/rusqlite/rusqlite)

Some features of the tutorial were intentionally left out and possibly will be worked on in the future:
Expand All @@ -29,11 +33,9 @@ Additionally, there were some other features and integral parts of Rails that ha

* The look and feel deviates from [demo](https://www.hotrails.dev/quotes) because the author has made some UI enhancements that are not in the tutorial
* Viewports less than tablet sizing
* Proper validation error messages ("to_sentence" on ValidationErrors struct for flash message)
* Only add border color to fields with errors
* Labels for input fields
* Delete confirmation
* Probably a few others
* ...probably a few others

## Getting Started

Expand Down Expand Up @@ -62,7 +64,7 @@ Additionally, there were some other features and integral parts of Rails that ha

* Create volume with `docker volume create db-data`
* Build with `docker build -t rust-quote-editor .`
* Run with `docker run -itd -e "DATABASE_URL=sqlite:///data/demo.db" -p 8080:8080 -v db-data:/data rust-quote-editor`
* Run with `docker run -itd -p 8080:8080 -v db-data:/data rust-quote-editor`

#### Docker Compose

Expand All @@ -78,12 +80,11 @@ Additionally, there were some other features and integral parts of Rails that ha
* Update `primary_region` property in `fly.toml`
* `fly volumes create <VOLUME-NAME> -s 1 -r <REGION>`
* Update `mounts.source` property in `fly.toml` with <VOLUME-NAME>
* `fly secrets set DATABASE_URL=/data/demo.db`
* `docker build -t registry.fly.io/<GLOBALLY-UNIQUE-APP-NAME>:<VERSION-NUMBER> --target deploy .`
* `fly deploy --image registry.fly.io/<GLOBALLY-UNIQUE-APP-NAME>:<VERSION-NUMBER>`

## Automated deployment of new versions with GitHub [action](.github/workflows/deploy.yml)
* [Set up](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) your `FLY_API_TOKEN` secret in your repository
* [Set up](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) your `FLY_API_TOKEN` [secret](https://fly.io/docs/reference/deploy-tokens/) in your repository
* Tag release with a tag name starting with 'v'
* Example: `git tag -a v2 -m "My new release!" && git push --tags`

Expand Down
13 changes: 13 additions & 0 deletions Rocket.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[default]
log_level = "debug"

[default.databases.demo]
url = "data/demo.db"
timeout = 10

[docker]
address = "0.0.0.0"

[docker.databases.demo]
url = "/data/demo.db"
timeout = 10
4 changes: 1 addition & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ services:
web.app:
build:
target: dev
environment:
- DATABASE_URL=/data/demo.db
ports:
- "8080:8080"
- "8000:8000"
volumes:
- db-data:/data

Expand Down
6 changes: 3 additions & 3 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ kill_timeout = 5
PORT = "8080"

[mounts]
source = "jbc_ah_data"
source = "jbc_qe_data"
destination = "/data"

[[services]]
internal_port = 8080
internal_port = 8000
protocol = "tcp"

[services.concurrency]
Expand All @@ -31,6 +31,6 @@ port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
port = "8080"
port = "8000"
restart_limit = 6
timeout = "2s"
74 changes: 34 additions & 40 deletions src/assets.rs
Original file line number Diff line number Diff line change
@@ -1,54 +1,48 @@
use axum::{
body::Body,
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
use rocket::{
fairing::AdHoc,
http::{ContentType, Header},
response::Responder,
};
use rust_embed::RustEmbed;
use tracing::info;
use std::borrow::Cow;
use std::ffi::OsStr;
use std::path::PathBuf;

#[derive(RustEmbed)]
#[folder = "$CARGO_MANIFEST_DIR/ui/target/public/"]
pub(crate) struct Assets;

pub(crate) struct StaticFile<T>(pub(crate) T);
pub(crate) struct Asset;

#[derive(Responder)]
#[response(status = 200)]
struct AssetResponse {
content: Cow<'static, [u8]>,
content_type: ContentType,
max_age: Header<'static>,
}

#[cfg(debug_assertions)]
const MAX_AGE: &str = "max-age=120";
#[cfg(not(debug_assertions))]
const MAX_AGE: &str = "max-age=31536000";

impl<T> IntoResponse for StaticFile<T>
where
T: Into<String>,
{
fn into_response(self) -> Response {
let path = self.0.into();

match Assets::get(path.as_str()) {
Some(content) => {
info!("Retrieving asset with path: {path}");
let body = Body::from(content.data);
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::CACHE_CONTROL, MAX_AGE)
.body(body)
.unwrap()
}
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Not Found"))
.unwrap(),
}
}
pub(crate) fn stage() -> AdHoc {
AdHoc::on_ignite("Assets Stage", |rocket| async {
rocket.mount("/dist", routes![asset_handler])
})
}

pub(crate) async fn asset_handler(uri: Uri) -> impl IntoResponse {
let mut path = uri.path().trim_start_matches('/').to_string();

if path.starts_with("dist/") {
path = path.replace("dist/", "");
}

StaticFile(path)
#[get("/<file..>")]
fn asset_handler(file: PathBuf) -> Option<AssetResponse> {
let filename = file.display().to_string();
let asset = Asset::get(&filename)?;
let content_type = file
.extension()
.and_then(OsStr::to_str)
.and_then(ContentType::from_extension)
.unwrap_or(ContentType::Bytes);
Some(AssetResponse {
content: asset.data,
content_type,
max_age: Header::new("Cache-Control", MAX_AGE),
})
}
2 changes: 1 addition & 1 deletion src/currency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ use once_cell::sync::Lazy;
use regex::Regex;

pub(crate) static FORM_CURRENCY_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^\d*(\.\d{2})?$").unwrap());
Lazy::new(|| Regex::new(r"^\d+(\.\d{2})?$").unwrap());
17 changes: 6 additions & 11 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
use rocket::{
response::{Debug, Responder, Result},
Request,
};

// Make our own error that wraps `anyhow::Error`.
pub(crate) struct AppError(anyhow::Error);

// Tell axum how to convert `AppError` into a response.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", self.0),
)
.into_response()
impl<'r> Responder<'r, 'r> for AppError {
fn respond_to(self, request: &Request<'_>) -> Result<'r> {
Debug(self.0).respond_to(request)
}
}

Expand Down
56 changes: 56 additions & 0 deletions src/forms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use crate::{currency::FORM_CURRENCY_REGEX, time::DATE_REGEX};
use once_cell::sync::Lazy;
use regex::Regex;
use rocket::form::{Contextual, Form};

pub(crate) static QUANTITY_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d+$").unwrap());

pub(crate) fn css_for_field<'b, T>(
form: &Form<Contextual<'_, T>>,
field: &'b str,
default_class: &'b str,
error_class: &'b str,
) -> String {
if form.context.exact_field_errors(field).count() == 0 {
default_class.to_string()
} else {
format!("{} {}", default_class, error_class)
}
}

pub(crate) fn validate_date<'v>(date: &str) -> rocket::form::Result<'v, ()> {
if date.is_empty() {
Err(rocket::form::Error::validation("Please enter a date"))?;
}
if !DATE_REGEX.is_match(date) {
Err(rocket::form::Error::validation("Please enter a valid date"))?;
}

Ok(())
}

pub(crate) fn validate_amount<'v>(amount: &str) -> rocket::form::Result<'v, ()> {
if amount.is_empty() {
Err(rocket::form::Error::validation("Please enter an amount"))?;
}
if !FORM_CURRENCY_REGEX.is_match(amount) {
Err(rocket::form::Error::validation(
"Please enter a valid amount",
))?;
}

Ok(())
}

pub(crate) fn validate_quantity<'v>(quantity: &str) -> rocket::form::Result<'v, ()> {
if quantity.is_empty() {
Err(rocket::form::Error::validation("Please enter a quantity"))?;
}
if !QUANTITY_REGEX.is_match(quantity) {
Err(rocket::form::Error::validation(
"Please enter a valid quantity",
))?;
}

Ok(())
}
Loading

0 comments on commit e7b4fe8

Please sign in to comment.