diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a57dcd11..11b66101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,7 @@ jobs: fail-fast: false matrix: crate: + - arangodb - diesel - lapin - postgres @@ -112,6 +113,9 @@ jobs: feature: postgres - crate: diesel feature: sqlite + exclude: + - crate: arangodb + feature: serde runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -158,6 +162,7 @@ jobs: - { crate: deadpool-runtime, msrv: '1.75.0' } - { crate: deadpool-sync, msrv: '1.75.0' } - { crate: deadpool, msrv: '1.75.0' } + - { crate: deadpool-arangodb, msrv: '1.75.0' } - { crate: deadpool-diesel, msrv: '1.78.0' } - { crate: deadpool-lapin, msrv: '1.75.0' } - { crate: deadpool-postgres, msrv: '1.75.0' } @@ -188,6 +193,7 @@ jobs: - deadpool-runtime - deadpool-sync - deadpool + - deadpool-arangodb - deadpool-diesel - deadpool-lapin - deadpool-postgres @@ -195,6 +201,13 @@ jobs: - deadpool-sqlite runs-on: ubuntu-latest services: + arangodb: + image: arangodb + ports: + - 8529:8529 + env: + ARANGO_ROOT_PASSWORD: deadpool + ARANGODB_OVERRIDE_DETECTED_TOTAL_MEMORY: 500M postgres: image: postgres:15.3-alpine ports: @@ -234,6 +247,10 @@ jobs: - run: cargo test -p ${{ matrix.crate }} --all-features env: + ARANGODB__URL: http://127.0.0.1:8529 + ARANGODB__USERNAME: root + ARANGODB__PASSWORD: deadpool + ARANGODB__USE_JWT: "true" PG__HOST: 127.0.0.1 PG__PORT: 5432 PG__USER: deadpool @@ -258,6 +275,7 @@ jobs: - deadpool-runtime - deadpool-sync - deadpool + - deadpool-arangodb - deadpool-diesel - deadpool-lapin - deadpool-postgres diff --git a/Cargo.toml b/Cargo.toml index c5b90ca1..78503e37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ harness = false [workspace] members = [ + "arangodb", "diesel", "lapin", "memcached", diff --git a/README.md b/README.md index c6ed2e8a..d5393453 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ struct Manager {} impl managed::Manager for Manager { type Type = Computer; type Error = Error; - + async fn create(&self) -> Result { Ok(Computer {}) } @@ -99,6 +99,7 @@ Backend | Crate | Latest Version | [tiberius](https://crates.io/crates/tiberius) | [deadpool-tiberius](https://crates.io/crates/deadpool-tiberius) | [![Latest Version](https://img.shields.io/crates/v/deadpool-tiberius.svg)](https://crates.io/crates/deadpool-tiberius) | [r2d2](https://crates.io/crates/r2d2) | [deadpool-r2d2](https://crates.io/crates/deadpool-r2d2) | [![Latest Version](https://img.shields.io/crates/v/deadpool-r2d2.svg)](https://crates.io/crates/deadpool-r2d2) | [rbatis](https://crates.io/crates/rbatis) | [rbatis](https://crates.io/crates/rbatis) | [![Latest Version](https://img.shields.io/crates/v/rbatis.svg)](https://crates.io/crates/rbatis) | +[arangors](https://crates.io/crates/arangors) | [deadpool-arangodb](https://crates.io/crates/deadpool-arangodb) | [![Latest Version](https://img.shields.io/crates/v/deadpool-arangodb.svg)](https://crates.io/crates/deadpool-arangodb) | ### Reasons for yet another connection pool diff --git a/arangodb/Cargo.toml b/arangodb/Cargo.toml new file mode 100644 index 00000000..8e487f7a --- /dev/null +++ b/arangodb/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "deadpool-arangodb" +version = "0.1.0" +edition = "2021" +resolver = "2" +authors = ["Daniel Wiesenberg "] +description = "Dead simple async pool for ArangoDB" +keywords = ["async", "database", "pool", "arango", "arangodb"] +license = "MIT/Apache-2.0" +repository = "https://github.com/bikeshedder/deadpool/arangodb" +readme = "README.md" + +[package.metadata.docs.rs] +all-features = true + +[features] +default = ["rt_tokio_1"] +rt_tokio_1 = ["deadpool/rt_tokio_1", "arangors/reqwest_async"] +#rt_async-std_1 = ["deadpool/rt_async-std_1", "arangors/reqwest_async"] + +[dependencies] +deadpool = { path = "../", version = "0.12.0", default-features = false, features = [ + "managed", + "serde", +] } +arangors = { version = "0.6.0", default-features = false } +serde = { version = "1.0", features = ["derive"] } +url = "2.2" + +[dev-dependencies] +config = { version = "0.13", default-features = false } +dotenv = "0.15.0" +futures = "0.3" +tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } diff --git a/arangodb/README.md b/arangodb/README.md new file mode 100644 index 00000000..bffcabb8 --- /dev/null +++ b/arangodb/README.md @@ -0,0 +1,48 @@ +# Deadpool for ArangoDB [![Latest Version](https://img.shields.io/crates/v/deadpool-arangodb.svg)](https://crates.io/crates/deadpool-arangodb) ![Unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg "Unsafe forbidden") [![Rust 1.54+](https://img.shields.io/badge/rustc-1.54+-lightgray.svg "Rust 1.54+")](https://blog.rust-lang.org/2021/07/29/Rust-1.54.0.html) + +Deadpool is a dead simple async pool for connections and objects +of any type. + +This crate implements a [`deadpool`](https://crates.io/crates/deadpool) +manager for [`ArangoDB`](https://www.arangodb.com/) using [`arangors`](https://crates.io/crates/arangors). + +## Features + +| Feature | Description | Extra dependencies | Default | +| ------- | ----------- | ------------------ | ------- | +| `rt_tokio_1` | Enable support for [tokio](https://crates.io/crates/tokio) crate,
through the usage of [reqwest](https://crates.io/crates/reqwest) as http client | `deadpool/rt_tokio_1`, `arangors/reqwest_async` | yes | +| `rt_async-std_1` | Enable support for [async-std](https://crates.io/crates/config) crate,
through the usage of [surf](https://crates.io/crates/surf) as http client | `deadpool/rt_async-std_1`, `arangors/surf_async` | no | +| `serde` | Enable support for [serde](https://crates.io/crates/serde) crate | `deadpool/serde`, `serde/derive` | no | + +## Example + +```rust +use deadpool_arangodb::{Config, Runtime}; + +#[tokio::main] +async fn main() { + let mut cfg = Config { + url: Some("http://localhost:8529".to_string()), + username: Some("root".to_string()), + password: Some("deadpool".to_string()), + use_jwt: true, + pool: None, + }; + let pool = cfg.create_pool(Runtime::Tokio1).unwrap(); + let mut conn = pool.get().await.unwrap(); + + let db = conn.create_database("deadpool_favorite_foods") + .await.expect("Failed to create database: {:?}"); + + // Do stuff with db... +} +``` + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. diff --git a/arangodb/src/config.rs b/arangodb/src/config.rs new file mode 100644 index 00000000..e82df923 --- /dev/null +++ b/arangodb/src/config.rs @@ -0,0 +1,174 @@ +use std::fmt; + +use deadpool::Runtime; +use serde::Deserialize; +use url::Url; + +use crate::{CreatePoolError, Pool, PoolConfig}; + +/// Configuration object. +/// +/// # Example (from environment) +/// +/// By enabling the `serde` feature you can read the configuration using the +/// [`config`](https://crates.io/crates/config) crate as following: +/// ```env +/// ARANGODB__URL=arangodb.example.com +/// ARANGODB__USERNAME=root +/// ARANGODB__PASSWORD=deadpool +/// ARANGODB__USE_JWT=true +/// ARANGODB__POOL__MAX_SIZE=16 +/// ARANGODB__POOL__TIMEOUTS__WAIT__SECS=2 +/// ARANGODB__POOL__TIMEOUTS__WAIT__NANOS=0 +/// ``` +/// ```rust +/// # use serde; +/// # +/// #[derive(serde::Deserialize)] +/// struct Config { +/// arango: deadpool_arangodb::Config, +/// } +/// +/// impl Config { +/// pub fn from_env() -> Result { +/// let mut cfg = config::Config::new(); +/// cfg.merge(config::Environment::new().separator("__")).unwrap(); +/// cfg.try_into() +/// } +/// } +/// ``` +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + /// ArangoDB URL. + /// + /// See [Arangors Connection](arangors/connection/struct.GenericConnection.html#method.establish_jwt). + pub url: Option, + /// ArangoDB username. + /// If you have not manually created a new user on a ArangoDB instance, then this must be `root`. + /// + /// See [Arangors Connection](arangors/connection/struct.GenericConnection.html#method.establish_jwt). + pub username: Option, + /// ArangoDB password. + /// + /// See [Arangors Connection](arangors/connection/struct.GenericConnection.html#method.establish_jwt). + pub password: Option, + /// If jwt authentication should be used. JWT token expires after 1 month. + /// + /// See [Arangors Connection](arangors/connection/struct.GenericConnection.html#method.establish_jwt). + pub use_jwt: bool, + + /// [`Pool`] configuration. + pub pool: Option, +} + +impl Config { + /// Creates a new [`Config`] from the given URL. + /// + /// Url format is: `http://username:password@localhost:8529/?use_jwt=true`. If `use_jwt` is missing, then it defaults to `true`. + pub fn from_url>(url: U) -> Result { + let url = url.into(); + let url = Url::parse(&url).map_err(|e| ConfigError::InvalidUrl(url, e))?; + + let use_jwt = url + .query_pairs() + .filter(|(name, _)| name == "use_jwt") + .map(|(_, value)| value.to_string()) + .next(); + let use_jwt = match use_jwt { + Some(use_jwt) => use_jwt + .parse() + .map_err(|e| ConfigError::InvalidUseJwt(use_jwt, e))?, + None => true, + }; + + Ok(Config { + url: Some(format!( + "{}://{}:{}", + url.scheme(), + url.host_str().unwrap(), + url.port_or_known_default().unwrap() + )), + username: Some(url.username().to_string()), + password: url.password().map(ToString::to_string), + use_jwt, + pool: None, + }) + } + + /// Creates a new [`Pool`] using this [`Config`]. + /// + /// # Errors + /// + /// See [`BuildError`] and [`ClientError`] for details. + /// + /// [`ClientError`]: arangors::ClientError + pub fn create_pool(&self, runtime: Runtime) -> Result { + let manager = crate::Manager::from_config(self.clone())?; + let pool_config = self.get_pool_config(); + Pool::builder(manager) + .config(pool_config) + .runtime(runtime) + .build() + .map_err(CreatePoolError::Build) + } + + /// Returns [`deadpool::managed::PoolConfig`] which can be used to construct + /// a [`deadpool::managed::Pool`] instance. + #[must_use] + pub fn get_pool_config(&self) -> PoolConfig { + self.pool.unwrap_or_default() + } +} + +impl Default for Config { + fn default() -> Self { + Self { + url: None, + username: None, + password: None, + use_jwt: true, + pool: None, + } + } +} + +/// This error is returned if the configuration contains an error +#[derive(Debug)] +pub enum ConfigError { + /// The `url` is invalid + InvalidUrl(String, url::ParseError), + /// The `use_jwt` part of the URL is invalid + InvalidUseJwt(String, std::str::ParseBoolError), + /// The `use` is `None` + MissingUrl, + /// The `username` is `None` + MissingUsername, + /// The `password` is None + MissingPassword, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidUrl(url, e) => write!(f, "InvalidUrl: {} - Error: {}", url, e), + Self::InvalidUseJwt(use_jwt, e) => write!( + f, + "Could not parse `use_jwt` value: `{}` - Error: {}", + use_jwt, e + ), + Self::MissingUrl => write!(f, "Missing URL"), + Self::MissingUsername => write!(f, "Missing username"), + Self::MissingPassword => write!(f, "Missing password"), + } + } +} + +impl std::error::Error for ConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidUrl(_, e) => Some(e), + Self::InvalidUseJwt(_, e) => Some(e), + _ => None, + } + } +} diff --git a/arangodb/src/lib.rs b/arangodb/src/lib.rs new file mode 100644 index 00000000..9b4b47c3 --- /dev/null +++ b/arangodb/src/lib.rs @@ -0,0 +1,162 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![deny( + nonstandard_style, + rust_2018_idioms, + rustdoc::broken_intra_doc_links, + rustdoc::private_intra_doc_links +)] +#![forbid(non_ascii_idents, unsafe_code)] +#![warn( + deprecated_in_future, + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + unreachable_pub, + unused_import_braces, + unused_labels, + unused_lifetimes, + unused_qualifications, + unused_results +)] + +mod config; + +use std::ops::{Deref, DerefMut}; + +use arangors::{uclient::ClientExt, ClientError, Connection as ArangoConnection}; +use deadpool::managed; +use url::Url; + +pub use arangors; + +pub use self::config::{Config, ConfigError}; + +pub use deadpool::managed::reexports::*; +deadpool::managed_reexports!( + "arangors", + Manager, + managed::Object, + ClientError, + ConfigError +); + +/// Type alias for using [`deadpool::managed::RecycleResult`] with [`arangors`]. +type RecycleResult = managed::RecycleResult; + +/// Wrapper around [`arangors::Connection`]. +/// +/// This structure implements [`std::ops::Deref`] and can therefore +/// be used just like a regular [`arangors::Connection`]. +#[derive(Debug)] +pub struct Connection { + conn: Object, +} + +impl Connection { + /// Takes this [`Connection`] from its [`Pool`] permanently. + /// + /// This reduces the size of the [`Pool`]. + #[must_use] + pub fn take(this: Self) -> ArangoConnection { + Object::take(this.conn) + } +} + +impl From for Connection { + fn from(conn: Object) -> Self { + Self { conn } + } +} + +impl Deref for Connection { + type Target = ArangoConnection; + + fn deref(&self) -> &ArangoConnection { + &self.conn + } +} + +impl DerefMut for Connection { + fn deref_mut(&mut self) -> &mut ArangoConnection { + &mut self.conn + } +} + +impl AsRef for Connection { + fn as_ref(&self) -> &ArangoConnection { + &self.conn + } +} + +impl AsMut for Connection { + fn as_mut(&mut self) -> &mut ArangoConnection { + &mut self.conn + } +} + +/// [`Manager`] for creating and recycling [`arangors`] connections. +/// +/// [`Manager`]: managed::Manager +#[derive(Debug)] +pub struct Manager { + url: String, + username: String, + password: String, + use_jwt: bool, +} + +impl Manager { + /// Creates a new [`Manager`] with the given params. + pub fn new(url: &str, username: &str, password: &str, use_jwt: bool) -> Self { + Self { + url: url.to_string(), + username: username.to_string(), + password: password.to_string(), + use_jwt, + } + } + + /// Creates a new [`Manager`] with the given params. + pub fn from_config(config: Config) -> Result { + Ok(Self { + url: config + .url + .ok_or(CreatePoolError::Config(ConfigError::MissingUrl))?, + username: config + .username + .ok_or(CreatePoolError::Config(ConfigError::MissingUsername))?, + password: config + .password + .ok_or(CreatePoolError::Config(ConfigError::MissingPassword))?, + use_jwt: config.use_jwt, + }) + } +} + +impl managed::Manager for Manager { + type Type = ArangoConnection; + type Error = ClientError; + + async fn create(&self) -> Result { + let conn = if self.use_jwt { + ArangoConnection::establish_jwt(&self.url, &self.username, &self.password).await? + } else { + ArangoConnection::establish_basic_auth(&self.url, &self.username, &self.password) + .await? + }; + + Ok(conn) + } + + async fn recycle(&self, conn: &mut ArangoConnection, _metrics: &Metrics) -> RecycleResult { + let url = Url::parse(&self.url).expect("Url should be valid at this point"); + conn.session() + // I don't know if this is the correct way to do it, but TRACE should allow us to check if the connection is still open, + // if the server answers it's open, if not, then not. + .trace(url, String::default()) + .await + .map(|_| ()) + .map_err(|e| managed::RecycleError::Backend(ClientError::HttpClient(e))) + } +} diff --git a/arangodb/tests/arangodb.rs b/arangodb/tests/arangodb.rs new file mode 100644 index 00000000..770ed49d --- /dev/null +++ b/arangodb/tests/arangodb.rs @@ -0,0 +1,51 @@ +use config::ConfigError; +use serde::Deserialize; + +use deadpool_arangodb::Runtime; + +fn default_dbname() -> String { + "deadpool".to_string() +} + +#[derive(Debug, Default, Deserialize)] +struct Config { + #[serde(default)] + arango: deadpool_arangodb::Config, + #[serde(default = "default_dbname")] + dbname: String, +} + +impl Config { + pub fn from_env() -> Result { + let cfg = config::Config::builder() + .add_source(config::Environment::default().separator("__")) + .build() + .unwrap(); + let mut cfg: Self = cfg.try_deserialize()?; + cfg.arango + .url + .get_or_insert("http://localhost:8529".to_string()); + Ok(cfg) + } +} + +#[tokio::test] +async fn create_database() { + let cfg = Config::from_env().unwrap(); + let pool = cfg.arango.create_pool(Runtime::Tokio1).unwrap(); + let conn = pool.get().await.unwrap(); + + let result = conn.create_database(&cfg.dbname).await; + if let Err(e) = result { + panic!("Failed to create database: {:?}", e) + }; + let result = conn.db(&cfg.dbname).await; + assert!(result.is_ok()); + + let result = conn.drop_database(&cfg.dbname).await; + if let Err(e) = result { + panic!("Failed to drop database: {:?}", e) + }; + let result = conn.db(&cfg.dbname).await; + assert!(result.is_err()); +}