Skip to content

Commit

Permalink
feat: MySQL support (#75)
Browse files Browse the repository at this point in the history
This also deduplicates the database code and adds feature flags that can
enable and disable the database backends (or remove the ORM altogether).

This commit also adds a cargo hack step to the CI that checks if
the project can be compiled using any subset of the features.
  • Loading branch information
m4tx authored Nov 25, 2024
1 parent 96260c8 commit 764964c
Show file tree
Hide file tree
Showing 23 changed files with 701 additions and 490 deletions.
25 changes: 25 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,28 @@ jobs:
# Update Cargo.lock to minimal version dependencies.
cargo update -Z minimal-versions
cargo hack check --all-features --ignore-private
build-feature-power-set:
if: github.event_name == 'push' || github.event_name == 'schedule' ||
github.event.pull_request.head.repo.full_name != github.repository

name: Build with each feature combination
runs-on: ubuntu-latest
needs: ["build"]
steps:
- name: Checkout source
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly

- name: Install cargo-hack
uses: taiki-e/install-action@cargo-hack

- name: Cache Cargo registry
uses: Swatinem/rust-cache@v2

- name: Run cargo check with every combination of features
run: cargo hack check --feature-powerset --exclude-features db --no-dev-deps
6 changes: 2 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ darling = "0.20"
derive_builder = "0.20"
derive_more = "1"
env_logger = "0.11"
fake = "3"
# TODO: replace with upstream when https://github.com/cksac/fake-rs/pull/204 is merged and released
fake = { git = "https://github.com/m4tx/fake-rs.git" }
flareon = { path = "flareon" }
flareon_codegen = { path = "flareon-codegen" }
flareon_macros = { path = "flareon-macros" }
Expand Down
1 change: 1 addition & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
doc-valid-idents = ["PostgreSQL", "MySQL", "SQLite"]
14 changes: 12 additions & 2 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
services:
mariadb:
image: mariadb:11
container_name: flareon-mariadb
environment:
MARIADB_DATABASE: mysql
MARIADB_USER: flareon
MARIADB_PASSWORD: flareon
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
ports:
- "3306:3306"

postgres:
image: postgres:16-alpine
image: postgres:17-alpine
container_name: flareon-postgres
restart: always
environment:
POSTGRES_USER: flareon
POSTGRES_PASSWORD: flareon
Expand Down
15 changes: 15 additions & 0 deletions flareon-macros/src/dbtest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub(super) fn fn_to_dbtest(test_function_decl: ItemFn) -> syn::Result<TokenStrea
let test_fn = &test_function_decl.sig.ident;
let sqlite_ident = format_ident!("{}_sqlite", test_fn);
let postgres_ident = format_ident!("{}_postgres", test_fn);
let mysql_ident = format_ident!("{}_mysql", test_fn);

if test_function_decl.sig.inputs.len() != 1 {
return Err(syn::Error::new_spanned(
Expand Down Expand Up @@ -39,6 +40,20 @@ pub(super) fn fn_to_dbtest(test_function_decl: ItemFn) -> syn::Result<TokenStrea

#test_function_decl
}

#[ignore]
#[::tokio::test]
async fn #mysql_ident() {
let mut database = flareon::test::TestDatabase::new_mysql(stringify!(#test_fn))
.await
.unwrap();

#test_fn(&mut database).await;

database.cleanup().await.unwrap();

#test_function_decl
}
};
Ok(result)
}
11 changes: 8 additions & 3 deletions flareon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ log.workspace = true
mime_guess.workspace = true
password-auth = { workspace = true, features = ["std", "argon2"] }
pin-project-lite.workspace = true
sea-query = { workspace = true, features = ["backend-sqlite", "backend-postgres"] }
sea-query-binder = { workspace = true, features = ["sqlx-sqlite", "sqlx-postgres", "with-chrono", "runtime-tokio"] }
sea-query = { workspace = true }
sea-query-binder = { workspace = true, features = ["with-chrono", "runtime-tokio"] }
serde.workspace = true
sha2.workspace = true
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
sqlx = { workspace = true, features = ["runtime-tokio", "chrono"] }
subtle = { workspace = true, features = ["std"] }
sync_wrapper.workspace = true
thiserror.workspace = true
Expand Down Expand Up @@ -67,4 +67,9 @@ ignored = [
]

[features]
default = ["sqlite", "postgres", "mysql"]
fake = ["dep:fake"]
db = []
sqlite = ["db", "sea-query/backend-sqlite", "sea-query-binder/sqlx-sqlite", "sqlx/sqlite"]
postgres = ["db", "sea-query/backend-postgres", "sea-query-binder/sqlx-postgres", "sqlx/postgres"]
mysql = ["db", "sea-query/backend-mysql", "sea-query-binder/sqlx-mysql", "sqlx/mysql"]
9 changes: 9 additions & 0 deletions flareon/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#[cfg(all(
feature = "db",
not(any(feature = "sqlite", feature = "postgres", feature = "mysql"))
))]
compile_error!("feature \"db\" requires one of: \"sqlite\", \"postgres\", \"mysql\" to be enabled");

fn main() {
// do nothing; this only checks the feature flags
}
7 changes: 5 additions & 2 deletions flareon/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use async_trait::async_trait;
use bytes::Bytes;
use derive_more::Debug;

use crate::auth::db::DatabaseUserCredentials;
use crate::auth::AuthRequestExt;
use crate::forms::fields::Password;
use crate::forms::{
Expand Down Expand Up @@ -100,14 +99,18 @@ async fn login(mut request: Request) -> flareon::Result<Response> {
}

async fn authenticate(request: &mut Request, login_form: LoginForm) -> flareon::Result<bool> {
#[cfg(feature = "db")]
let user = request
.authenticate(&DatabaseUserCredentials::new(
.authenticate(&crate::auth::db::DatabaseUserCredentials::new(
login_form.username,
// TODO unify auth::Password and forms::fields::Password
flareon::auth::Password::new(login_form.password.into_string()),
))
.await?;

#[cfg(not(any(feature = "sqlite", feature = "postgres", feature = "mysql")))]
let mut user: Option<Box<dyn crate::auth::User + Send + Sync>> = None;

if let Some(user) = user {
request.login(user).await?;
Ok(true)
Expand Down
72 changes: 44 additions & 28 deletions flareon/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//!
//! For the default way to store users in the database, see the [`db`] module.

#[cfg(feature = "db")]
pub mod db;

use std::any::Any;
Expand All @@ -14,16 +15,15 @@ use std::sync::Arc;

use async_trait::async_trait;
use chrono::{DateTime, FixedOffset};
use flareon::config::SecretKey;
use flareon::db::impl_postgres::PostgresValueRef;
#[cfg(test)]
use mockall::automock;
use password_auth::VerifyError;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use thiserror::Error;

use crate::db::impl_sqlite::SqliteValueRef;
use crate::config::SecretKey;
#[cfg(feature = "db")]
use crate::db::{ColumnType, DatabaseField, FromDbValue, SqlxValueRef, ToDbValue};
use crate::request::{Request, RequestExt};

Expand Down Expand Up @@ -403,20 +403,35 @@ impl Debug for PasswordHash {

const MAX_PASSWORD_HASH_LENGTH: u32 = 128;

#[cfg(feature = "db")]
impl DatabaseField for PasswordHash {
const TYPE: ColumnType = ColumnType::String(MAX_PASSWORD_HASH_LENGTH);
}

#[cfg(feature = "db")]
impl FromDbValue for PasswordHash {
fn from_sqlite(value: SqliteValueRef) -> flareon::db::Result<Self> {
#[cfg(feature = "sqlite")]
fn from_sqlite(value: crate::db::impl_sqlite::SqliteValueRef) -> flareon::db::Result<Self> {
PasswordHash::new(value.get::<String>()?).map_err(flareon::db::DatabaseError::value_decode)
}

fn from_postgres(value: PostgresValueRef) -> flareon::db::Result<Self> {
#[cfg(feature = "postgres")]
fn from_postgres(
value: crate::db::impl_postgres::PostgresValueRef,
) -> flareon::db::Result<Self> {
PasswordHash::new(value.get::<String>()?).map_err(flareon::db::DatabaseError::value_decode)
}

#[cfg(feature = "mysql")]
fn from_mysql(value: crate::db::impl_mysql::MySqlValueRef) -> crate::db::Result<Self>
where
Self: Sized,
{
PasswordHash::new(value.get::<String>()?).map_err(flareon::db::DatabaseError::value_decode)
}
}

#[cfg(feature = "db")]
impl ToDbValue for PasswordHash {
fn to_sea_query_value(&self) -> sea_query::Value {
self.0.clone().into()
Expand Down Expand Up @@ -710,6 +725,28 @@ pub trait AuthBackend: Send + Sync {
) -> Result<Option<Box<dyn User + Send + Sync>>>;
}

#[derive(Debug, Copy, Clone)]
pub struct NoAuthBackend;

#[async_trait]
impl AuthBackend for NoAuthBackend {
async fn authenticate(
&self,
_request: &Request,
_credentials: &(dyn Any + Send + Sync),
) -> Result<Option<Box<dyn User + Send + Sync>>> {
Ok(None)
}

async fn get_by_id(
&self,
_request: &Request,
_id: UserId,
) -> Result<Option<Box<dyn User + Send + Sync>>> {
Ok(None)
}
}

#[cfg(test)]
mod tests {
use std::sync::Mutex;
Expand All @@ -720,27 +757,6 @@ mod tests {
use crate::config::ProjectConfig;
use crate::test::TestRequestBuilder;

struct NoUserAuthBackend;

#[async_trait]
impl AuthBackend for NoUserAuthBackend {
async fn authenticate(
&self,
_request: &Request,
_credentials: &(dyn Any + Send + Sync),
) -> Result<Option<Box<dyn User + Send + Sync>>> {
Ok(None)
}

async fn get_by_id(
&self,
_request: &Request,
_id: UserId,
) -> Result<Option<Box<dyn User + Send + Sync>>> {
Ok(None)
}
}

struct MockAuthBackend<F> {
return_user: F,
}
Expand Down Expand Up @@ -894,7 +910,7 @@ mod tests {

#[tokio::test]
async fn user_anonymous() {
let mut request = test_request_with_auth_backend(NoUserAuthBackend {});
let mut request = test_request_with_auth_backend(NoAuthBackend {});

let user = request.user().await.unwrap();
assert!(!user.is_authenticated());
Expand Down Expand Up @@ -955,7 +971,7 @@ mod tests {
/// session (can happen if the user is deleted from the database)
#[tokio::test]
async fn logout_on_invalid_user_id_in_session() {
let mut request = test_request_with_auth_backend(NoUserAuthBackend {});
let mut request = test_request_with_auth_backend(NoAuthBackend {});

request
.session_mut()
Expand Down
17 changes: 16 additions & 1 deletion flareon/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use derive_builder::Builder;
use derive_more::Debug;
use subtle::ConstantTimeEq;

#[cfg(feature = "db")]
use crate::auth::db::DatabaseUserBackend;
use crate::auth::AuthBackend;

Expand Down Expand Up @@ -63,6 +64,7 @@ pub struct ProjectConfig {
#[debug("..")]
#[builder(setter(custom))]
auth_backend: Arc<dyn AuthBackend>,
#[cfg(feature = "db")]
database_config: DatabaseConfig,
}

Expand All @@ -81,17 +83,20 @@ impl ProjectConfigBuilder {
.auth_backend
.clone()
.unwrap_or_else(default_auth_backend),
#[cfg(feature = "db")]
database_config: self.database_config.clone().unwrap_or_default(),
}
}
}

#[cfg(feature = "db")]
#[derive(Debug, Clone, Builder)]
pub struct DatabaseConfig {
#[builder(setter(into))]
url: String,
}

#[cfg(feature = "db")]
impl DatabaseConfig {
#[must_use]
pub fn builder() -> DatabaseConfigBuilder {
Expand All @@ -104,6 +109,7 @@ impl DatabaseConfig {
}
}

#[cfg(feature = "db")]
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
Expand All @@ -119,7 +125,15 @@ impl Default for ProjectConfig {
}

fn default_auth_backend() -> Arc<dyn AuthBackend> {
Arc::new(DatabaseUserBackend::new())
#[cfg(feature = "db")]
{
Arc::new(DatabaseUserBackend::new())
}

#[cfg(not(any(feature = "sqlite", feature = "postgres", feature = "mysql")))]
{
Arc::new(flareon::auth::NoAuthBackend)
}
}

impl ProjectConfig {
Expand All @@ -144,6 +158,7 @@ impl ProjectConfig {
}

#[must_use]
#[cfg(feature = "db")]
pub fn database_config(&self) -> &DatabaseConfig {
&self.database_config
}
Expand Down
Loading

0 comments on commit 764964c

Please sign in to comment.