Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367use mlua::{Lua, Result as LuaResult};use reqwest::Method;use std::sync::Arc;
use crate::AppState;
/// Register the `http` table with async HTTP request functions.pub fn register_http_api(lua: &Lua, state: Arc<AppState>) -> LuaResult<()> { let http_table = lua.create_table()?;
let methods = [ ("get", Method::GET), ("post", Method::POST), ("put", Method::PUT), ("patch", Method::PATCH), ("delete", Method::DELETE), ("head", Method::HEAD), ];
for (name, method) in methods { let state_clone = state.clone(); let func = lua.create_async_function(move |lua, (url, opts): (String, Option<mlua::Table>)| { let state = state_clone.clone(); let method = method.clone(); async move { let mut builder = state.http.request(method.clone(), &url);
if let Some(ref opts) = opts { if let Ok(headers_table) = opts.get::<mlua::Table>("headers") { for pair in headers_table.pairs::<String, String>() { let (key, value) = pair?; builder = builder.header(key, value); } }
if method != Method::GET && method != Method::HEAD && let Ok(body) = opts.get::<String>("body") { builder = builder.body(body); } }
let response = builder .send() .await .map_err(|e| mlua::Error::runtime(format!("HTTP request failed: {e}")))?;
let status = response.status().as_u16();
let headers_table = lua.create_table()?; for (key, value) in response.headers() { if let Ok(v) = value.to_str() { headers_table.set(key.as_str().to_lowercase(), v.to_string())?; } }
let body = if method == Method::HEAD { String::new() } else { response.text().await.map_err(|e| { mlua::Error::runtime(format!("HTTP read body failed: {e}")) })? };
let result = lua.create_table()?; result.set("status", status)?; result.set("body", body)?; result.set("headers", headers_table)?;
Ok(mlua::Value::Table(result)) } })?; http_table.set(name, func)?; }
lua.globals().set("http", http_table)?; Ok(())}
#[cfg(test)]mod tests { use super::*; use crate::config::Config; use crate::lexicon::LexiconRegistry; use tokio::sync::watch;
fn test_state() -> AppState { let config = Config { host: "127.0.0.1".into(), port: 3000, database_url: String::new(), database_backend: crate::db::DatabaseBackend::Sqlite, public_url: String::new(), session_secret: "test-secret".into(), jetstream_url: String::new(), relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, tos_uri: None, policy_uri: None, token_encryption_key: None, default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); sqlx::any::install_default_drivers(); let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); let did_resolver = atrium_identity::did::CommonDidResolver::new( atrium_identity::did::CommonDidResolverConfig { plc_directory_url: "https://plc.directory".into(), http_client: std::sync::Arc::clone(&atrium_http), }, ); let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( atrium_identity::handle::AtprotoHandleResolverConfig { dns_txt_resolver: crate::dns::NativeDnsResolver::new(), http_client: atrium_http, }, ); let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), scopes: Some(vec![atrium_oauth::Scope::Known( atrium_oauth::KnownScope::Atproto, )]), }, keys: None, state_store: crate::auth::oauth_store::DbStateStore::new( test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), session_store: crate::auth::oauth_store::DbSessionStore::new( test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), resolver: atrium_oauth::OAuthResolverConfig { did_resolver, handle_resolver, authorization_server_metadata: Default::default(), protected_resource_metadata: Default::default(), }, }) .expect("Failed to create test OAuth client"); AppState { config, http: reqwest::Client::new(), db: test_db.clone(), db_backend: crate::db::DatabaseBackend::Sqlite, domain_cache: crate::domain::DomainCache::new(), lexicons: LexiconRegistry::new(), collections_tx: tx, labeler_subscriptions_tx: labeler_tx, rate_limiter: crate::rate_limit::RateLimiter::new( crate::rate_limit::RateLimitDefaults { query_cost: 1, procedure_cost: 1, proxy_cost: 1, }, ), oauth: std::sync::Arc::new(crate::auth::OAuthClientRegistry::new(std::sync::Arc::new( oauth, ))), oauth_state_store: crate::auth::oauth_store::DbStateStore::new( test_db.clone(), crate::db::DatabaseBackend::Sqlite, ), cookie_key: axum_extra::extract::cookie::Key::derive_from( b"test-secret-for-tests-only-not-production", ), plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), wasm_runtime: std::sync::Arc::new( crate::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( crate::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( crate::proxy_config::ProxyConfig::default(), ))), } }
fn setup(state: &AppState) -> Lua { let lua = Lua::new(); register_http_api(&lua, Arc::new(state.clone())).unwrap(); lua }
#[tokio::test] async fn get_returns_status_and_body() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/test")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("hello")) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!(r#"return http.get("{}/test")"#, mock.uri()); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result.get::<u16>("status").unwrap(), 200); assert_eq!(result.get::<String>("body").unwrap(), "hello"); }
#[tokio::test] async fn get_returns_headers() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/h")) .respond_with( wiremock::ResponseTemplate::new(200) .insert_header("X-Custom", "test-value") .set_body_string(""), ) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!(r#"return http.get("{}/h")"#, mock.uri()); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); let headers: mlua::Table = result.get("headers").unwrap(); assert_eq!(headers.get::<String>("x-custom").unwrap(), "test-value"); }
#[tokio::test] async fn post_sends_body_and_headers() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("POST")) .and(wiremock::matchers::path("/post")) .and(wiremock::matchers::header( "content-type", "application/json", )) .and(wiremock::matchers::body_string(r#"{"k":"v"}"#)) .respond_with(wiremock::ResponseTemplate::new(201).set_body_string("created")) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!( r#"return http.post("{}/post", {{ body = '{{"k":"v"}}', headers = {{ ["content-type"] = "application/json" }} }})"#, mock.uri() ); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result.get::<u16>("status").unwrap(), 201); assert_eq!(result.get::<String>("body").unwrap(), "created"); }
#[tokio::test] async fn head_returns_empty_body() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("HEAD")) .and(wiremock::matchers::path("/head")) .respond_with(wiremock::ResponseTemplate::new(204)) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!(r#"return http.head("{}/head")"#, mock.uri()); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result.get::<u16>("status").unwrap(), 204); assert_eq!(result.get::<String>("body").unwrap(), ""); }
#[tokio::test] async fn put_sends_body() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("PUT")) .and(wiremock::matchers::path("/put")) .and(wiremock::matchers::body_string("updated")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok")) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!( r#"return http.put("{}/put", {{ body = "updated" }})"#, mock.uri() ); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result.get::<u16>("status").unwrap(), 200); }
#[tokio::test] async fn delete_works() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("DELETE")) .and(wiremock::matchers::path("/del")) .respond_with(wiremock::ResponseTemplate::new(204).set_body_string("")) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!(r#"return http.delete("{}/del")"#, mock.uri()); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result.get::<u16>("status").unwrap(), 204); }
#[tokio::test] async fn patch_sends_body() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("PATCH")) .and(wiremock::matchers::path("/patch")) .and(wiremock::matchers::body_string("patched")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok")) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!( r#"return http.patch("{}/patch", {{ body = "patched" }})"#, mock.uri() ); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result.get::<u16>("status").unwrap(), 200); }
#[tokio::test] async fn get_without_opts_works() { let mock = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/simple")) .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok")) .mount(&mock) .await;
let state = test_state(); let lua = setup(&state); let chunk = format!(r#"return http.get("{}/simple")"#, mock.uri()); let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result.get::<u16>("status").unwrap(), 200); assert_eq!(result.get::<String>("body").unwrap(), "ok"); }
#[tokio::test] async fn invalid_url_returns_error() { let state = test_state(); let lua = setup(&state); let result: Result<mlua::Table, _> = lua .load(r#"return http.get("http://0.0.0.0:1/nope")"#) .eval_async() .await; assert!(result.is_err()); }}