diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4731afe --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# All the following variables can be found under your application/tenant settings in Auth0 + +# Your OIDC client +OIDC_CLIENT_ID= +# Your OIDC client secret +OIDC_CLIENT_SECRET= +# Your OIDC domain +OIDC_DOMAIN= +# Your OIDC audience +OIDC_AUDIENCE= + +# Your database url +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres diff --git a/.gitignore b/.gitignore index 00ee64d..bf15d6e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ target target_analyser .DS_Store .vscode -**/**.DS_Store \ No newline at end of file +**/**.DS_Store +pgdata \ No newline at end of file diff --git a/README.md b/README.md index 8b5da87..98d26f1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RUST Backend Template - GraphQL API/PostgreSQL database/JWKS authentication +# *WIP* RUST Backend template: GraphQL/PostgreSQL/JWKS This repository provide a production ready stateless backend application template written in Rust. Its mainely design to be the backend of a SaaS or mobile application backend featuring a GraphQL API, a PostgreSQL database (that can be embedded in the application or external) and a JWKS authentication compatible with Auth0 or any other provider supporting JWKS and OpenID Connect. @@ -26,12 +26,69 @@ But even better it came with everythings you need to easyly build proper develop - Feature integration tests execution with isolated database and parallel execution - Multiplatform (Linux/MacOS/Windows) -## How to run the project with minimal setup -In this section we will see how to run the project with a minimal setup, without a persistent database and without Auth0. +## Demo frontend +This template came with an fully featured example of frontend application that uses the `expo` framework to build a mobile/web application that use the API and perform authentication using Auth0. + +## How to prepare and run the project +There is various way to run the project depending on your needs. +For development, you can use the `embedded-database` feature to have a fast setup with an embeded database. +For production or regular development, you will want to use an externaly installed database. ### Prerequisites - Rust nightly toolchain with rustc version >= 1.84 -- `sqlx_cli` golbaly installed (i.e `cargo install sqlx-cli`) + +### OIDC Provider +You need to setup an OIDC provider that support JWKS and OpenID Connect in order to use the authentication feature. +To do so we will use Auth0 as an example but you can use any other OpenID Connect provider that support JWKS and OpenID Connect. + +#### Auth0 +You will need to create a new application in your Auth0 tenant and get the following information: +- Client ID +- Client Secret +- Domain +- Audience + +Here is a picture of the Auth0 application settings: + +![Auth0 Application Settings](./images/auth0-application-settings.png) + +The audience is the API identifier, you can find it under the "settings" section of your application: + +![Auth0 API Identifier](./images/auth0-api-identifier.png) + + +### Preparing your environment +You can use the `.env.example` as a template to create your own `.env` file with the correct environment variables. ### Running the project +#### Development +To run the project in development mode, you can use the following command: + +```bash +cargo run --features embedded-database +``` + +or if you want to run the project without the embedded database: +> You also can use the `DATABASE_URL` environment variable to set the database url. +```bash +cargo run --database-url +``` + +```bash +cargo run +``` + +Once the application is running, you can test the API using your browser and directly access the GraphQL playground at `http:///graphql` + +Once whithin the playground you can test the API with the following query: +```graphql +query { + getCurrentUserFeed(category: HOME, limit: 0) { + offset + posts { + content + } + } +} +``` diff --git a/binaries/rave-app-backend/src/main.rs b/binaries/rave-app-backend/src/main.rs index 92ab1d7..fa3006f 100644 --- a/binaries/rave-app-backend/src/main.rs +++ b/binaries/rave-app-backend/src/main.rs @@ -1,10 +1,8 @@ -#![feature(exitcode_exit_method)] - -use std::process::ExitCode; +use std::process::{abort, ExitCode}; use dotenv::dotenv; -use tracing::{error, info}; use rave_api::prelude::*; +use tracing::{error, info}; mod log; use clap::Parser; @@ -16,41 +14,54 @@ struct Args { short, long, conflicts_with = "database_url", - help = "Name of the embedded database (will be created if not exists). When using embedded database, the DATABASE_URL environment variable is ignored.")] + help = "Name of the embedded database (will be created if not exists). When using embedded database, the DATABASE_URL environment variable is ignored. The database is not persisted by default but setting the `PG_DATA_DIR` environment variable will make it so." + )] #[cfg(feature = "embedded-database")] embeded_database: Option, - #[arg(short, long, help = "Listen address in format IP:PORT (use environment variable LISTEN_ADDRESS if not set)")] + #[arg( + short, + long, + help = "Listen address in format IP:PORT (use environment variable LISTEN_ADDRESS if not set)" + )] address: Option, - #[arg(short, long, help = "Database URL (use environment variable DATABASE_URL if not set)")] + #[arg( + short, + long, + help = "Database URL (use environment variable DATABASE_URL if not set)" + )] database_url: Option, } - #[tokio::main] async fn main() { dotenv().ok(); log::init(); - let args = Args::parse(); - let database_url; - #[cfg(feature="embedded-database")] { + let database_url; + // When not using the `embedded_database` feature, just use `args.database_url` as is + #[cfg(not(feature = "embedded-database"))] + { + database_url = args.database_url + } + // When using the `embedded-database` feature, handle embedded database creation + #[cfg(feature = "embedded-database")] + { use rave_embedded_database::prelude::*; + if let Some(embedded) = args.embeded_database { + // Handle embedded database creation let database_pool = EmbeddedDatabasePool::new().await.unwrap(); let database = database_pool.create_database(Some(embedded)).await.unwrap(); database_url = Some(database.connection_string()); } else { + // Use regular database_url or env variable if not set database_url = args.database_url; } } - #[cfg(not(feature="embedded-database"))] { - database_url = args.database_url - } - let options = match RaveApiOptions::try_from_env(args.address, database_url) { Ok(options) => options, Err(e) => return error!("{}", e), @@ -60,9 +71,7 @@ async fn main() { Ok(_) => info!("Server stopped gracefully"), Err(e) => { error!("Server stopped with error: {}", e); - - // Exit the process with platform specific failure exit status - ExitCode::FAILURE.exit_process() + abort() } }; } diff --git a/crates/rave-api/src/graphql/query/feed.rs b/crates/rave-api/src/graphql/query/feed.rs index b8ba8ba..921db64 100644 --- a/crates/rave-api/src/graphql/query/feed.rs +++ b/crates/rave-api/src/graphql/query/feed.rs @@ -1,12 +1,10 @@ use crate::{ prelude::*, - services::{ - feed_provider::{FeedCategory, FeedChunk, FeedOffset, FeedProvider}, - }, + services::feed_provider::{FeedCategory, FeedChunk, FeedOffset, FeedProvider}, }; use async_graphql::{Context, Object, Result}; -use rave_entity::{async_graphql}; +use rave_entity::async_graphql; #[derive(Default)] pub struct FeedQuery; @@ -20,10 +18,10 @@ impl FeedQuery { category: FeedCategory, limit: usize, ) -> Result { - // let api_user = ctx.data::()?; + let api_user = ctx.data::()?; let feed_provider = ctx.data::()?; let chunk = feed_provider - .get(None, Uuid::new_v4(), category, limit, None) + .get(None, api_user, category, limit, None) .await?; tracing::info!( offset = chunk.offset, diff --git a/crates/rave-api/src/graphql/query/mod.rs b/crates/rave-api/src/graphql/query/mod.rs index 5cd5767..d286d54 100644 --- a/crates/rave-api/src/graphql/query/mod.rs +++ b/crates/rave-api/src/graphql/query/mod.rs @@ -1,13 +1,8 @@ use rave_entity::async_graphql; - -pub mod user; pub mod feed; -pub use user::UserQuery; - use self::feed::FeedQuery; - -// Add your other ones here to create a unified Query object -// e.x. Query(NoteQuery, OtherQuery, OtherOtherQuery) +// You can add other queries here to create a unified Query object +// e.x. Query(FeedQuery, OtherQuery) #[derive(async_graphql::MergedObject, Default)] -pub struct Query(UserQuery, FeedQuery); \ No newline at end of file +pub struct Query(FeedQuery); \ No newline at end of file diff --git a/crates/rave-api/src/graphql/query/user.rs b/crates/rave-api/src/graphql/query/user.rs deleted file mode 100644 index 02d75b6..0000000 --- a/crates/rave-api/src/graphql/query/user.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::prelude::*; - -use async_graphql::{Context, Object, Result}; - -#[derive(Default)] -pub struct UserQuery; - -#[Object] -impl UserQuery { - async fn get_current_user(&self, ctx: &Context<'_>) -> Result> { - dbg!(ctx.field()); - // let db = ctx.data::()?; - // let user = sqlx::query_as!( - // PublicUser, - // r#"SELECT external_user_id, entity_sid, name, email FROM public_users WHERE entity_sid = '1'"# - // ) - // .fetch_one(&db.pool) - // .await?; - unimplemented!() - // Ok(Some(user)) - } - -} diff --git a/crates/rave-api/src/lib.rs b/crates/rave-api/src/lib.rs index d9e2221..e2c69ef 100644 --- a/crates/rave-api/src/lib.rs +++ b/crates/rave-api/src/lib.rs @@ -34,6 +34,8 @@ pub mod options; pub type ApiSchema = Schema; +/// The API state contains the GraphQL schema and may contains other state in the future +/// > It is important to keep in mind that the state must be optional or shared using a cache system to keep the ability to scale horizontally the api #[derive(Clone)] pub struct ApiState { pub schema: ApiSchema, @@ -41,36 +43,37 @@ pub struct ApiState { #[instrument(skip(options))] pub async fn serve(options: RaveApiOptions) -> RaveApiResult<()> { - let db = Database::new().await?; + // Initialize the database pool + let db = Database::new(&options.database_url).await?; - let schema: ApiSchema = build_schema(db.clone()) - .await?; - let state = ApiState { schema }; - let iam = Iam::init(db, options.auth0.clone()) - .await?; + // Initialize the GraphQL schema and wrap it in the `ApiState`` + let state = ApiState { + schema: build_schema(db.clone()).await?, + }; + + // Initialize the authentication layer + let authentication_layer = Extension(Iam::init(db, options.auth0.clone()).await?); + + // Initialize the tracing layer + let tracing_layer = TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { + let matched_path = request + .extensions() + .get::() + .map(MatchedPath::as_str); + info_span!( + "http_request", + method = ?request.method(), + matched_path, + some_other_field = tracing::field::Empty, + ) + }); let app = Router::new() - // .layer(Extension(Arc::new(iam))) .route("/", get(graphiql).post(graphql_handler)) - .layer( - TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { - let matched_path = request - .extensions() - .get::() - .map(MatchedPath::as_str); - info_span!( - "http_request", - method = ?request.method(), - matched_path, - some_other_field = tracing::field::Empty, - ) - }), - ) - .layer(Extension(iam)) + .layer(tracing_layer) + .layer(authentication_layer) .with_state(state); - tracing::info!("starting server on {}", options.listen_address); - axum::Server::bind(&options.listen_address) .serve(app.into_make_service()) .await @@ -78,6 +81,12 @@ pub async fn serve(options: RaveApiOptions) -> RaveApiResult<()> { Ok(()) } +/// The GraphQL handler is the entry point for all GraphQL requests that came from the webserver +/// +/// # Arguments +/// * `state` - The global state of the app +/// * `user` - The user that made the request (extracted from the JWT) +/// * `req` - The GraphQL request #[instrument(skip(req, user, schema), fields(api_user = %user))] async fn graphql_handler( State(ApiState { schema, .. }): State, diff --git a/crates/rave-api/src/options.rs b/crates/rave-api/src/options.rs index 89367ac..ebbbf21 100644 --- a/crates/rave-api/src/options.rs +++ b/crates/rave-api/src/options.rs @@ -22,11 +22,11 @@ macro_rules! opt_from_env { pub struct RaveApiOptions { pub listen_address: SocketAddr, pub database_url: String, - pub auth0: Auth0Options, + pub auth0: AuthOptions, } #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Auth0Options { +pub struct AuthOptions { pub client_id: String, pub client_secret: String, pub domain: String, @@ -43,22 +43,22 @@ impl RaveApiOptions { .parse() .map_err(|e| RaveApiError::Config(format!("invalid listen address: {}", e)))?, database_url: opt_from_env!("DATABASE_URL", database_url)?, - auth0: Auth0Options::try_from_env()?, + auth0: AuthOptions::try_from_env()?, }) } } -impl Auth0Options { +impl AuthOptions { pub fn oidc_url(&self) -> String { format!("https://{}/.well-known/openid-configuration", self.domain) } pub fn try_from_env() -> RaveApiResult { - Ok(Auth0Options { - client_id: opt_from_env!("AUTH0_CLIENT_ID")?, - client_secret: opt_from_env!("AUTH0_CLIENT_SECRET")?, - domain: opt_from_env!("AUTH0_DOMAIN")?, - audience: opt_from_env!("AUTH0_AUDIENCE")?, + Ok(AuthOptions { + client_id: opt_from_env!("OIDC_CLIENT_ID")?, + client_secret: opt_from_env!("OIDC_CLIENT_SECRET")?, + domain: opt_from_env!("OIDC_DOMAIN")?, + audience: opt_from_env!("OIDC_AUDIENCE")?, }) } } diff --git a/crates/rave-api/src/prelude.rs b/crates/rave-api/src/prelude.rs index 45706f8..a9d1be0 100644 --- a/crates/rave-api/src/prelude.rs +++ b/crates/rave-api/src/prelude.rs @@ -5,7 +5,8 @@ pub use axum_macros::debug_handler; pub use crate::error::*; pub use crate::services::database::Database; -pub use crate::options::{RaveApiOptions, Auth0Options}; +pub use crate::options::{RaveApiOptions, AuthOptions}; +pub (crate)use crate::AnyApiUser; pub (crate) use async_recursion::async_recursion; pub (crate) use async_trait::async_trait; diff --git a/crates/rave-api/src/services/database.rs b/crates/rave-api/src/services/database.rs index c7b681e..cc8524d 100644 --- a/crates/rave-api/src/services/database.rs +++ b/crates/rave-api/src/services/database.rs @@ -1,3 +1,5 @@ +//! Database service that provides a SQLx connection pool to the rest of the application + use crate::prelude::*; use sqlx::pool::PoolConnection; @@ -10,11 +12,9 @@ pub struct Database { } impl Database { - pub async fn new() -> RaveApiResult { + pub async fn new(url: &str) -> RaveApiResult { let pool = - Pool::::connect(&std::env::var("DATABASE_URL").map_err(|_| { - RaveApiError::DatabaseConfig("`DATABASE_URL` must be set".to_string()) - })?) + Pool::::connect(url) .await .map_err(|e| { RaveApiError::DatabaseConfig(format!("failed to create connections pool: {}", e)) diff --git a/crates/rave-api/src/services/feed_provider.rs b/crates/rave-api/src/services/feed_provider.rs index a699080..36ed1dd 100644 --- a/crates/rave-api/src/services/feed_provider.rs +++ b/crates/rave-api/src/services/feed_provider.rs @@ -52,7 +52,7 @@ impl FeedProvider { pub async fn get( &self, feed_uid: Option, - owner_uid: Uuid, + requested_by: &AnyApiUser, category: FeedCategory, limit: usize, offset: Option, diff --git a/crates/rave-api/src/services/iam/mod.rs b/crates/rave-api/src/services/iam/mod.rs index 67d6591..156d10f 100644 --- a/crates/rave-api/src/services/iam/mod.rs +++ b/crates/rave-api/src/services/iam/mod.rs @@ -1,6 +1,6 @@ use std::borrow::Borrow; -use crate::{options::Auth0Options, prelude::*}; +use crate::{options::AuthOptions, prelude::*}; use axum::{ extract::FromRequestParts, headers::{authorization::Bearer, Authorization}, @@ -26,18 +26,17 @@ pub mod models; #[derive(Clone)] pub struct Iam { pub client: Arc, - pub auth0_options: Auth0Options, + pub auth0_options: AuthOptions, jwks: Arc>, database: Database, } impl Iam { #[instrument(skip(auth0_options, database), err, fields(domain = %auth0_options.domain, audience = %auth0_options.audience))] - pub async fn init(database: Database, auth0_options: Auth0Options) -> IamResult { + pub async fn init(database: Database, auth0_options: AuthOptions) -> IamResult { tracing::info!("feetching jwks"); let jwks = Jwks::from_oidc_url(&auth0_options.oidc_url(), auth0_options.audience.clone()).await?; - Ok(Self { database, client: Arc::new(Client::new()), diff --git a/crates/rave-embedded-database/src/lib.rs b/crates/rave-embedded-database/src/lib.rs index 382ba8a..7c37c31 100644 --- a/crates/rave-embedded-database/src/lib.rs +++ b/crates/rave-embedded-database/src/lib.rs @@ -3,6 +3,9 @@ //! It can handle multiple databases in parallel allowing to be used in parallel tests environments. //! It featires database migration by embedding the migrations located at the root migrations folder. +const PG_VERSION: &str = "16.0.0"; + +use std::path::PathBuf; use std::sync::{Arc, RwLock}; use error::EmbeddedDatabaseResult; @@ -16,6 +19,7 @@ pub struct EmbeddedDatabasePool { engine: Arc>, } +#[allow(dead_code)] pub struct EmbeddedDatabase { pool: EmbeddedDatabasePool, name: String, @@ -27,7 +31,13 @@ pub struct EmbeddedDatabase { impl EmbeddedDatabasePool { pub async fn new() -> EmbeddedDatabaseResult { - let mut db = PostgreSQL::default(); + let mut settings = postgresql_embedded::Settings::default(); + if let Ok(data_dir) = std::env::var("PG_DATA_DIR") { + settings.data_dir = PathBuf::from(data_dir); + settings.temporary = false; + settings.version = VersionReq::parse(PG_VERSION).expect("Invalid PG version"); + } + let mut db = PostgreSQL::new(settings); info!("Setting up database (this might take a while) ..."); db.setup().await?; info!("Starting database ..."); @@ -80,7 +90,9 @@ impl EmbeddedDatabase { } pub async fn get_pool(&self) -> sqlx::Pool { - sqlx::Pool::connect(&self.connection_string()).await.unwrap() + sqlx::Pool::connect(&self.connection_string()) + .await + .unwrap() } pub fn connection_string(&self) -> String { diff --git a/crates/rave-entity/src/tables/contents.rs b/crates/rave-entity/src/tables/contents.rs index 474f539..4256851 100644 --- a/crates/rave-entity/src/tables/contents.rs +++ b/crates/rave-entity/src/tables/contents.rs @@ -6,11 +6,3 @@ use sqlx::types::Uuid; pub enum ContentRowJson { Text(String), } - -pub struct ContentRow { - sid: i32, - uid: Uuid, - entity_sid: i32, - // created_at, - // updated_at -} diff --git a/images/auth0-api-identifier.png b/images/auth0-api-identifier.png new file mode 100644 index 0000000..88f01e9 Binary files /dev/null and b/images/auth0-api-identifier.png differ diff --git a/images/auth0-application-settings.png b/images/auth0-application-settings.png new file mode 100644 index 0000000..a91da61 Binary files /dev/null and b/images/auth0-application-settings.png differ diff --git a/scripts/database/recreate.sh b/scripts/database/recreate.sh deleted file mode 100755 index 2ccbbe3..0000000 --- a/scripts/database/recreate.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -source .env - -if !(echo "$DATABASE_URL" | grep -E "localhost|127.0.0.1|0.0.0.0" > /dev/null) && [ ! "$FORCE" == "true" ]; then - echo DATABASE_URL is not localhost, skipping database recreation - echo $DATABASE_URL - exit 1 -fi - -sqlx database drop -sqlx database create -sqlx migrate run \ No newline at end of file