Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions Cargo.lock

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

15 changes: 11 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ edition = "2024"

[features]
default = []
cloudevents = ["dep:cloudevents-sdk"]
testcontainer = ["dep:testcontainers"]

[dependencies]
thiserror = "2.0.12"
chrono = { version = "0.4.41", features = ["serde"] }
cloudevents-sdk = { version = "0.8.0", features = ["reqwest"], optional = true }
url = "2.5.4"
testcontainers = { version = "0.24.0", features = ["http_wait"], optional = true }
reqwest = { version = "0.12.15", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
testcontainers = { version = "0.24.0", features = [
"http_wait",
], optional = true }
thiserror = "2.0.12"

[dev-dependencies]
eventsourcingdb-client-rust = {path = ".", features = ["testcontainer"]}
reqwest = "0.12.15"
eventsourcingdb-client-rust = { path = ".", features = ["testcontainer"] }
testcontainers = { version = "0.24.0", features = ["http_wait"] }
tokio = { version = "1.44.2", features = ["full"] }
tokio-test = "0.4.4"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The official Rust client SDK for [EventSourcingDB](https://www.eventsourcingdb.i
This is a work in progress and not yet ready for production use.
Based on the [compliance criteria](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/) the SDK covers these criteria:

- [Essentials](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#essentials)
- 🚀 [Essentials](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#essentials)
- ❌ [Writing Events](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#writing-events)
- ❌ [Reading Events](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#reading-events)
- ❌ [Using EventQL](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#using-eventql)
Expand Down
152 changes: 152 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! Client for the [EventsourcingDB](https://www.eventsourcingdb.io/) API.
//!
//! To use the client, create it with the base URL and API token of your [EventsourcingDB](https://www.eventsourcingdb.io/) instance.
//! ```
//! # tokio_test::block_on(async {
//! # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
//! let db_url = "http://localhost:3000/";
//! let api_token = "secrettoken";
//! # let db_url = container.get_base_url().await.unwrap();
//! # let api_token = container.get_api_token();
//! let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
//! client.ping().await.expect("Failed to ping");
//! client.verify_api_token().await.expect("Failed to verify API token");
//! # })
//! ```
//!
//! With the code above you can verify that the DB is reachable and that the API token is valid.
//! If this works, it means that the client is correctly configured and you can use it to make requests to the DB.

mod client_request;

use client_request::{ClientRequest, PingRequest, VerifyApiTokenRequest};

use reqwest;
use url::Url;

use crate::error::ClientError;

/// Client for an [EventsourcingDB](https://www.eventsourcingdb.io/) instance.
#[derive(Debug)]
pub struct Client {
base_url: Url,
api_token: String,
client: reqwest::Client,
}

impl Client {
/// Creates a new client instance based on the base URL and API token
pub fn new(base_url: Url, api_token: impl Into<String>) -> Self {
Client {
base_url,
api_token: api_token.into(),
client: reqwest::Client::new(),
}
}

/// Get the base URL of the client to use for API calls
/// ```
/// # use url::Url;
/// # use eventsourcingdb_client_rust::client::Client;
/// # let client = Client::new("http://localhost:8080/".parse().unwrap(), "token");
/// let base_url = client.get_base_url();
/// # assert_eq!(base_url.as_str(), "http://localhost:8080/");
/// ```
#[must_use]
pub fn get_base_url(&self) -> &Url {
&self.base_url
}

/// Get the API token of the client to use for API calls
/// ```
/// # use eventsourcingdb_client_rust::client::Client;
/// # use url::Url;
/// # let client = Client::new("http://localhost:8080/".parse().unwrap(), "secrettoken");
/// let api_token = client.get_api_token();
/// # assert_eq!(api_token, "secrettoken");
/// ```
#[must_use]
pub fn get_api_token(&self) -> &str {
&self.api_token
}

/// Utility function to request an endpoint of the API.
///
/// # Errors
/// This function will return an error if the request fails or if the URL is invalid.
async fn request<R: ClientRequest>(&self, endpoint: R) -> Result<R::Response, ClientError> {
let url = self
.base_url
.join(endpoint.url_path())
.map_err(ClientError::URLParseError)?;

let request = match endpoint.method() {
reqwest::Method::GET => self.client.get(url),
reqwest::Method::POST => self.client.post(url),
_ => return Err(ClientError::InvalidRequestMethod),
}
.header("Authorization", format!("Bearer {}", self.api_token));
let request = if let Some(body) = endpoint.body() {
request
.header("Content-Type", "application/json")
.json(&body?)
} else {
request
};

let response = request.send().await?;

if response.status().is_success() {
let result = response.json().await?;
endpoint.validate_response(&result)?;
Ok(result)
} else {
Err(ClientError::DBError(
response.status(),
response.text().await.unwrap_or_default(),
))
}
}

/// Pings the DB instance to check if it is reachable.
///
/// ```
/// # tokio_test::block_on(async {
/// # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
/// let db_url = "http://localhost:3000/";
/// let api_token = "secrettoken";
/// # let db_url = container.get_base_url().await.unwrap();
/// # let api_token = container.get_api_token();
/// let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
/// client.ping().await.expect("Failed to ping");
/// # })
/// ```
///
/// # Errors
/// This function will return an error if the request fails or if the URL is invalid.
pub async fn ping(&self) -> Result<(), ClientError> {
let _ = self.request(PingRequest).await?;
Ok(())
}

/// Verifies the API token by sending a request to the DB instance.
///
/// ```
/// # tokio_test::block_on(async {
/// # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
/// let db_url = "http://localhost:3000/";
/// let api_token = "secrettoken";
/// # let db_url = container.get_base_url().await.unwrap();
/// # let api_token = container.get_api_token();
/// let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
/// client.verify_api_token().await.expect("Failed to ping");
/// # })
/// ```
///
/// # Errors
/// This function will return an error if the request fails or if the URL is invalid.
pub async fn verify_api_token(&self) -> Result<(), ClientError> {
let _ = self.request(VerifyApiTokenRequest).await?;
Ok(())
}
}
65 changes: 65 additions & 0 deletions src/client/client_request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! This is a purely internal module to represent client requests to the database.

use reqwest::Method;
use serde_json::Value;

use crate::{error::ClientError, event::ManagementEvent};

/// Represents a request to the database client
pub trait ClientRequest {
const URL_PATH: &'static str;
const METHOD: Method;
type Response: serde::de::DeserializeOwned;

/// Returns the URL path for the request
fn url_path(&self) -> &'static str {
Self::URL_PATH
}

/// Returns the http method type for the request
fn method(&self) -> Method {
Self::METHOD
}

/// Returns the body for the request
fn body(&self) -> Option<Result<Value, ClientError>> {
None
}

/// Validate the response from the database
fn validate_response(&self, _response: &Self::Response) -> Result<(), ClientError> {
Ok(())
}
}

/// Ping the Database instance
#[derive(Debug, Clone, Copy)]
pub struct PingRequest;

impl ClientRequest for PingRequest {
const URL_PATH: &'static str = "/api/v1/ping";
const METHOD: Method = Method::GET;
type Response = ManagementEvent;

fn validate_response(&self, response: &Self::Response) -> Result<(), ClientError> {
(response.ty() == "io.eventsourcingdb.api.ping-received")
.then_some(())
.ok_or(ClientError::PingFailed)
}
}

/// Verify the API token
#[derive(Debug, Clone, Copy)]
pub struct VerifyApiTokenRequest;

impl ClientRequest for VerifyApiTokenRequest {
const URL_PATH: &'static str = "/api/v1/verify-api-token";
const METHOD: Method = Method::POST;
type Response = ManagementEvent;

fn validate_response(&self, response: &Self::Response) -> Result<(), ClientError> {
(response.ty() == "io.eventsourcingdb.api.api-token-verified")
.then_some(())
.ok_or(ClientError::APITokenInvalid)
}
}
Loading