diff --git a/Cargo.lock b/Cargo.lock index 32aa907..da6e6b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,6 +832,14 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "outbound-redis-test-component" +version = "0.1.0" +dependencies = [ + "anyhow", + "wit-bindgen", +] + [[package]] name = "percent-encoding" version = "2.3.1" diff --git a/components/outbound-redis/Cargo.toml b/components/outbound-redis/Cargo.toml new file mode 100644 index 0000000..23af389 --- /dev/null +++ b/components/outbound-redis/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "outbound-redis-test-component" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +wit-bindgen = { workspace = true } diff --git a/components/outbound-redis/src/lib.rs b/components/outbound-redis/src/lib.rs new file mode 100644 index 0000000..193b99b --- /dev/null +++ b/components/outbound-redis/src/lib.rs @@ -0,0 +1,107 @@ +use anyhow::Context as _; +use bindings::{ + exports::wasi::http0_2_0::incoming_handler::Guest, + fermyon::spin2_0_0::redis, + wasi::http0_2_0::types::{ + Headers, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam, + }, +}; + +struct Component; + +mod bindings { + wit_bindgen::generate!({ + world: "http-trigger", + path: "../../wit", + }); + use super::Component; + export!(Component); +} + +impl Guest for Component { + fn handle(request: IncomingRequest, response_out: ResponseOutparam) { + let result = match handle(request) { + Err(e) => response(500, format!("{e}").as_bytes()), + Ok(r) => r, + }; + ResponseOutparam::set(response_out, Ok(result)) + } +} + +const REDIS_ADDRESS_HEADER: &str = "REDIS_ADDRESS"; + +fn handle(request: IncomingRequest) -> anyhow::Result { + let Some(address) = request + .headers() + .get(&REDIS_ADDRESS_HEADER.to_owned()) + .pop() + .and_then(|v| String::from_utf8(v).ok()) + else { + // Otherwise, return a 400 Bad Request response. + return Ok(response(400, b"Bad Request")); + }; + let connection = redis::Connection::open(&address)?; + + connection.set("spin-example-get-set", &b"Eureka!".to_vec())?; + + let payload = connection + .get("spin-example-get-set")? + .context("missing value for 'spin-example-get-set'")?; + + anyhow::ensure!(String::from_utf8_lossy(&payload) == "Eureka!"); + + connection.set("spin-example-incr", &b"0".to_vec())?; + + let int_value = connection.incr("spin-example-incr")?; + + anyhow::ensure!(int_value == 1); + + let keys = vec!["spin-example-get-set".into(), "spin-example-incr".into()]; + + let del_keys = connection.del(&keys)?; + + anyhow::ensure!(del_keys == 2); + + connection.execute( + "set", + &[ + redis::RedisParameter::Binary(b"spin-example".to_vec()), + redis::RedisParameter::Binary(b"Eureka!".to_vec()), + ], + )?; + + connection.execute( + "append", + &[ + redis::RedisParameter::Binary(b"spin-example".to_vec()), + redis::RedisParameter::Binary(b" I've got it!".to_vec()), + ], + )?; + + let values = connection.execute( + "get", + &[redis::RedisParameter::Binary(b"spin-example".to_vec())], + )?; + + anyhow::ensure!(matches!( + values.as_slice(), + &[redis::RedisResult::Binary(ref b)] if b == b"Eureka! I've got it!")); + + Ok(response(200, b"")) +} + +fn response(status: u16, body: &[u8]) -> OutgoingResponse { + let response = OutgoingResponse::new(Headers::new()); + response.set_status_code(status).unwrap(); + if !body.is_empty() { + assert!(body.len() <= 4096); + let outgoing_body = response.body().unwrap(); + { + let outgoing_stream = outgoing_body.write().unwrap(); + outgoing_stream.blocking_write_and_flush(body).unwrap(); + // The outgoing stream must be dropped before the outgoing body is finished. + } + OutgoingBody::finish(outgoing_body, None).unwrap(); + } + response +} diff --git a/crates/conformance-tests/src/config.rs b/crates/conformance-tests/src/config.rs index 4c6604c..603f0c3 100644 --- a/crates/conformance-tests/src/config.rs +++ b/crates/conformance-tests/src/config.rs @@ -174,6 +174,8 @@ pub enum Precondition { TcpEcho, /// The test expects a sqlite service to be available. Sqlite, + /// The test expects a Redis service to be available. + Redis, } #[derive(Debug, Clone, serde::Deserialize)] diff --git a/tests/outbound-redis-no-permission/spin.toml b/tests/outbound-redis-no-permission/spin.toml new file mode 100644 index 0000000..95e75b1 --- /dev/null +++ b/tests/outbound-redis-no-permission/spin.toml @@ -0,0 +1,14 @@ +spin_manifest_version = 2 + +[application] +name = "outbound-redis" +authors = ["Fermyon Engineering "] +version = "0.1.0" + +[[trigger.http]] +route = "/" +component = "test" + +[component.test] +source = "%{source=outbound-redis}" +environment = { REDIS_ADDRESS = "redis://localhost:6379" } diff --git a/tests/outbound-redis-no-permission/test.json5 b/tests/outbound-redis-no-permission/test.json5 new file mode 100644 index 0000000..38f9d62 --- /dev/null +++ b/tests/outbound-redis-no-permission/test.json5 @@ -0,0 +1,38 @@ +{ + "invocations": [ + { + "request": { + "path": "/", + "headers": [ + { + "name": "Host", + "value": "example.com" + }, + { + "name": "redis_address", + "value": "redis://localhost:%{port=6379}", + } + ] + }, + "response": { + "status": 500, + "headers": [ + { + "name": "Content-Length", + "optional": true, + }, + { + "name": "transfer-encoding", + "optional": true + }, + { + "name": "Date", + "optional": true + } + ], + "body": "Error::InvalidAddress" + } + } + ], + "preconditions": [ { "kind": "redis" } ] +} \ No newline at end of file diff --git a/tests/outbound-redis-variable-permission/spin.toml b/tests/outbound-redis-variable-permission/spin.toml new file mode 100644 index 0000000..176e5b1 --- /dev/null +++ b/tests/outbound-redis-variable-permission/spin.toml @@ -0,0 +1,18 @@ +spin_manifest_version = 2 + +[application] +name = "outbound-redis" +authors = ["Fermyon Engineering "] +version = "0.1.0" + +[variables] +redis_host = { default = "localhost" } + +[[trigger.http]] +route = "/" +component = "test" + +[component.test] +source = "%{source=outbound-redis}" +environment = { REDIS_ADDRESS = "redis://localhost:%{port=6379}" } +allowed_outbound_hosts = ["redis://{{ redis_host }}:%{port=6379}"] diff --git a/tests/outbound-redis-variable-permission/test.json5 b/tests/outbound-redis-variable-permission/test.json5 new file mode 100644 index 0000000..2d1f889 --- /dev/null +++ b/tests/outbound-redis-variable-permission/test.json5 @@ -0,0 +1,32 @@ +{ + "invocations": [ + { + "request": { + "path": "/", + "headers": [ + { + "name": "Host", + "value": "example.com" + }, + { + "name": "redis_address", + "value": "redis://localhost:%{port=6379}", + } + ] + }, + "response": { + "headers": [ + { + "name": "Content-Length", + "value": "0" + }, + { + "name": "Date", + "optional": true + } + ], + } + } + ], + "preconditions": [ { "kind": "redis" } ] +} \ No newline at end of file diff --git a/tests/outbound-redis/spin.toml b/tests/outbound-redis/spin.toml new file mode 100644 index 0000000..30c0d22 --- /dev/null +++ b/tests/outbound-redis/spin.toml @@ -0,0 +1,14 @@ +spin_manifest_version = 2 + +[application] +name = "outbound-redis" +authors = ["Fermyon Engineering "] +version = "0.1.0" + +[[trigger.http]] +route = "/" +component = "test" + +[component.test] +source = "%{source=outbound-redis}" +allowed_outbound_hosts = ["redis://localhost:%{port=6379}"] diff --git a/tests/outbound-redis/test.json5 b/tests/outbound-redis/test.json5 new file mode 100644 index 0000000..8c201c5 --- /dev/null +++ b/tests/outbound-redis/test.json5 @@ -0,0 +1,32 @@ +{ + "invocations": [ + { + "request": { + "path": "/", + "headers": [ + { + "name": "Host", + "value": "example.com" + }, + { + "name": "redis_address", + "value": "redis://localhost:%{port=6379}", + } + ] + }, + "response": { + "headers": [ + { + "name": "Content-Length", + "value": "0" + }, + { + "name": "Date", + "optional": true + } + ] + } + } + ], + "preconditions": [ { "kind": "redis" } ] +} \ No newline at end of file