use crate::error::Result;
use crate::provider::{BindingsProvider, LazyEnvBindingsProvider};
use crate::refreshing::{
RefreshingKey, RefreshingKv, RefreshingQueue, RefreshingStorage, RefreshingVault,
RefreshingWorker,
};
use crate::traits::{
BindingsProviderApi, Container, Key, Kv, MessagePayload, Postgres, Queue, QueueMessage,
Sandbox, Storage, Vault, Worker,
};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug)]
pub struct Bindings {
provider: Arc<LazyEnvBindingsProvider>,
}
#[derive(Clone)]
pub struct BoundQueue {
inner: Arc<dyn Queue>,
name: Arc<str>,
}
impl std::fmt::Debug for BoundQueue {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Queue")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl BoundQueue {
pub(crate) fn new(inner: Arc<dyn Queue>, name: impl Into<Arc<str>>) -> Self {
Self {
inner,
name: name.into(),
}
}
pub async fn send(&self, message: MessagePayload) -> Result<()> {
self.inner.send(&self.name, message).await
}
pub async fn send_batch(
&self,
messages: Vec<MessagePayload>,
) -> Result<Vec<crate::traits::QueueSendResult>> {
self.inner.send_batch(&self.name, messages).await
}
pub async fn receive(&self, max_messages: usize) -> Result<Vec<QueueMessage>> {
self.inner.receive(&self.name, max_messages).await
}
pub async fn ack(&self, receipt_handle: &str) -> Result<()> {
self.inner.ack(&self.name, receipt_handle).await
}
pub async fn nack(&self, receipt_handle: &str) -> Result<()> {
self.inner.nack(&self.name, receipt_handle).await
}
pub async fn purge(&self) -> Result<()> {
self.inner.purge(&self.name).await
}
}
impl Bindings {
pub fn from_env() -> Result<Self> {
Self::from_env_map(std::env::vars().collect())
}
pub fn from_env_map(env: HashMap<String, String>) -> Result<Self> {
Ok(Self {
provider: Arc::new(BindingsProvider::from_env_deferred(env)?),
})
}
pub async fn storage(&self, binding_name: &str) -> Result<Arc<dyn Storage>> {
let initial = self.provider.load_storage(binding_name).await?;
Ok(Arc::new(RefreshingStorage::new(
self.provider.clone(),
binding_name.to_string(),
initial,
)))
}
pub async fn key(&self, binding_name: &str) -> Result<Arc<dyn Key>> {
self.provider.load_key(binding_name).await?;
Ok(Arc::new(RefreshingKey::new(
self.provider.clone(),
binding_name.to_string(),
)))
}
pub async fn kv(&self, binding_name: &str) -> Result<Arc<dyn Kv>> {
self.provider.load_kv(binding_name).await?;
Ok(Arc::new(RefreshingKv::new(
self.provider.clone(),
binding_name.to_string(),
)))
}
pub async fn queue(&self, binding_name: &str) -> Result<BoundQueue> {
self.provider.load_queue(binding_name).await?;
let queue: Arc<dyn Queue> = Arc::new(RefreshingQueue::new(
self.provider.clone(),
binding_name.to_string(),
));
Ok(BoundQueue::new(queue, binding_name))
}
pub async fn vault(&self, binding_name: &str) -> Result<Arc<dyn Vault>> {
self.provider.load_vault(binding_name).await?;
Ok(Arc::new(RefreshingVault::new(
self.provider.clone(),
binding_name.to_string(),
)))
}
pub async fn container(&self, binding_name: &str) -> Result<Arc<dyn Container>> {
self.provider.load_container(binding_name).await
}
pub async fn worker(&self, binding_name: &str) -> Result<Arc<dyn Worker>> {
self.provider.load_worker(binding_name).await?;
Ok(Arc::new(RefreshingWorker::new(
self.provider.clone(),
binding_name.to_string(),
)))
}
pub async fn postgres(&self, binding_name: &str) -> Result<Arc<dyn Postgres>> {
self.provider.load_postgres(binding_name).await
}
pub async fn sandbox(&self, binding_name: &str) -> Result<Arc<dyn Sandbox>> {
self.provider.load_sandbox(binding_name).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::binding_env_var;
use crate::traits::MessagePayload;
use alien_core::{Platform, ENV_ALIEN_DEPLOYMENT_TYPE};
use object_store::{path::Path as ObjectPath, PutPayload};
use std::collections::HashMap;
use tempfile::TempDir;
fn base_env() -> HashMap<String, String> {
HashMap::from([(
ENV_ALIEN_DEPLOYMENT_TYPE.to_string(),
Platform::Local.as_str().to_string(),
)])
}
fn with_binding(
mut env: HashMap<String, String>,
binding_name: &str,
json: &str,
) -> HashMap<String, String> {
env.insert(binding_env_var(binding_name), json.to_string());
env
}
#[test]
fn from_env_map_constructs_synchronously_from_injected_env() {
let bindings =
Bindings::from_env_map(base_env()).expect("valid env should construct Bindings");
drop(bindings);
}
#[tokio::test]
async fn storage_delegates_to_local_provider_and_performs_real_io() {
let temp_dir = TempDir::new().expect("tempdir");
let json = format!(
r#"{{"service":"local-storage","storagePath":"{}"}}"#,
temp_dir.path().display()
);
let env = with_binding(base_env(), "files", &json);
let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
let storage = bindings
.storage("files")
.await
.expect("storage binding should load");
let path = ObjectPath::from("greeting.txt");
storage
.put(&path, PutPayload::from(bytes::Bytes::from_static(b"hello")))
.await
.expect("put should succeed");
let fetched = storage
.get(&path)
.await
.expect("get should succeed")
.bytes()
.await
.expect("reading bytes should succeed");
assert_eq!(fetched.as_ref(), b"hello");
}
#[tokio::test]
async fn kv_delegates_to_local_provider_and_performs_real_io() {
let temp_dir = TempDir::new().expect("tempdir");
let json = format!(
r#"{{"service":"local-kv","dataDir":"{}"}}"#,
temp_dir.path().display()
);
let env = with_binding(base_env(), "cache", &json);
let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
let kv = bindings.kv("cache").await.expect("kv binding should load");
kv.put("greeting", b"hi".to_vec(), None)
.await
.expect("put should succeed");
let value = kv
.get("greeting")
.await
.expect("get should succeed")
.expect("value should exist");
assert_eq!(value.value, b"hi");
}
#[tokio::test]
async fn queue_delegates_to_local_provider_and_performs_real_io() {
let temp_dir = TempDir::new().expect("tempdir");
let json = format!(
r#"{{"service":"local-queue","queuePath":"{}"}}"#,
temp_dir.path().join("queue.db").display()
);
let env = with_binding(base_env(), "jobs", &json);
let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
let queue = bindings
.queue("jobs")
.await
.expect("queue binding should load");
queue
.send(MessagePayload::Text("hello".to_string()))
.await
.expect("send should succeed");
let messages = queue.receive(1).await.expect("receive should succeed");
assert_eq!(messages.len(), 1);
}
#[tokio::test]
async fn bound_queue_uses_its_configured_name_for_every_operation() {
let temp_dir = TempDir::new().expect("tempdir");
let json = format!(
r#"{{"service":"local-queue","queuePath":"{}"}}"#,
temp_dir.path().join("queue.db").display()
);
let env = with_binding(base_env(), "jobs", &json);
let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
let queue = bindings
.queue("jobs")
.await
.expect("queue binding should load");
queue
.send(MessagePayload::Text("retry".to_string()))
.await
.expect("send should succeed");
let first = queue.receive(1).await.expect("receive should succeed");
assert_eq!(first.len(), 1);
assert!(
queue
.receive(1)
.await
.expect("receive should succeed")
.is_empty(),
"in-flight message must be hidden before nack"
);
queue
.nack(&first[0].receipt_handle)
.await
.expect("nack should succeed");
let redelivered = queue.receive(1).await.expect("receive should succeed");
assert_eq!(redelivered.len(), 1, "nacked message must be redelivered");
queue.purge().await.expect("purge should succeed");
assert!(
queue
.receive(1)
.await
.expect("receive should succeed")
.is_empty(),
"purge must empty the queue"
);
}
#[tokio::test]
async fn container_exposes_internal_and_optional_public_urls() {
let env = with_binding(
base_env(),
"database",
r#"{"service":"local","containerName":"database","internalUrl":"http://database.internal:5432","publicUrl":"http://localhost:15432"}"#,
);
let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
let container = bindings
.container("database")
.await
.expect("container binding should load");
assert_eq!(
container.get_internal_url(),
"http://database.internal:5432"
);
assert_eq!(container.get_public_url(), Some("http://localhost:15432"));
}
#[tokio::test]
async fn vault_delegates_to_local_provider_and_performs_real_io() {
let temp_dir = TempDir::new().expect("tempdir");
let json = format!(
r#"{{"service":"local-vault","vaultName":"secrets","dataDir":"{}"}}"#,
temp_dir.path().display()
);
let env = with_binding(base_env(), "secrets", &json);
let bindings = Bindings::from_env_map(env).expect("valid env should construct Bindings");
let vault = bindings
.vault("secrets")
.await
.expect("vault binding should load");
vault
.set_secret("api-key", "sekrit")
.await
.expect("set_secret should succeed");
let value = vault
.get_secret("api-key")
.await
.expect("get_secret should succeed");
assert_eq!(value, "sekrit");
vault
.set_secret("db-url", "postgres://…")
.await
.expect("set_secret should succeed");
let mut names = vault
.list_secrets()
.await
.expect("list_secrets should succeed");
names.sort();
assert_eq!(names, vec!["api-key".to_string(), "db-url".to_string()]);
}
#[tokio::test]
async fn missing_storage_binding_returns_binding_not_configured() {
let bindings = Bindings::from_env_map(base_env())
.expect("construction should succeed with no bindings configured");
let error = bindings
.storage("files")
.await
.expect_err("missing binding should error");
assert_eq!(error.code, "BINDING_NOT_CONFIGURED");
assert!(
error.to_string().contains("ALIEN_FILES_BINDING"),
"message should name the env var, got: {error}"
);
}
#[tokio::test]
async fn zero_env_construct_then_missing_binding_is_binding_not_configured() {
for kind in ["storage", "kv", "queue", "vault"] {
let bindings = Bindings::from_env_map(HashMap::new())
.expect("zero-env construction must succeed (platform resolution deferred)");
let error = match kind {
"storage" => bindings.storage("x").await.unwrap_err(),
"kv" => bindings.kv("x").await.unwrap_err(),
"queue" => bindings.queue("x").await.unwrap_err(),
"vault" => bindings.vault("x").await.unwrap_err(),
other => unreachable!("unhandled kind in table test: {other}"),
};
assert_eq!(
error.code, "BINDING_NOT_CONFIGURED",
"{kind}: expected the missing-binding error, not a platform/deployment error: {error}"
);
assert!(
error.to_string().contains("ALIEN_X_BINDING"),
"{kind}: message should name the env var, got: {error}"
);
}
}
#[test]
fn malformed_binding_json_returns_binding_config_invalid_naming_env_var() {
let env = with_binding(base_env(), "files", "not-json");
let error =
Bindings::from_env_map(env).expect_err("malformed binding JSON should fail to load");
assert_eq!(error.code, "BINDING_CONFIG_INVALID");
assert!(
error.to_string().contains("ALIEN_FILES_BINDING"),
"message should name the env var, got: {error}"
);
}
#[tokio::test]
async fn redis_kv_binding_returns_unsupported_binding_provider() {
let json = r#"{"service":"redis","connectionUrl":"redis://localhost:6379"}"#;
let env = with_binding(base_env(), "cache", json);
let bindings = Bindings::from_env_map(env).expect("valid JSON should construct");
let error = bindings
.kv("cache")
.await
.expect_err("redis is not a supported kv provider in this build");
assert_eq!(error.code, "UNSUPPORTED_BINDING_PROVIDER");
assert!(
error.to_string().contains("ALIEN_CACHE_BINDING"),
"message should name the env var, got: {error}"
);
}
}