diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn-apps-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn-apps-rust.md index bdad5fe..42303a5 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn-apps-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn-apps-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # FastEdge Rust SDK — CDN Apps (Proxy-Wasm) @@ -46,7 +46,7 @@ proxy-wasm = "0.2" log = "0.4" ``` -**Tier 2 — CDN app with FastEdge host services** (KV, secrets, dictionary): +**Tier 2 — CDN app with FastEdge host services** (KV, cache, secrets, dictionary): ```toml [package] @@ -195,12 +195,12 @@ impl HttpContext for HelloWorld { ## Lifecycle Callbacks -| Callback | Phase | Description | -| ----------------------------------------------------------------- | ---------------- | --------------------------------------------------- | -| `on_http_request_headers(num_headers: usize, end_of_stream: bool) -> Action` | Request headers | Inspect or modify request headers before forwarding | -| `on_http_request_body(body_size: usize, end_of_stream: bool) -> Action` | Request body | Inspect or modify request body before forwarding | -| `on_http_response_headers(num_headers: usize, end_of_stream: bool) -> Action` | Response headers | Inspect or modify response headers from origin | -| `on_http_response_body(body_size: usize, end_of_stream: bool) -> Action` | Response body | Inspect or modify response body from origin | +| Callback | Phase | Description | +| ----------------------------------------------------------------------------------------------- | ---------------- | --------------------------------------------------- | +| `on_http_request_headers(num_headers: usize, end_of_stream: bool) -> Action` | Request headers | Inspect or modify request headers before forwarding | +| `on_http_request_body(body_size: usize, end_of_stream: bool) -> Action` | Request body | Inspect or modify request body before forwarding | +| `on_http_response_headers(num_headers: usize, end_of_stream: bool) -> Action` | Response headers | Inspect or modify response headers from origin | +| `on_http_response_body(body_size: usize, end_of_stream: bool) -> Action` | Response body | Inspect or modify response body from origin | All callbacks have default no-op implementations. Override only the phases your app needs to process. @@ -508,6 +508,91 @@ impl HttpContext for RateLimitFilter { } ``` +### Cache (`fastedge::proxywasm::cache`) + +Provides ephemeral cache storage, implemented by the host. Unlike `key_value::Store`, cache operations are not scoped to a named store or handle — every function is a free function keyed directly, and every entry is scoped to the calling application. + +```rust,ignore +pub fn get(key: &str) -> Result>, Error> +pub fn set(key: &str, value: &[u8], ttl_ms: Option) -> Result<(), Error> +pub fn delete(key: &str) -> Result<(), Error> +pub fn exists(key: &str) -> Result +pub fn incr(key: &str, delta: i64) -> Result +pub fn expire(key: &str, ttl_ms: u64) -> Result +pub fn purge() -> Result +pub fn purge_prefix(prefix: &str) -> Result +``` + +| Function | Return Type | Description | +| ---------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------- | +| `get(key: &str)` | `Result>, Error>` | Get the value for a key; `None` if the key does not exist | +| `set(key: &str, value: &[u8], ttl_ms: Option)` | `Result<(), Error>` | Set a value with an optional expiry; `None` means no expiry | +| `delete(key: &str)` | `Result<(), Error>` | Delete a key; a no-op if the key does not exist | +| `exists(key: &str)` | `Result` | Test whether a key exists | +| `incr(key: &str, delta: i64)` | `Result` | Atomically increment (or decrement) an integer value; returns the new value | +| `expire(key: &str, ttl_ms: u64)` | `Result` | Set or update a key's expiry; `false` if the key does not exist | +| `purge()` | `Result` | Delete all cache entries owned by the calling application | +| `purge_prefix(prefix: &str)` | `Result` | Delete all cache entries whose key begins with `prefix` | + +`purge()` and `purge_prefix()` both return the number of keys that were deleted. + +#### `Error` + +```rust,ignore +pub enum Error { + AccessDenied, + InternalError, + Other(String), +} +``` + +| Variant | Description | +| --------------- | ----------------------------------------------------------- | +| `AccessDenied` | The application does not have access to the specified cache | +| `InternalError` | An unexpected internal error occurred | +| `Other(String)` | An implementation-specific error (e.g., I/O failure) | + +#### Example — cache a computed value in the response headers phase + +```rust,no_run +use fastedge::proxywasm::cache; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Trace); + proxy_wasm::set_root_context(|_| -> Box { Box::new(CacheRoot) }); +}} + +struct CacheRoot; +impl Context for CacheRoot {} +impl RootContext for CacheRoot { + fn get_type(&self) -> Option { Some(ContextType::HttpContext) } + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(CacheFilter)) + } +} + +struct CacheFilter; +impl Context for CacheFilter {} + +impl HttpContext for CacheFilter { + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + match cache::get("key-3338664") { + Ok(Some(_cached)) => { + // reuse the cached value + } + Ok(None) => { + // store the value for 5 minutes + let _ = cache::set("key-3338664", b"value", Some(300_000)); + } + Err(_) => {} + } + Action::Continue + } +} +``` + ### Secret Management (`fastedge::proxywasm::secret`) Provides access to encrypted secrets stored in the FastEdge platform. @@ -733,16 +818,17 @@ The `log` crate macros (`info!`, `warn!`, `error!`, etc.) work when `proxy_wasm: ## API Comparison: HTTP vs CDN -| Service | HTTP Apps (Component Model) | CDN Apps (ProxyWasm) | -| ------------- | ------------------------------------------------------------------- | -------------------------------------------------------- | -| Key-Value | `fastedge::key_value::Store` | `fastedge::proxywasm::key_value::Store` | -| Secrets | `fastedge::secret::get` | `fastedge::proxywasm::secret::get` | -| Dictionary | `fastedge::dictionary::get` | `fastedge::proxywasm::dictionary::get` | -| Diagnostics | `fastedge::utils::set_user_diag` | `fastedge::proxywasm::utils::set_user_diag` | -| Error types | Typed `Error` enums | `u32` status codes (secret) or typed `Error` (key_value) | -| Cargo feature | None required | `features = ["proxywasm"]` | -| Build target | `wasm32-wasip1` (basic) / `wasm32-wasip2` (wstd) | `wasm32-wasip1` | -| Handler | `#[wstd::http_server]` (recommended) / `#[fastedge::http]` (basic) | `proxy_wasm::main!` + traits | +| Service | HTTP Apps (Component Model) | CDN Apps (ProxyWasm) | +| ------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Key-Value | `fastedge::key_value::Store` | `fastedge::proxywasm::key_value::Store` | +| Cache | `fastedge::cache` | `fastedge::proxywasm::cache` | +| Secrets | `fastedge::secret::get` | `fastedge::proxywasm::secret::get` | +| Dictionary | `fastedge::dictionary::get` | `fastedge::proxywasm::dictionary::get` | +| Diagnostics | `fastedge::utils::set_user_diag` | `fastedge::proxywasm::utils::set_user_diag` | +| Error types | Typed `Error` enums | `u32` status codes (secret) or typed `Error` (key_value, cache) | +| Cargo feature | None required | `features = ["proxywasm"]` | +| Build target | `wasm32-wasip1` (basic) / `wasm32-wasip2` (wstd) | `wasm32-wasip1` | +| Handler | `#[wstd::http_server]` (recommended) / `#[fastedge::http]` (basic) | `proxy_wasm::main!` + traits | ## See Also diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-ab-testing-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-ab-testing-rust.md index a8e55fb..9d90a98 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-ab-testing-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-ab-testing-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-api-key-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-api-key-rust.md index 5aeb1a4..a001d60 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-api-key-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-api-key-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # API Key Validation — CDN (Rust) @@ -159,120 +159,3 @@ return Action::Pause; - Host services reference — secrets API (`fastedge::proxywasm::secret`) and other CDN host services - CDN apps reference — proxy-wasm app structure, `RootContext`/`HttpContext` setup, `Action` enum, and request property encodings - SDK API reference — Rust CDN SDK traits and types - -## Source Material - -### FILE: examples/cdn/api_key/src/lib.rs - -```rust -/* -* Copyright 2025 G-Core Innovations SARL -*/ -/* -Example CDN app demonstrating API key validation. - -Validates requests using an X-API-Key header checked against a stored -secret. Simpler alternative to JWT when token expiry and claims are -not needed. - -Required configuration: - - Secret: API_KEY -*/ - -use fastedge::proxywasm::secret; -use proxy_wasm::traits::*; -use proxy_wasm::types::*; - -proxy_wasm::main! {{ - proxy_wasm::set_log_level(LogLevel::Info); - proxy_wasm::set_root_context(|_| -> Box { Box::new(ApiKeyRoot) }); -}} - -struct ApiKeyRoot; - -impl Context for ApiKeyRoot {} - -impl RootContext for ApiKeyRoot { - fn get_type(&self) -> Option { - Some(ContextType::HttpContext) - } - - fn create_http_context(&self, _: u32) -> Option> { - Some(Box::new(ApiKeyContext)) - } -} - -struct ApiKeyContext; - -impl Context for ApiKeyContext {} - -impl HttpContext for ApiKeyContext { - fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { - let expected_key = match secret::get("API_KEY") { - Ok(Some(bytes)) => match String::from_utf8(bytes) { - Ok(s) if !s.is_empty() => s, - _ => { - self.send_http_response(500, vec![], Some(b"App misconfigured")); - return Action::Pause; - } - }, - _ => { - self.send_http_response(500, vec![], Some(b"App misconfigured")); - return Action::Pause; - } - }; - - let provided_key = match self.get_http_request_header("X-API-Key") { - Some(k) if !k.is_empty() => k, - _ => { - self.send_http_response( - 401, - vec![("WWW-Authenticate", "API-Key")], - Some(b"Missing X-API-Key header"), - ); - return Action::Pause; - } - }; - - if provided_key != expected_key { - println!("API key validation failed"); - self.send_http_response(403, vec![], Some(b"Invalid API key")); - return Action::Pause; - } - - // Strip the API key header before forwarding to upstream - self.set_http_request_header("X-API-Key", None); - - println!("API key validated successfully"); - Action::Continue - } -} -``` - -### FILE: examples/cdn/api_key/Cargo.toml - -```toml -[workspace] - -[package] -name = "api_key" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -proxy-wasm = "0.2" -fastedge = { version = "0.4", features = ["proxywasm"] } -``` - -### FILE: examples/cdn/api_key/README.md - -``` -[← Back to examples](../../README.md) - -# API Key (CDN) - -Validates requests using an `X-API-Key` header checked against a stored secret. Returns 401 if missing, 403 if invalid, and strips the header before forwarding to upstream. -``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-auth-jwt-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-auth-jwt-rust.md index 047c4eb..d906398 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-auth-jwt-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-auth-jwt-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-body-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-body-rust.md index 78eaab7..889ecbb 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-body-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-body-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cache-control-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cache-control-rust.md index e0bb9aa..cbbf346 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cache-control-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cache-control-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cache-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cache-rust.md new file mode 100644 index 0000000..8a65925 --- /dev/null +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cache-rust.md @@ -0,0 +1,237 @@ + + +# CDN Cache — Rust Example + +## Overview + +CDN app demonstrating cache operations via the proxy-wasm ABI. All operations are scoped to the calling application and addressed by key alone — there are no named stores or handles (unlike the KV store). + +Operations are dispatched via query parameters on the incoming request. Responses are always JSON. Errors return HTTP 500 with `{"error": ""}`. + +## App Type + +- **Interface**: proxy-wasm (`fastedge::proxywasm::cache`) +- **App type**: CDN +- **Language**: Rust + +## Request Interface + +All operations are specified via query parameters. The `action` parameter selects the operation; `action=get` is the default when omitted. + +| Query string | Operation | +|---|---| +| `?action=get&key=` | Read a cached value. `response` is `null` when the key is absent. | +| `?action=set&key=&value=[&ttl=]` | Store a value. Omitting `ttl` stores with no expiry. | +| `?action=delete&key=` | Delete a key. No-op when the key is absent. | +| `?action=exists&key=` | Test key membership. | +| `?action=incr&key=&delta=` | Atomic increment. `delta` may be negative. A missing key starts at `0`. | +| `?action=expire&key=&ttl=` | Set or update a key's TTL. `response` is `false` when the key is absent. | +| `?action=purge` | Delete every key owned by this app. Returns count of deleted keys. | +| `?action=purgePrefix&prefix=` | Delete app-owned keys starting with `prefix`. Returns count deleted. | + +## API Reference + +All functions are in `fastedge::proxywasm::cache`. + +### `cache::get` + +```rust +pub fn get(key: &str) -> Result>, _> +``` + +Retrieve cached bytes by key. + +- **Parameters**: `key` — cache key string +- **Returns**: `Ok(Some(Vec))` if the key exists, `Ok(None)` if absent, `Err` on failure + +**JSON response shape:** +```json +{ "action": "get", "key": "", "response": "" } +{ "action": "get", "key": "", "response": null } +``` + +--- + +### `cache::set` + +```rust +pub fn set(key: &str, value: &[u8], ttl_ms: Option) -> Result<(), _> +``` + +Store bytes under a key with an optional TTL. + +- **Parameters**: + - `key` — cache key string + - `value` — byte slice to store + - `ttl_ms` — TTL in milliseconds; `None` means no expiry +- **Returns**: `Ok(())` on success, `Err` on failure +- **Constraint**: `ttl_ms` must be a positive integer when provided; invalid values produce an error response + +**JSON response shape:** +```json +{ "action": "set", "key": "", "value": "", "ttlMs": |null, "response": true } +``` + +--- + +### `cache::delete` + +```rust +pub fn delete(key: &str) -> Result<(), _> +``` + +Remove a key from the cache. No-op if the key does not exist. + +- **Parameters**: `key` — cache key string +- **Returns**: `Ok(())` on success, `Err` on failure + +**JSON response shape:** +```json +{ "action": "delete", "key": "", "response": true } +``` + +--- + +### `cache::exists` + +```rust +pub fn exists(key: &str) -> Result +``` + +Test whether a key is present in the cache. + +- **Parameters**: `key` — cache key string +- **Returns**: `Ok(true)` if present, `Ok(false)` if absent, `Err` on failure + +**JSON response shape:** +```json +{ "action": "exists", "key": "", "response": true|false } +``` + +--- + +### `cache::incr` + +```rust +pub fn incr(key: &str, delta: i64) -> Result +``` + +Atomically increment (or decrement) a cached counter. If the key does not exist, it is treated as `0` before applying the delta. + +- **Parameters**: + - `key` — cache key string + - `delta` — signed 64-bit integer; may be negative for decrements +- **Returns**: `Ok(new_value)` — the value after increment, `Err` on failure + +**JSON response shape:** +```json +{ "action": "incr", "key": "", "delta": , "response": } +``` + +--- + +### `cache::expire` + +```rust +pub fn expire(key: &str, ttl_ms: u64) -> Result +``` + +Set or update the TTL on an existing key. + +- **Parameters**: + - `key` — cache key string + - `ttl_ms` — TTL in milliseconds; must be a positive integer +- **Returns**: `Ok(true)` if the key existed and was updated, `Ok(false)` if the key was absent, `Err` on failure + +**JSON response shape:** +```json +{ "action": "expire", "key": "", "ttlMs": , "response": true|false } +``` + +--- + +### `cache::purge` + +```rust +pub fn purge() -> Result +``` + +Delete all keys owned by the calling application. + +- **Parameters**: none +- **Returns**: `Ok(count)` — number of keys deleted, `Err` on failure + +**JSON response shape:** +```json +{ "action": "purge", "response": } +``` + +--- + +### `cache::purge_prefix` + +```rust +pub fn purge_prefix(prefix: &str) -> Result +``` + +Delete all app-owned keys whose names start with the given prefix. + +- **Parameters**: `prefix` — key prefix string +- **Returns**: `Ok(count)` — number of keys deleted, `Err` on failure + +**JSON response shape:** +```json +{ "action": "purgePrefix", "prefix": "", "response": } +``` + +--- + +## Error Handling + +- All errors return HTTP 500 with body `{"error": ""}`. +- Missing required query parameters produce descriptive error messages, e.g. `"Missing required param 'key' for 'get' action"`. +- Invalid parameter values (e.g. non-integer `ttl`, non-integer `delta`) produce descriptive parse error messages. +- Unknown `action` values produce an error listing all supported actions. + +## Dependencies + +```toml +proxy-wasm = "0.2" +fastedge = { path = "...", features = ["proxywasm"] } +querystring = "1.1" +serde_json = "1" +``` + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip1/release/cache.wasm +``` + +## Lifecycle Notes + +- The app uses the proxy-wasm `RootContext` + `HttpContext` pattern. +- Logic executes in `on_http_response_body` (waits for `end_of_stream`). +- `on_http_response_headers` strips `content-length`, sets `content-type: application/json`, and sets `transfer-encoding: chunked` before body processing. +- Query string is read from `request.query` via `get_property`. + +## Key Constraints + +- Cache operations are scoped to the calling application — keys from one app are not accessible to another. +- There are no named stores or handles; the key is the sole address. +- `ttl` values are always in **milliseconds**. +- `delta` for `incr` is a signed 64-bit integer (`i64`); a missing key is treated as `0`. + +## See Also + +- fastedge-sdk-rust CDN key_value example (named KV store with handles, contrast to this cache API) +- fastedge-sdk-rust CDN examples overview +- proxy-wasm `HttpContext` and `RootContext` trait documentation diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-convert-image-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-convert-image-rust.md index cf31db4..9f4fdd9 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-convert-image-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-convert-image-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # CDN Example: Convert Image (Rust) @@ -202,300 +202,3 @@ Transformation is skipped (returning `Action::Continue` without modifying the re - FastEdge CDN app platform overview - FastEdge SDK Rust reference - FastEdge error codes reference - -## Source Material - -### FILE: examples/cdn/convert_image/src/lib.rs - -```rust -use image::*; -use proxy_wasm::traits::*; -use proxy_wasm::types::*; -use std::{env, env::VarError, io::Cursor, str::from_utf8}; - -proxy_wasm::main! {{ - proxy_wasm::set_log_level(LogLevel::Trace); - proxy_wasm::set_root_context(|_| -> Box { Box::new(ConvertImageRoot) }); -}} - -struct ConvertImageRoot; - -impl Context for ConvertImageRoot {} - -impl RootContext for ConvertImageRoot { - fn get_type(&self) -> Option { - Some(ContextType::HttpContext) - } - - fn create_http_context(&self, _: u32) -> Option> { - Some(Box::new(ConvertImageContext)) - } -} - -struct ConvertImageContext; - -impl Context for ConvertImageContext {} - -impl HttpContext for ConvertImageContext { - fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { - // this header is used to select correct image version from cache - self.add_http_request_header("Image-Format", "original"); - - // get extension - let path = self.get_property(vec!["request.path"]).map(|v| String::from_utf8(v).unwrap_or_default()).unwrap_or_default(); - println!("request.path={path:?}"); - let raw_ext = self.get_property(vec!["request.extension"]); - println!("request.extension={raw_ext:?}"); - let Some(ext) = raw_ext else { - println!("No extension in request path, not transforming"); - return Action::Continue; - }; - let Ok(ext) = from_utf8(&ext) else { - println!("Invalid UTF-8 in request extension, not transforming"); - return Action::Continue; - }; - if ext.is_empty() { - println!("No extension in request path, not transforming"); - return Action::Continue; - } - - // FORMATS_TO_TRANSFORM contains list of file extensions to transfor - // note that jpg and jpeg are different extensions - let Ok(image_list) = str_param("FORMATS_TO_TRANSFORM") else { - println!("FORMATS_TO_TRANSFORM param is not set, not transforming"); - return Action::Continue; - }; - if !image_list.split(',').any(|entry| entry == ext) { - println!( - "extension {} is not in the list of formats to transform: {}, not transforming", - ext, image_list - ); - return Action::Continue; - } - - // requests from User agents that match substrings in the IGNORED_UA_LIST param are not transformed - let Some(ua) = self.get_http_request_header("User-Agent") else { - println!("User-Agent header is not set, not transforming"); - return Action::Continue; - }; - if ua.is_empty() { - println!("User-Agent header is not set, not transforming"); - return Action::Continue; - } - if let Ok(ua_to_ignore) = str_param("IGNORED_UA_LIST") { - if ua_to_ignore.split(",").any(|entry| ua.contains(entry)) { - println!("User-Agent is in ignore list, not transforming"); - return Action::Continue; - } - } - - // indicator for on_response_headers and for cache key - self.set_http_request_header("Image-Format", Some("image/avif")); - - Action::Continue - } - - fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { - // only process 200 responses - if let Some(status) = self.rsp_status() { - if status != 200 { - println!( - "Response status is {} instead of expected 200, not transforming", - status - ); - return Action::Continue; - } - } else { - println!("Response status is not set, not transforming"); - return Action::Continue; - } - - // if "Image-Format" request header is not set, don't convert the image - let Some(content_type) = self.get_http_request_header("Image-Format") else { - return Action::Continue; - }; - // instruct cache to vary by this header so "original" and "image/avif" are cached separately - self.add_http_response_header("Vary", "Image-Format"); - - if content_type == "original" { - return Action::Continue; - }; - - // image to be transformed, set headers accordingly - self.set_http_response_header("Content-Length", None); - self.set_http_response_header("Transfer-Encoding", Some("Chunked")); - self.set_http_response_header("Content-Type", Some(content_type.as_str())); - - // indicate to on_http_response_body that transformation is needed - self.set_property(vec!["response.content-type"], Some(content_type.as_bytes())); - - Action::Continue - } - - fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { - if !end_of_stream { - // wait till we get complete body - return Action::Pause; - } - - let Some(content_type) = self.get_property(vec!["response.content-type"]) else { - return Action::Continue; - }; - - let Ok(content_type) = from_utf8(&content_type) else { - // should never happen - println!("Invalid UTF-8 in Content-Type"); - self.send_http_response(500, vec![], None); - return Action::Pause; - }; - - if content_type != "image/avif" { - // should never happen - println!( - "Content-Type {} is not supported, not transforming", - content_type - ); - return Action::Continue; - } - - if let Some(body_bytes) = self.get_http_response_body(0, body_size) { - let buf = body_bytes.as_bytes(); - let img = match load_from_memory(buf) { - Ok(i) => i, - Err(e) => { - println!("cannot load image to memory {}, not converting", e); - return Action::Continue; - } - }; - - let mut out = Vec::new(); - let mut c = Cursor::new(&mut out); - let res = img.write_with_encoder(codecs::avif::AvifEncoder::new_with_speed_quality( - &mut c, - u8_param("AVIF_SPEED", 1, 10, 5), - u8_param("AVIF_QUALITY", 1, 100, 70), - )); - - match res { - Ok(_) => { - println!( - "{} bytes -> {} bytes {}", - body_size, - out.len(), - content_type - ); - self.set_http_response_body(0, body_size, &out) - } - Err(e) => println!("cannot store transformed image {}", e), - } - } else { - println!("No response body to transform"); - } - - Action::Continue - } -} - -impl ConvertImageContext { - fn rsp_status(&mut self) -> Option { - if let Some(status) = self.get_property(vec!["response.status"]) { - if status.len() != 2 { - println!("HTTP status property is not 2 bytes"); - return None; - } - return Some(u16::from_be_bytes([status[0], status[1]])); - } - None - } -} - -fn str_param(name: &str) -> Result { - let val = env::var(name)?; - if val.is_empty() { - return Err(VarError::NotPresent); - } - - Ok(val) -} - -fn u8_param(name: &str, min: u8, max: u8, default: u8) -> u8 { - let Ok(val) = env::var(name) else { - println!("Param {} is not set, using default value {}", name, default); - return default; - }; - if val.is_empty() { - println!("Param {} is not set, using default value {}", name, default); - return default; - } - - let val = match val.parse() { - Err(_) => { - println!( - "Param {} is not a valid number, using default value {}", - name, default - ); - return default; - } - Ok(v) => v, - }; - if val < min { - println!( - "Param {} is below minimum {}, using default value {}", - name, min, default - ); - return default; - } - if val > max { - println!( - "Param {} is above maximum {}, using default value {}", - name, max, default - ); - return default; - } - - val -} -``` - - -### FILE: examples/cdn/convert_image/Cargo.toml - -```toml -[workspace] - -[package] -name = "convert_image" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -proxy-wasm = "0.2" -image = "0.25" -``` - - -### FILE: examples/cdn/convert_image/README.md - -``` -[← Back to examples](../../README.md) - -# Convert Image (CDN) - -Converts images to AVIF format on the fly using the proxy-wasm ABI. Only transforms requests matching configured file extensions and skips specified user agents. - -## Configuration - -- Environment variable: `FORMATS_TO_TRANSFORM` — comma-separated list of file extensions to convert (e.g. `jpg,jpeg,png`) -- Environment variable: `IGNORED_UA_LIST` — (optional) comma-separated list of User-Agent substrings to skip -- Environment variable: `AVIF_SPEED` — (optional) AVIF encoding speed, 1-10 (default: 5) -- Environment variable: `AVIF_QUALITY` — (optional) AVIF encoding quality, 1-100 (default: 70) - -## How it works - -1. **on_request_headers** — checks file extension and User-Agent, sets `Image-Format` header for cache variation -2. **on_response_headers** — sets response headers for AVIF content type on 200 responses -3. **on_response_body** — decodes the original image and re-encodes it as AVIF -``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cors-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cors-rust.md index 0412a56..29d4f68 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cors-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-cors-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-error-pages-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-error-pages-rust.md index ee84089..ff9f3c3 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-error-pages-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-error-pages-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> ## Custom Error Pages — CDN (Rust) diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-rust.md index d049948..c999103 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-custom-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-env-secrets-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-env-secrets-rust.md index 0239461..82ec81d 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-env-secrets-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-env-secrets-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- type: example diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geo-redirect-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geo-redirect-rust.md index 2d22742..854c725 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geo-redirect-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geo-redirect-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geoblock-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geoblock-rust.md index bc7fabe..f7db849 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geoblock-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-geoblock-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-headers-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-headers-rust.md index bbfc088..473091a 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-headers-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-headers-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-http-call-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-http-call-rust.md index 35d0930..2cefbe5 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-http-call-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-http-call-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # HTTP Call — CDN (Rust) @@ -285,6 +285,7 @@ impl Context for HttpHeaders { println!( "Received http call response with token id: {token_id}, num_headers: {num_headers}" ); + // If num_headers is 0, then the HTTP call failed. if num_headers != 0 { let headers = self.get_http_call_response_headers(); let headers_str = headers @@ -294,7 +295,7 @@ impl Context for HttpHeaders { .join(","); println!("Response headers: [{}]", headers_str); - self.state = 1; + self.state = 1; // Set state to 1 to indicate that the HTTP call response was received successfully. self.resume_http_request(); } else { diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-kv-store-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-kv-store-rust.md index 56aad05..620ee62 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-kv-store-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-kv-store-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -64,7 +64,7 @@ serde_json = "1" ### Proxy-wasm lifecycle hooks - **`on_http_response_headers`** — set response `content-type` and remove `content-length` before replacing the body -- **`on_http_response_body`** — read request query parameters via `get_property(vec!["request", "query"])`, open the store, dispatch to operations, and replace the body via `set_http_response_body` +- **`on_http_response_body`** — read request query parameters via `get_property(vec!["request.query"])`, open the store, dispatch to operations, and replace the body via `set_http_response_body` Query parameters are only available in `on_http_response_body` via `get_property` because request data is accessible throughout the response phase. @@ -151,7 +151,7 @@ match store.zrange_by_score("leaderboard", 0.0, 100.0) { ```rust let query = self - .get_property(vec!["request", "query"]) + .get_property(vec!["request.query"]) .and_then(|bytes| String::from_utf8(bytes).ok()) .unwrap_or_default(); @@ -189,7 +189,7 @@ The complete example supports these query parameter combinations: fn send_error(&self, msg: &str, body_size: usize) { println!("{}", msg); self.set_property( - vec!["response", "status"], + vec!["response.status"], Some(b"500"), ); let error_body = serde_json::json!({"error": msg}).to_string(); @@ -228,7 +228,9 @@ Sorted set entries shape: `[{"value": "", "score": }, ...]` - **`Store::open` takes a platform-configured name** — the store name must match a KV store resource attached to the app in the FastEdge platform. Passing an unknown name returns `Err`. - **`content-length` must be removed before body replacement** — when replacing the response body, always remove `content-length` in `on_http_response_headers` (set to `None`). Note: on the FastEdge CDN platform, passing `None` sets the header to an empty string rather than truly removing it; set `transfer-encoding: chunked` as a workaround. - **`on_http_response_body` must wait for end of stream** — return `Action::Pause` until `end_of_stream` is `true` before reading query parameters or replacing the body. -- **`query` property is available in the response phase** — `get_property(vec!["request", "query"])` works in `on_http_response_body` because request metadata is accessible throughout the response lifecycle. +- **`get_property` uses dot-notation path** — the correct call is `get_property(vec!["request.query"])` (a single dot-separated string), not `get_property(vec!["request", "query"])` (two separate path segments). The source uses `vec!["request.query"]`. +- **`query` property is available in the response phase** — `get_property(vec!["request.query"])` works in `on_http_response_body` because request metadata is accessible throughout the response lifecycle. +- **`set_property` uses dot-notation path** — the correct call is `set_property(vec!["response.status"], Some(b"500"))` (a single dot-separated string). - **Error type is a typed `Error` enum** (not a raw `u32` as with secrets). Match on the `Err(e)` variant and format with `format!("{}", e)` for human-readable messages. - **`store` query parameter is always required** — the example reads `store` from query params and passes it to `Store::open`. An absent `store` param returns a 500 error before any store operations are attempted. - **Empty query string is rejected** — if no query parameters are provided at all, the app returns a 500 error with the message `"App must be called with query parameters"`. @@ -240,3 +242,363 @@ Sorted set entries shape: `[{"value": "", "score": }, ...]` - Host services reference — full KV store, secrets, and dictionary API documentation for CDN (proxy-wasm) apps - CDN apps reference — proxy-wasm lifecycle hooks, request properties, header and body manipulation patterns - SDK reference (Rust) — `fastedge` crate modules, feature flags, and component model vs. proxy-wasm differences + +## Source Material + +### FILE: examples/cdn/key_value/src/lib.rs + +```rust +/* +* Copyright 2025 G-Core Innovations SARL +*/ +/* +Example CDN app demonstrating KV Store operations via the proxy-wasm interface. + +Supports all KV Store operations via query parameters: + ?store=&action=get&key= + ?store=&action=scan&match= + ?store=&action=zrange&key=&min=&max= + ?store=&action=zscan&key=&match= + ?store=&action=bfExists&key=&item= + +Defaults to action=get if not specified. +*/ + +use fastedge::proxywasm::key_value::Store; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use serde_json::json; +use std::collections::HashMap; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { Box::new(KvStoreRoot) }); +}} + +struct KvStoreRoot; + +impl Context for KvStoreRoot {} + +impl RootContext for KvStoreRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(KvStoreContext)) + } +} + +struct KvStoreContext; + +impl Context for KvStoreContext {} + +impl HttpContext for KvStoreContext { + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + // Remove content-length since we replace the body + self.set_http_response_header("content-length", None); + self.set_http_response_header("content-type", Some("application/json")); + self.set_http_response_header("transfer-encoding", Some("chunked")); + Action::Continue + } + + fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { + if !end_of_stream { + return Action::Pause; + } + + let query = self + .get_property(vec!["request.query"]) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + + if query.is_empty() { + self.send_error("App must be called with query parameters", body_size); + return Action::Continue; + } + + let params: HashMap<&str, &str> = querystring::querify(&query).into_iter().collect(); + + let Some(store_name) = params.get("store") else { + self.send_error("Missing required param 'store'", body_size); + return Action::Continue; + }; + + let action = params.get("action").copied().unwrap_or("get"); + + let store = match Store::open(store_name) { + Ok(s) => s, + Err(e) => { + self.send_error(&format!("Failed to open KvStore '{}': {}", store_name, e), body_size); + return Action::Continue; + } + }; + + let result = match action { + "get" => self.handle_get(&store, ¶ms), + "scan" => self.handle_scan(&store, ¶ms), + "zrange" => self.handle_zrange(&store, ¶ms), + "zscan" => self.handle_zscan(&store, ¶ms), + "bfExists" => self.handle_bf_exists(&store, ¶ms), + _ => Err(format!( + "Invalid action '{}'. Supported: get, scan, zrange, zscan, bfExists", + action + )), + }; + + let body = match result { + Ok(json) => json, + Err(msg) => { + self.send_error(&msg, body_size); + return Action::Continue; + } + }; + + self.set_http_response_body(0, body_size, body.as_bytes()); + + Action::Continue + } +} + +impl KvStoreContext { + fn handle_get(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'get' action")?; + match store.get(key) { + Ok(Some(value)) => { + let value_str = String::from_utf8_lossy(&value); + Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "get", + "key": key, + "response": value_str.as_ref() + }).to_string()) + } + Ok(None) => Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "get", + "key": key, + "response": null + }).to_string()), + Err(e) => Err(format!("KV get error: {}", e)), + } + } + + fn handle_scan(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let pattern = *params.get("match").ok_or("Missing required param 'match' for 'scan' action")?; + match store.scan(pattern) { + Ok(keys) => Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "scan", + "match": pattern, + "response": keys + }).to_string()), + Err(e) => Err(format!("KV scan error: {}", e)), + } + } + + fn handle_zrange(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'zrange' action")?; + let min: f64 = params + .get("min") + .ok_or("Missing required param 'min' for 'zrange' action")? + .parse() + .map_err(|_| "Invalid 'min' value: must be a number".to_string())?; + let max: f64 = params + .get("max") + .ok_or("Missing required param 'max' for 'zrange' action")? + .parse() + .map_err(|_| "Invalid 'max' value: must be a number".to_string())?; + + match store.zrange_by_score(key, min, max) { + Ok(entries) => { + let entries_json: Vec = entries + .iter() + .map(|(value, score)| { + let value_str = String::from_utf8_lossy(value); + json!({"value": value_str.as_ref(), "score": score}) + }) + .collect(); + Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "zrange", + "key": key, + "min": min, + "max": max, + "response": entries_json + }).to_string()) + } + Err(e) => Err(format!("KV zrange error: {}", e)), + } + } + + fn handle_zscan(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'zscan' action")?; + let pattern = *params.get("match").ok_or("Missing required param 'match' for 'zscan' action")?; + + match store.zscan(key, pattern) { + Ok(entries) => { + let entries_json: Vec = entries + .iter() + .map(|(value, score)| { + let value_str = String::from_utf8_lossy(value); + json!({"value": value_str.as_ref(), "score": score}) + }) + .collect(); + Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "zscan", + "key": key, + "match": pattern, + "response": entries_json + }).to_string()) + } + Err(e) => Err(format!("KV zscan error: {}", e)), + } + } + + fn handle_bf_exists(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'bfExists' action")?; + let item = *params.get("item").ok_or("Missing required param 'item' for 'bfExists' action")?; + + match store.bf_exists(key, item) { + Ok(exists) => Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "bfExists", + "key": key, + "item": item, + "response": exists + }).to_string()), + Err(e) => Err(format!("KV bfExists error: {}", e)), + } + } + + fn send_error(&self, msg: &str, body_size: usize) { + println!("{}", msg); + self.set_property( + vec!["response.status"], + Some(b"500"), + ); + let error_body = json!({"error": msg}).to_string(); + self.set_http_response_body(0, body_size, error_body.as_bytes()); + } +} +``` + + +### FILE: examples/cdn/key_value/Cargo.toml + +```toml +[workspace] + +[package] +name = "key_value" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +fastedge = { version = "0.4", features = ["proxywasm"] } +querystring = "1.1" +serde_json = "1" +``` + + +### FILE: examples/cdn/key_value/README.md + +``` +[← Back to examples](../../README.md) + +# Key Value (CDN) + +This example shows how to read and write data from a FastEdge KV store from a CDN app. +It intercepts the HTTP response, reads the request query string, and executes a KV operation against a named store. + +## What it does + +The app supports the following actions: + +- `get` — fetch one key +- `scan` — list keys matching a pattern +- `zrange` — read sorted-set entries by score range +- `zscan` — list sorted-set entries matching a pattern +- `bfExists` — check whether a Bloom filter contains an item + +The request must include at least: + +- `store=` — KV store name +- `action=` — optional, defaults to `get` + +For each action, the app validates required parameters and returns a JSON response body. + +## Supported query examples + +### Get a key + +```text +?store=my_store&action=get&key=user:42 +``` + +### List keys by pattern + +```text +?store=my_store&action=scan&match=user:* +``` + +### Read a sorted set by score range + +```text +?store=my_store&action=zrange&key=leaderboard&min=0&max=100 +``` + +### Search sorted-set members by pattern + +```text +?store=my_store&action=zscan&key=leaderboard&match=user:* +``` + +### Check a Bloom filter item + +```text +?store=my_store&action=bfExists&key=visitors&item=alice@example.com +``` + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip1/release/key_value.wasm +``` + +This example is a CDN app, so it targets `wasm32-wasip1`. + +## Response format + +The app replaces the response body with JSON and sets `content-type: application/json`. +A successful response looks like this: + +```json +{ + "store": "my_store", + "action": "get", + "key": "user:42", + "response": "Alice" +} +``` + +If a required parameter is missing or the KV operation fails, the app responds with an error payload: + +```json +{ + "error": "Missing required param 'key' for 'get' action" +} +``` + +## Notes + +- The app requires query parameters to run. +- If `action` is omitted, it defaults to `get`. +- The code uses `fastedge::proxywasm::key_value::Store` and `proxy_wasm` lifecycle hooks to operate on the KV store. +``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-large-dictionary-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-large-dictionary-rust.md index 48de0d0..e59c0c0 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-large-dictionary-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-large-dictionary-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-log-time-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-log-time-rust.md index c4b7b5c..ab71c4c 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-log-time-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-log-time-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-md2html-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-md2html-rust.md index 0ebd4e7..bf9ce12 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-md2html-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-md2html-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-properties-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-properties-rust.md index 364c99f..ec5f2e9 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-properties-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/cdn/examples-properties-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/host-services-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/host-services-rust.md index 7bcf9c1..3214224 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/host-services-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/host-services-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # Rust Host Services Reference @@ -260,7 +260,7 @@ async fn main(_request: Request) -> anyhow::Result> { Module: `fastedge::dictionary` -Provides fast, read-only lookups for configuration values that do not change during the lifetime of a deployment. +Provides fast, read-only lookups for configuration values that do not change during the lifetime of a deployment. Dictionary = read-only config, key_value = persistent data, secret = encrypted credentials. ### API diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-ab-testing-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-ab-testing-wasi-rust.md index 75ff192..745791f 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-ab-testing-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-ab-testing-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-api-wrapper-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-api-wrapper-basic-rust.md index 6699d03..c772314 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-api-wrapper-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-api-wrapper-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # examples-api-wrapper-basic-rust diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-backend-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-backend-basic-rust.md index 6de33d5..eeb5488 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-backend-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-backend-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -153,114 +153,3 @@ cargo build --release --target wasm32-wasip1 - platform-overview (FastEdge request lifecycle, outbound request capabilities) - sdk-reference-rust (`fastedge::send_request`, `Body`, HTTP types) - best-practices (body buffering considerations, error handling patterns) - -## Source Material - -### FILE: examples/http/basic/backend/src/lib.rs - -```rust -use anyhow::{anyhow, Error, Result}; -use fastedge::body::Body; -use fastedge::http::{Method, Request, Response, StatusCode}; - -#[allow(dead_code)] -#[fastedge::http] -fn main(req: Request) -> Result> { - let (parts, body) = req.into_parts(); - let query = parts - .uri - .query() - .ok_or(anyhow!("missing uri query parameter"))?; - let params = querystring::querify(query); - let url = params - .iter() - .find(|(k, _)| k == &"url") - .ok_or(anyhow!("missing url parameter"))?; - let url = urlencoding::decode(url.1)?.to_string(); - println!("url = {:?}", url); - let request = Request::builder().uri(url).method(Method::GET).body(body)?; - - let response = fastedge::send_request(request).map_err(Error::msg)?; - - Response::builder() - .status(StatusCode::OK) - .body(Body::from(format!( - "len = {}, content-type = {:?}", - response.body().len(), - response.headers().get("Content-Type") - ))) - .map_err(Error::msg) -} -``` - - -### FILE: examples/http/basic/backend/Cargo.toml - -```toml -[workspace] - -[package] -name = "backend" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -fastedge = "0.4" -anyhow = "1" -querystring = "1.1" -urlencoding = "2.1" -``` - - -### FILE: examples/http/basic/backend/README.md - -``` -[← Back to examples](../../../README.md) - -# Backend (URL Proxy) - -A FastEdge application that accepts a `?url=` query parameter, makes an outbound GET request to that URL via `fastedge::send_request`, and returns a summary of the upstream response (`len` and `content-type`) in the response body. - -> **When to use this example:** When you want to see how to make outbound HTTP requests from a FastEdge edge function using the legacy sync handler (`#[fastedge::http]`). For new apps, prefer the async WASI handler — see [`examples/http/wasi/hello_world`](../../wasi/hello_world/README.md). - -## What it does - -1. Parses the `?url=` query parameter from the request URI (percent-decodes it via `urlencoding::decode`). -2. Builds an outbound `GET` request to that URL using `fastedge::send_request`. -3. Returns HTTP 200 with a plain-text body: - ``` - len = , content-type = - ``` -4. Returns HTTP 500 with an error message if `?url=` is absent or the query string is missing. - -## APIs used - -| API | Purpose | -|---|---| -| `#[fastedge::http]` | Sync request-response handler macro | -| `fastedge::send_request(request)` | Blocking outbound HTTP request | -| `fastedge::http::{Request, Response, StatusCode, Method}` | HTTP types | -| `fastedge::body::Body` | Request and response bodies | -| `querystring::querify` | Parse query string into key-value pairs | -| `urlencoding::decode` | Percent-decode the `?url=` value | - -## Build - -```sh -cargo build --release -# Output: target/wasm32-wasip1/release/backend.wasm -``` - -## Expected behavior - -| Request | Response status | Response body | -|---|---|---| -| `GET /?url=https%3A%2F%2Fhttpbin.org%2Fget` | 200 | `len = , content-type = Some("")` | -| `GET /?q=hello` (no `url` key) | 500 | `missing url parameter` | -| `GET /` (no query string) | 500 | `missing uri query parameter` | - -The `len` value is the byte length of the upstream response body. The `content-type` value is the `Content-Type` header returned by the upstream server, formatted as a Rust `Option` debug string (e.g. `Some("application/json")`). -``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-bloom-filter-denylist-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-bloom-filter-denylist-wasi-rust.md index d9377ed..7acebb4 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-bloom-filter-denylist-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-bloom-filter-denylist-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -150,3 +150,164 @@ Uses the `wstd` WASI Component Model HTTP server macro. - KV Store provisioning and population via FastEdge API - examples-bloom-filter-denylist-js (JavaScript mirror of this example) - platform-overview (KV Store concepts) + +## Source Material + +### FILE: examples/http/wasi/bloom_filter_denylist/src/lib.rs + +```rust +/* + * Copyright 2025 G-Core Innovations SARL + */ +/* +Bloom-filter IP denylist example. + +Checks the client IP (from the `x-real-ip` request header, falling back to +`x-forwarded-for`) against a bloom filter stored in FastEdge KV. Returns 403 +on a hit, 200 otherwise. + +Required configuration: + - Environment variable: DENYLIST_STORE (KV store name holding the bloom filter) + +The bloom-filter key is hardcoded to `blocked-ips`. The handler is read-only; +populate the filter out of band. + +Mirror of the FastEdge-sdk-js `bloom-filter-denylist` example. +*/ + +use std::env; + +use anyhow::anyhow; +use fastedge::key_value::{Error as StoreError, Store}; +use serde_json::json; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +const BLOOM_KEY: &str = "blocked-ips"; + +#[wstd::http_server] +async fn main(req: Request) -> anyhow::Result> { + let store_name = match env::var("DENYLIST_STORE") { + Ok(s) if !s.trim().is_empty() => s, + _ => { + return json_response( + 500, + json!({ "error": "DENYLIST_STORE environment variable is not configured" }), + ); + } + }; + + let headers = req.headers(); + let client_ip = headers + .get("x-real-ip") + .or_else(|| headers.get("x-forwarded-for")) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + let Some(ip) = client_ip else { + return json_response(500, json!({ "error": "client IP not available" })); + }; + + let store = match Store::open(&store_name) { + Ok(s) => s, + Err(StoreError::AccessDenied) => { + return json_response( + 403, + json!({ "error": "access denied opening denylist store" }), + ); + } + Err(e) => { + return json_response(500, json!({ "error": format!("store open error: {e}") })); + } + }; + + let blocked = store + .bf_exists(BLOOM_KEY, ip) + .map_err(|e| anyhow!("bf_exists error: {e}"))?; + + if blocked { + // Bloom filter says "maybe in set" — a small fraction of hits will be false + // positives. Acceptable for a denylist (you over-block some legitimate users); + // not acceptable for allowlists — use `store.get()` against a regular key instead. + return json_response(403, json!({ "allowed": false, "ip": ip })); + } + + json_response(200, json!({ "allowed": true, "ip": ip })) +} + +fn json_response(status: u16, value: serde_json::Value) -> anyhow::Result> { + Ok(Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(value.to_string()))?) +} +``` + + +### FILE: examples/http/wasi/bloom_filter_denylist/Cargo.toml + +```toml +[workspace] + +[package] +name = "bloom_filter_denylist_wasi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +fastedge = "0.4" +anyhow = "1" +serde_json = "1" +``` + + +### FILE: examples/http/wasi/bloom_filter_denylist/README.md + +``` +[← Back to examples](../../../README.md) + +# Bloom Filter — IP Denylist (WASI) + +Rejects requests from IPs present in a KV Store bloom filter. Reads the client IP from the +`x-real-ip` request header (falling back to `x-forwarded-for`), checks it against a +pre-populated bloom filter, and returns **403** on a hit or **200** otherwise. + +Demonstrates `fastedge::key_value::Store` + `bf_exists()` and the conventional way to obtain +the client IP from a Component Model HTTP handler. + +## Configuration + +- Environment variable `DENYLIST_STORE` — name of the KV store that holds the bloom filter. +- Bloom-filter key — hardcoded to `blocked-ips`. Change `BLOOM_KEY` in `src/lib.rs` if your + key is different. + +## Behaviour + +| `bf_exists("blocked-ips", ip)` | Response | +| --- | --- | +| `true` | `403` `{ "allowed": false, "ip": "..." }` | +| `false` | `200` `{ "allowed": true, "ip": "..." }` | + +## Tradeoff: false positives + +Bloom filters answer "**definitely not** in set" vs "**maybe** in set". When `bf_exists` +returns `true`, the IP *probably* was added — but a small fraction of hits will be false +positives, meaning some legitimate IPs will be over-blocked. Acceptable for a denylist; for +allowlists or anything requiring exact membership, use `store.get()` against a regular key +instead. + +## Populating the filter + +The edge handler is read-only. Populate `blocked-ips` out of band (for example, via the +FastEdge API). + +## Related + +Mirror of `FastEdge-sdk-js/examples/bloom-filter-denylist/`. +``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-basic-rust.md index cc1509e..a1e64c6 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -133,3 +133,149 @@ use fastedge::http::{Request, Response, StatusCode}; - examples-cache-basic-rust (this file) pairs with the HTTP app deploy reference for upload and wiring steps - platform-overview for cache subsystem lifecycle and eviction behavior - best-practices for cache key namespacing and TTL selection guidance + +## Source Material + +### FILE: examples/http/basic/cache/src/lib.rs + +```rust +/* + * Copyright 2025 G-Core Innovations SARL + */ +/* +Example app demonstrating cache-aside pattern via the cache interface. + +On each request the app: + 1. Builds a cache key from the request path. + 2. Returns the cached body immediately on a hit (x-cache: hit). + 3. On a miss, generates a response body, stores it in the cache, and + returns it (x-cache: miss). + +Environment variables: + CACHE_TTL_MS How long to cache the generated body in milliseconds + (default: 30000) + +Build: + cargo build --release +*/ + +use std::env; + +use fastedge::body::Body; +use fastedge::cache; +use fastedge::http::{Request, Response, StatusCode}; + +#[fastedge::http] +fn main(req: Request) -> anyhow::Result> { + let ttl_ms: u64 = env::var("CACHE_TTL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30_000); + + let path = req.uri().path().to_string(); + let cache_key = format!("page:{path}"); + + // Cache hit — return stored body + if let Some(cached) = cache::get(&cache_key)? { + println!("cache hit: {cache_key}"); + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .header("x-cache", "hit") + .body(Body::from(cached))?); + } + + // Cache miss — generate the response body + println!("cache miss: {cache_key}"); + let body = generate_body(&path); + + // Store in cache with TTL + cache::set(&cache_key, body.as_bytes(), Some(ttl_ms))?; + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .header("x-cache", "miss") + .body(Body::from(body))?) +} + +/// Simulates an expensive computation or template render. +fn generate_body(path: &str) -> String { + format!( + "\ + FastEdge Cache Demo\ + \ +

Hello from FastEdge

\ +

Path: {path}

\ +

This response was generated and is now cached.

\ + " + ) +} +``` + + +### FILE: examples/http/basic/cache/Cargo.toml + +```toml +[workspace] + +[package] +name = "cache_basic" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +fastedge = "0.4" +anyhow = "1" +``` + + +### FILE: examples/http/basic/cache/README.md + +``` +[← Back to examples](../../../README.md) + +# Cache (Basic) + +Demonstrates the cache-aside pattern using `fastedge::cache` — store a generated response body with a TTL and serve it directly on subsequent requests without re-computing it. + +## Configuration + +| Env var | Required | Description | +|---|---|---| +| `CACHE_TTL_MS` | No | How long to cache each response in milliseconds. Default: `30000` (30 s). | + +## How it works + +``` +GET /api/data → cache miss → generate body → store in cache → 200 (x-cache: miss) +GET /api/data → cache hit → return cached body → 200 (x-cache: hit) +``` + +The cache key is `page:`. Each unique path gets its own cache entry. The response body is a simple HTML page that includes the request path — stand in for any expensive computation or template render. + +## What it returns + +``` +HTTP/1.1 200 OK +content-type: text/html +x-cache: hit | miss + +... +``` + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip1/release/cache_basic.wasm +``` + +## APIs used + +- `fastedge::cache::get(key)` — retrieve cached bytes by key; returns `Ok(Option>)` +- `fastedge::cache::set(key, bytes, ttl_ms)` — store bytes with optional TTL in milliseconds; `None` means no expiry +``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-wasi-rust.md index a84bdad..b8d5f3c 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -188,197 +188,3 @@ cargo build --release - HTTP outbound requests via wstd (sdk-reference-rust) - Environment variable configuration (platform-overview) - HTTP app deploy workflow (deploy skill) - -## Source Material - -### FILE: examples/http/wasi/cache/src/lib.rs - -```rust -/* - * Copyright 2025 G-Core Innovations SARL - */ -/* -Example app demonstrating response caching and cache purging via the cache interface. - -The app reads ORIGIN_HOST from the environment, forwards the incoming request -to that origin, and caches the response body keyed by the request path. -On subsequent requests for the same path the cached body is returned directly -without hitting the origin. - -Cache reads and writes use the synchronous `fastedge::cache` API; upstream -HTTP I/O still uses the async `wstd` client. - -Special purge routes (handled before any origin call): - GET /purge — purge all cached keys; returns 200 with deleted count - GET /purge/ — purge keys whose cache key starts with cache:/ - -Environment variables: - ORIGIN_HOST Base URL of the upstream origin, e.g. https://api.example.com - CACHE_TTL_MS How long to cache responses in milliseconds (default: 60000) - -Build: - cargo build --release -*/ - -use std::env; - -use anyhow::anyhow; -use fastedge::cache; -use wstd::http::body::Body; -use wstd::http::{Client, Request, Response}; - -#[wstd::http_server] -async fn main(req: Request) -> anyhow::Result> { - let origin = env::var("ORIGIN_HOST") - .map_err(|_| anyhow!("ORIGIN_HOST environment variable is not set"))?; - - let ttl_ms = req.headers().get("cache-ttl-ms").and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse().ok()) - .unwrap_or_else(|| { - env::var("CACHE_TTL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(60_000) - }); - - // Build cache key from the request path (and query string if present) - let path_and_query = req - .uri() - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); - - - // Handle purge requests before any cache/origin logic - if path_and_query == "/purge" { - let deleted = cache::purge()?; - println!("purge all: {deleted} keys removed"); - return Ok(Response::builder() - .status(204) - .body(Body::empty())?); - } - if let Some(prefix) = path_and_query.strip_prefix("/purge/") { - let prefix = format!("cache:/{prefix}"); - let deleted = cache::purge_prefix(&prefix)?; - println!("purge prefix '{prefix}': {deleted} keys removed"); - return Ok(Response::builder() - .status(204) - .body(Body::empty())?); - } - - if let Some(prefix) = path_and_query.strip_prefix("/delete/") { - let prefix = format!("cache:/{prefix}"); - cache::delete(&prefix)?; - println!("prefix '{prefix}': removed"); - return Ok(Response::builder() - .status(204) - .body(Body::empty())?); - } - - let cache_key = format!("cache:{path_and_query}"); - - // Return cached response if available - if let Some(cached) = cache::get(&cache_key)? { - println!("cache hit: {cache_key}"); - return Ok(Response::builder() - .status(200) - .header("content-type", "application/octet-stream") - .header("x-cache", "hit") - .body(Body::from(cached))?); - } - - // Cache miss — forward request to origin - let upstream_url = format!("{}{}", origin.trim_end_matches('/'), path_and_query); - println!("cache miss: {cache_key} → {upstream_url}"); - - let upstream_req = Request::get(&upstream_url) - .body(Body::empty()) - .map_err(|e| anyhow!("failed to build upstream request: {e}"))?; - - let upstream_resp = Client::new() - .send(upstream_req) - .await - .map_err(|e| anyhow!("upstream request failed: {e}"))?; - - let status = upstream_resp.status(); - let headers: Vec<(String, String)> = upstream_resp - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - // Read body bytes - let mut body = upstream_resp.into_body(); - let body_bytes = body.contents().await?.to_vec(); - - // Only cache successful responses - if status.is_success() { - cache::set(&cache_key, &body_bytes, Some(ttl_ms))?; - } - - // Replay original response - let mut builder = Response::builder() - .status(status) - .header("x-cache", "miss"); - for (k, v) in &headers { - builder = builder.header(k, v); - } - Ok(builder.body(Body::from(body_bytes))?) -} -``` - - -### FILE: examples/http/wasi/cache/Cargo.toml - -```toml -[workspace] - -[package] -name = "cache_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -fastedge = "0.4" -anyhow = "1" -``` - - -### FILE: examples/http/wasi/cache/README.md - -``` -[← Back to examples](../../../README.md) - -# Cache (WASI) - -Demonstrates the cache-aside pattern with origin forwarding using `fastedge::cache`. Forwards incoming requests to `ORIGIN_HOST`, caches successful response bodies keyed by path and query string, and serves cached bytes directly on subsequent matching requests. - -## Configuration - -| Env var | Required | Description | -|---|---|---| -| `ORIGIN_HOST` | Yes | Base URL of the upstream origin (e.g. `https://api.example.com`). Returns 500 if unset. | -| `CACHE_TTL_MS` | No | How long to cache responses in milliseconds. Default: `60000` (60 s). | - -## How it works - -GET /data?id=1 → cache miss → forward to ORIGIN_HOST/data?id=1 → cache 2xx body → 200 (x-cache: miss) -GET /data?id=1 → cache hit → return cached body → 200 (x-cache: hit) - -Cache key is `cache:?`. Only 2xx responses from the origin are cached — error responses pass through without being stored. The origin's response headers are replayed on cache miss; cache-hit responses use `content-type: application/octet-stream` since the original content-type is not stored alongside the body bytes. - -## Build - -cargo build --release -# Output: target/wasm32-wasip2/release/cache_wasi.wasm - -## APIs used - -- `fastedge::cache::get(key)` — retrieve cached bytes by key; returns `Ok(Option>)` -- `fastedge::cache::set(key, bytes, ttl_ms)` — store bytes with optional TTL in milliseconds; `None` means no expiry -- `wstd::http::Client::new().send(req).await` — async outbound HTTP request to origin -``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-diagnostic-logging-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-diagnostic-logging-wasi-rust.md index 05dd5fe..da2a970 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-diagnostic-logging-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-diagnostic-logging-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -190,152 +190,3 @@ Use `key=value` pairs separated by spaces (`logfmt`-ish format). Benefits: - host-services-rust (other host service integrations available to Rust WASI apps) - CDN (proxy-wasm) variant: `fastedge::proxywasm::utils::set_user_diag` — same semantics, different module path (see CDN apps reference) - examples-kv-store-wasi-rust (another Rust WASI HTTP example showing KV Store usage) - -## Source Material - -### FILE: examples/http/wasi/diagnostic_logging/src/lib.rs - -```rust -/* - * Copyright 2025 G-Core Innovations SARL - */ -/* -Diagnostic logging example. - -Tiny pass-through proxy that writes a single `set_user_diag` tag per request -summarising the outcome (config_error, origin_unreachable, or proxied). The -tag appears in the FastEdge platform's per-request log viewer and is distinct -from stdout — it's intended for filterable outcome labels, not verbose -traces. - -Required configuration: - - Environment variable: ORIGIN_URL (origin to proxy to) - -Uses `logfmt`-ish formatting (`outcome= key=value ...`) so the tag is -easy to slice in log search tooling. -*/ - -use std::env; - -use fastedge::utils::set_user_diag; -use wstd::http::body::Body; -use wstd::http::{Client, Request, Response}; - -#[wstd::http_server] -async fn main(req: Request) -> anyhow::Result> { - let method = req.method().as_str().to_string(); - let path = req.uri().path().to_string(); - - let origin = match env::var("ORIGIN_URL") { - Ok(u) if !u.trim().is_empty() => u, - _ => { - set_user_diag("outcome=config_error reason=origin_missing"); - return Ok(Response::builder() - .status(500) - .header("content-type", "text/plain; charset=utf-8") - .body(Body::from("ORIGIN_URL is not configured"))?); - } - }; - - let outbound = Request::get(&origin).body(Body::empty())?; - let resp = match Client::new().send(outbound).await { - Ok(r) => r, - Err(e) => { - set_user_diag(&format!( - "outcome=origin_unreachable method={method} path={path} err={e}" - )); - return Ok(Response::builder() - .status(502) - .header("content-type", "text/plain; charset=utf-8") - .body(Body::from("origin unreachable"))?); - } - }; - - let status = resp.status().as_u16(); - set_user_diag(&format!( - "outcome=proxied method={method} path={path} status={status}" - )); - - let (parts, mut body) = resp.into_parts(); - let bytes = body.contents().await?; - let content_type = parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - - Ok(Response::builder() - .status(parts.status) - .header("content-type", content_type) - .body(Body::from(bytes))?) -} -``` - - -### FILE: examples/http/wasi/diagnostic_logging/Cargo.toml - -```toml -[workspace] - -[package] -name = "diagnostic_logging_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -fastedge = "0.4" -anyhow = "1" -``` - - -### FILE: examples/http/wasi/diagnostic_logging/README.md - -``` -[← Back to examples](../../../README.md) - -# Diagnostic Logging (WASI) - -Pass-through proxy that writes a single `fastedge::utils::set_user_diag` tag per request -summarising the outcome. The tag appears in the FastEdge platform's per-request log viewer, -distinct from stdout, and is designed to be filtered/counted/aggregated by SREs looking at -per-request outcomes. - -## Configuration - -- `ORIGIN_URL` environment variable — the origin that requests are proxied to. - -## Outcomes - -Each request writes exactly one of: - -| Condition | Tag | -| --- | --- | -| `ORIGIN_URL` missing | `outcome=config_error reason=origin_missing` | -| Origin unreachable | `outcome=origin_unreachable method= path=

err=` | -| Request proxied | `outcome=proxied method= path=

status=` | - -## `set_user_diag` vs `println!` - -| | `println!` | `set_user_diag` | -| --- | --- | --- | -| Channel | stdout — general application logs | per-request structured tag in platform log viewer | -| Cardinality | many per request | **one per request** — multiple calls leave only the last or are concatenated (undefined) | -| Best for | verbose traces, debug details | a single filterable outcome label | -| Forbidden | — | secrets and PII (tags appear in platform logs) | - -## Convention - -Call `set_user_diag` **once**, on every branch, late enough in the handler to know the -outcome. The `logfmt`-ish format (`outcome= key=value key=value …`) is easy to slice in -log search tooling — keep keys short and stable so they make good filter terms. - -## Related - -CDN (proxy-wasm) variant: `fastedge::proxywasm::utils::set_user_diag` — same semantics, -different module path. See `docs/CDN_APPS.md`. -``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-geo-redirect-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-geo-redirect-wasi-rust.md index 156e1a1..1b51b47 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-geo-redirect-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-geo-redirect-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-headers-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-headers-wasi-rust.md index aa0c50f..56a90a3 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-headers-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-headers-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-basic-rust.md index 0d256e6..97dec3d 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-wasi-rust.md index 7fe9ca6..ab30b5e 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hello-world-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-kv-store-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-kv-store-wasi-rust.md index 99f207d..f629d30 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-kv-store-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-kv-store-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-large-env-variable-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-large-env-variable-wasi-rust.md index 5215a84..18a78c4 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-large-env-variable-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-large-env-variable-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # Large Environment Variable (WASI) — Rust HTTP Example diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-markdown-render-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-markdown-render-basic-rust.md index 7e480df..93ce19f 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-markdown-render-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-markdown-render-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-basic-rust.md index 50f4454..2be77e3 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-wasi-rust.md index 05d01a2..67088ca 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-fetch-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # Outbound Fetch — WASI (Rust) diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-modify-response-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-modify-response-wasi-rust.md index 83b2f9f..46414f6 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-modify-response-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-outbound-modify-response-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-print-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-print-basic-rust.md index b0699e3..0c80045 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-print-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-print-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -147,112 +147,3 @@ cargo build --release - sdk-reference-rust (full API reference for `fastedge` crate) - platform-overview (request lifecycle, header forwarding behaviour) - best-practices (response building patterns) - -## Source Material - -### FILE: examples/http/basic/print/src/lib.rs - -```rust -use anyhow::Result; -use fastedge::body::Body; -use fastedge::http::{Request, Response, StatusCode}; - -#[allow(dead_code)] -#[fastedge::http] -fn main(req: Request) -> Result> { - let mut body: String = "Method: ".to_string(); - body.push_str(req.method().as_str()); - - body.push_str("\nURL: "); - body.push_str(req.uri().to_string().as_str()); - - body.push_str("\nHeaders:"); - for (h, v) in req.headers() { - body.push_str("\n "); - body.push_str(h.as_str()); - body.push_str(": "); - match v.to_str() { - Err(_) => body.push_str("not a valid text"), - Ok(a) => body.push_str(a), - } - } - let res = Response::builder() - .status(StatusCode::OK) - .body(Body::from(body))?; - Ok(res) -} -``` - -### FILE: examples/http/basic/print/Cargo.toml - -```toml -[workspace] - -[package] -name = "print" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -fastedge = "0.4" -anyhow = "1" -``` - -### FILE: examples/http/basic/print/README.md - -``` -[← Back to examples](../../../README.md) - -# Print - -Echoes the incoming request's method, URL, and all headers back in the response body as plain text. Useful for debugging and inspecting what a FastEdge app receives from clients and the platform. - -> **Note:** This example uses the legacy `#[fastedge::http]` sync handler (`wasm32-wasip1`). For new apps, prefer `#[wstd::http_server]` (async, `wasm32-wasip2`) — see [`examples/http/wasi/`](../../wasi/). - -## What it demonstrates - -- Reading request method via `req.method().as_str()` -- Reading the request URI via `req.uri().to_string()` -- Iterating all request headers via `req.headers()` -- Handling non-UTF-8 header values gracefully with a `match` on `v.to_str()` -- Building a plain-text response with `Response::builder()` and `Body::from(...)` - -## APIs used - -| API | Purpose | -|-----|---------| -| `req.method().as_str()` | HTTP method as a string slice | -| `req.uri().to_string()` | Full request URI as a `String` | -| `req.headers()` | Iterator over `(HeaderName, HeaderValue)` pairs | -| `v.to_str()` | Decode a header value to `&str` (returns `Err` for non-UTF-8) | -| `Response::builder().status(...).body(...)` | Build the HTTP response | -| `Body::from(string)` | Create a response body from a `String` | - -## Build - -```sh -cargo build --release -# Output: target/wasm32-wasip1/release/print.wasm -``` - -## Expected behaviour - -For any request, the response body is a plain-text dump of the request details: - -``` -Method: GET -URL: /some/path?query=value -Headers: - host: example.com - accept: */* - ... -``` - -- Status: `200 OK` -- Content: plain text (no `content-type` header is set explicitly; the platform may add one) -- Each header appears on its own line, indented with four spaces -- Non-UTF-8 header values are replaced with `not a valid text` -``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-s3upload-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-s3upload-basic-rust.md index f789dad..f523acd 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-s3upload-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-s3upload-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -182,236 +182,3 @@ cargo build --release - sdk-reference-rust (`fastedge::send_request`, `Body`, `Request`, `Response`) - host-services-rust (outbound HTTP) - rusty-s3 crate documentation (external) - -## Source Material - -### FILE: examples/http/basic/s3upload/src/lib.rs - -```rust -use std::time::Duration; - -use fastedge::{ - body::Body, - http::{header, Error, Method, Request, Response, StatusCode}, -}; -use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle}; -use std::{collections::HashMap, env}; -use url::Url; - -#[fastedge::http] -fn main(req: Request) -> Result, Error> { - match req.method() { - // Allow only POST and PUT requests - &Method::POST | &Method::PUT => (), - - &Method::OPTIONS => { - return Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()); - } - - // Deny anything else - _ => { - return Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .header(header::ALLOW, "PUT, POST") - .body(Body::from("This method is not allowed\n")); - } - }; - - /* get request params */ - let query_pairs = |q: &str| { - q.split('&') - .filter_map(|q| { - let mut i = q.splitn(2, '='); - let k = i.next()?; - let v = i.next()?; - Some((k, v)) - }) - .map(|(k, v)| (k.to_owned(), v.to_owned())) - .collect::>() - }; - let hash_query: HashMap = req.uri().query().map_or(HashMap::new(), query_pairs); - - let fname = match hash_query.get("name") { - None => { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from("Malformed request\n")) - } - Some(i) => i, - }; - if req.body().is_empty() { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from("Malformed request\n")); - } - let content_type = match req.headers().get("Content-Type") { - None => "application/octet-stream", - Some(v) => v.to_str().unwrap_or("application/octet-stream"), - }; - let content_type = content_type.to_owned(); - let content = req.into_body(); - - match env::var("MAX_FILE_SIZE").ok() { - None => {} - Some(l) => match l.parse::() { - Err(_) => {} - Ok(v) => { - if content.len() > v { - let msg = format!("File exceeds allowed limit of {} bytes\n", v); - return Response::builder() - .status(StatusCode::PAYLOAD_TOO_LARGE) - .body(Body::from(msg.as_str().to_owned())); - } - } - }, - } - - let (signed_url, host) = match prepare_s3(fname) { - Err(_) => { - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from("App misconfigured\n")) - } - Ok((u, h)) => (u, h), - }; - - /* build outgoing req */ - let out_req = Request::builder() - .method(Method::PUT) - .uri(signed_url.as_str()) - .header("Host", host) - .header("Accept-Encoding", "identity") - .header("Content-Length", content.len().to_string()) - .header("Content-Type", content_type); - - let Ok(req) = out_req.body(content) else { - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from("Malformed request\n")); - }; - - let rsp = match fastedge::send_request(req) { - Err(_) => { - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::empty()) - } - Ok(r) => r, - }; - let (parts, body) = rsp.into_parts(); - let body = if parts.status == StatusCode::OK { - let mut tmp_url = signed_url.clone(); - tmp_url.set_query(None); - Body::from(tmp_url.to_string()) - } else { - body - }; - Ok(Response::from_parts(parts, body)) -} - -fn prepare_s3(fname: &str) -> anyhow::Result<(Url, String)> { - /* read S3 access params from env */ - let access_key = env::var("ACCESS_KEY")?; - let secret_key = env::var("SECRET_KEY")?; - let region = env::var("REGION")?; - let base_hostname = env::var("BASE_HOSTNAME")?; - let bucket = env::var("BUCKET")?; - let scheme = env::var("SCHEME").unwrap_or_else(|_| "http".to_string()); - - /* set S3 request params */ - let host = region.clone() + "." + base_hostname.as_str(); - let upload_url = scheme + "://" + host.as_str(); - let parsed_url = upload_url.parse()?; - let bucket = Bucket::new(parsed_url, UrlStyle::Path, bucket, region)?; - - let creds = Credentials::new(access_key, secret_key); - let action = bucket.put_object(Some(&creds), fname); - let signed_url = action.sign(Duration::from_secs(60 * 60)); - - Ok((signed_url, host)) -} -``` - - -### FILE: examples/http/basic/s3upload/Cargo.toml - -```toml -[workspace] - -[package] -name = "s3upload" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -fastedge = "0.4" -url = "2.3" -rusty-s3 = "0.5" -anyhow = "1" -``` - - -### FILE: examples/http/basic/s3upload/README.md - -``` -[← Back to examples](../../../README.md) - -# S3 Upload - -FastEdge edge function that accepts a file upload, signs an S3 PUT request on the fly, uploads the file directly to an S3-compatible bucket, and returns the clean object URL to the caller. - -> **Legacy handler:** Uses `#[fastedge::http]` (sync, `wasm32-wasip1`). For new apps prefer the async WASI handler — see [`examples/http/wasi/`](../../wasi/). - -## What it does - -1. Accepts `POST` or `PUT` only — returns 405 for other methods -2. Requires `?name=` query parameter and a non-empty body — returns 400 otherwise -3. Enforces `MAX_FILE_SIZE` if set — returns 413 if exceeded -4. Calls `prepare_s3()` to build a 1-hour presigned `PUT` URL using `rusty_s3` -5. Forwards the file body to S3 via `fastedge::send_request` -6. On success (S3 returns 200): responds with the clean object URL (no query string) -7. On S3 error: forwards the S3 status and error body back to the caller - -## Configuration - -| Env var | Required | Description | -|---|---|---| -| `ACCESS_KEY` | ✅ | S3 access key | -| `SECRET_KEY` | ✅ | S3 secret key | -| `REGION` | ✅ | S3 region (e.g. `s-ed1`) | -| `BASE_HOSTNAME` | ✅ | S3 base hostname (e.g. `cloud.gcore.lu`) | -| `BUCKET` | ✅ | S3 bucket name | -| `SCHEME` | optional | URL scheme — defaults to `http` | -| `MAX_FILE_SIZE` | optional | Maximum upload size in bytes — no limit if unset | - -The constructed endpoint is `://.//`. - -## Build - -```sh -cargo build --release -# Output: target/wasm32-wasip1/release/s3upload.wasm -``` - -## Usage - -``` -POST /upload?name=photo.jpg -Content-Type: image/jpeg - - -``` - -On success (200), the response body is the clean S3 object URL (presign query parameters stripped). - -## Notes - -- The `OPTIONS` method returns 204 but does **not** include CORS headers — add `Access-Control-Allow-*` headers if browser preflight support is needed. -- The presigned URL expires after 1 hour, but since the upload is performed server-side this has no practical impact. -- `MAX_FILE_SIZE` is silently ignored if set to a non-numeric value. -``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-basic-rust.md index d65296b..44ff104 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # Secret Access — Rust HTTP Example @@ -251,3 +251,183 @@ node tools/fixture-validator/index.mjs \ - fastedge-docs skill reference: sdk-reference-rust - fastedge-docs skill reference: error-codes - manage skill: secret management subcommands (set, list, delete) + +## Source Material + +### FILE: examples/http/basic/secret/src/lib.rs + +```rust +use anyhow::{Error, Result}; +use std::time::SystemTime; + +use fastedge::body::Body; +use fastedge::http::{Request, Response, StatusCode}; +use fastedge::secret; + +#[allow(dead_code)] +#[fastedge::http] +fn main(_req: Request) -> Result> { + let value = match secret::get("SECRET") { + Ok(value) => value, + Err(secret::Error::AccessDenied) => { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::empty()) + .map_err(Error::msg); + } + Err(secret::Error::Other(msg)) => { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from(msg)) + .map_err(Error::msg); + } + Err(secret::Error::DecryptError) => { + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::empty()) + .map_err(Error::msg); + } + }; + + if value.is_none() { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .map_err(Error::msg); + } + + let ts = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let effective_at_value = match secret::get_effective_at("SECRET", ts as u32) { + Ok(value) => value, + Err(secret::Error::AccessDenied) => { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::empty()) + .map_err(Error::msg); + } + Err(secret::Error::Other(msg)) => { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from(msg)) + .map_err(Error::msg); + } + Err(secret::Error::DecryptError) => { + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::empty()) + .map_err(Error::msg); + } + }; + + if effective_at_value.is_none() { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .map_err(Error::msg); + } + + Response::builder() + .status(StatusCode::OK) + .body(Body::from(format!( + "get={:?}\nget_efective_at={:?}\n", + value, effective_at_value + ))) + .map_err(Error::msg) +} +``` + +### FILE: examples/http/basic/secret/Cargo.toml + +```toml +[workspace] + +[package] +name = "secret" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +fastedge = "0.4" +anyhow = "1" +``` + +### FILE: examples/http/basic/secret/README.md + +``` +[← Back to examples](../../../README.md) + +# Secret + +Demonstrates accessing encrypted secrets injected by the FastEdge platform using `secret::get()` and `secret::get_effective_at()`. Shows how to handle all error variants (access denied, decrypt error) and the time-based secret rotation API. + +## What it does + +On every request, the handler: + +1. Calls `secret::get("SECRET")` — retrieves the current value of the secret named `SECRET`. +2. If the secret is missing (returned as `None`), returns **404**. +3. Calls `secret::get_effective_at("SECRET", )` — retrieves the secret value effective at the current Unix timestamp, demonstrating the rotation/versioning API. +4. If that value is also missing, returns **404**. +5. On success, returns **200** with both values in the body (Debug format). + +## APIs used + +| API | Purpose | +|---|---| +| `#[fastedge::http]` | Sync request-response handler macro | +| `fastedge::secret::get(key)` | Retrieve the current value of a named secret | +| `fastedge::secret::get_effective_at(key, timestamp)` | Retrieve the secret value effective at a specific Unix timestamp | +| `fastedge::secret::Error` | Error variants: `AccessDenied`, `Other(msg)`, `DecryptError` | +| `fastedge::http::{Request, Response, StatusCode}` | HTTP types | +| `fastedge::body::Body` | Response body | + +## Secret error variants + +| Variant | HTTP response | Meaning | +|---|---|---| +| `Ok(Some(value))` | 200 with body | Secret found | +| `Ok(None)` | 404 empty | Secret name is valid but not set | +| `Err(AccessDenied)` | 403 empty | App is not permitted to read this secret | +| `Err(Other(msg))` | 403 with `msg` body | Other denial with a human-readable message | +| `Err(DecryptError)` | 500 empty | Secret exists but could not be decrypted | + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip1/release/secret.wasm +``` + +## Expected behavior + +| Scenario | Secret `SECRET` | Response status | Response body | +|---|---|---|---| +| Happy path | `"my-value"` | 200 | `get=Some("my-value")\nget_efective_at=Some("my-value")\n` | +| Secret not set | (absent) | 404 | (empty) | +| Access denied | — | 403 | (empty) | + +> **Note:** The response body contains a typo in the field name (`get_efective_at` instead of `get_effective_at`). This is a known cosmetic issue in the source. + +## Local testing + +Inject the secret via a `.env` file in your fixtures directory using the `FASTEDGE_VAR_SECRET_` prefix: + +``` +# fixtures/.env +FASTEDGE_VAR_SECRET_SECRET=my-test-value +``` + +Run with the fixture validator: + +```sh +node tools/fixture-validator/index.mjs \ + FastEdge-sdk-rust/examples/http/basic/secret/ \ + --wasm FastEdge-sdk-rust/examples/http/basic/secret/target/wasm32-wasip1/release/secret.wasm +``` +``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-rollover-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-rollover-wasi-rust.md index df75287..70416dc 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-rollover-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-secret-rollover-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # Secret Rollover (WASI, Rust) @@ -134,7 +134,7 @@ use wstd::http::{Request, Response}; #[wstd::http_server] async fn main(request: Request) -> anyhow::Result> { - // Read slot from header, default to current unix timestamp + // Read the slot from the x-slot header, defaulting to current timestamp let slot: u32 = request .headers() .get("x-slot") @@ -153,11 +153,10 @@ async fn main(request: Request) -> anyhow::Result> { .and_then(|v| v.to_str().ok()) .unwrap_or("TOKEN_SECRET"); - // Get current (latest) value - let current = secret::get(secret_name) - .map_err(|e| anyhow!("secret::get failed: {e}"))?; + // Get the current secret value (latest slot) + let current = secret::get(secret_name).map_err(|e| anyhow!("secret::get failed: {e}"))?; - // Get value effective at the given slot + // Get the secret effective at the requested slot let effective = secret::get_effective_at(secret_name, slot) .map_err(|e| anyhow!("secret::get_effective_at failed: {e}"))?; diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-simple-fetch-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-simple-fetch-wasi-rust.md index 5d19fe4..56c7e10 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-simple-fetch-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-simple-fetch-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -16,7 +16,7 @@ capabilities: [outbound-fetch, header-read, async] # Example: Simple Fetch (WASI HTTP, Rust) -Demonstrates outbound HTTP requests using the WASI-HTTP interface via the `wstd` crate. Reads a target URL from an incoming request header and proxies the response back to the caller. +Demonstrates outbound HTTP requests using the WASI-HTTP interface via the `wstd` crate. Reads a target URL from an incoming request header and proxies the response back to the caller. Optionally forwards a custom header to the outbound request. ## Crate and Handler @@ -32,13 +32,16 @@ Demonstrates outbound HTTP requests using the WASI-HTTP interface via the `wstd` | Header | Required | Type | Default | Description | |--------|----------|------|---------|-------------| | `x-fetch-url` | No | String (fully-qualified URL) | `https://httpbin.org/get` | URL to fetch outbound | +| `x-fetch-header` | No | String (`key: value`) | — | Additional header to forward to the outbound request; parsed on `:` separator | ## Behavior 1. Read `x-fetch-url` header from the incoming request; fall back to `https://httpbin.org/get` if absent or unparseable. -2. Build an outbound `GET` request to that URL with `accept: application/json` header. -3. Send via `Client::new().send(req).await`. -4. Return the upstream `Response` directly to the caller — no decomposition. +2. Begin building an outbound `GET` request to that URL with `accept: application/json` header. +3. If `x-fetch-header` is present and parseable as `key: value` (split on first `:`), add that header to the outbound request builder. +4. Finalize the builder with `.body(Body::empty())`. +5. Send via `Client::new().send(req).await`. +6. Return the upstream `Response` directly to the caller — no decomposition. ## Key API Patterns @@ -58,14 +61,34 @@ let target_url = request - `.unwrap_or(default)` provides a safe fallback - `.to_string()` required — `Request::get` takes a `&str` or `String`; ensure the URL is owned before use +### Conditionally Adding an Outbound Header + +```rust +let mut builder = Request::get(&target_url).header("accept", "application/json"); + +if let Some(fetch_header) = request + .headers() + .get("x-fetch-header") + .and_then(|v| v.to_str().ok()) +{ + if let Some((key, value)) = fetch_header.split_once(':') { + builder = builder.header(key.trim(), value.trim()); + } +} +``` + +- The builder is `mut` to allow conditional chaining. +- `.split_once(':')` splits on the first `:`, returning `Option<(&str, &str)>`. +- `.trim()` removes surrounding whitespace from key and value before passing to `.header()`. +- If `x-fetch-header` is absent or unparseable, the builder proceeds without the extra header. + ### Building an Outbound Request ```rust use wstd::http::{Client, Request}; use wstd::http::body::Body; -let upstream_req = Request::get(&target_url) - .header("accept", "application/json") +let upstream_req = builder .body(Body::empty()) .map_err(|e| anyhow!("failed to build request: {e}"))?; ``` @@ -138,6 +161,8 @@ package = "component:simple_fetch" - The URL extracted from the header must be converted to an owned `String` with `.to_string()` before passing to `Request::get`. - The header parsing chain (`.get` → `.to_str()` → `.ok()`) returns `Option` — always provide a fallback via `.unwrap_or`. - Non-UTF-8 header values are silently discarded by `.to_str().ok()`. +- `x-fetch-header` is parsed with `.split_once(':')` — only the first `:` is used as the separator; values containing `:` are preserved correctly. +- The request builder must be declared `mut` when conditionally adding headers after initial construction. - `anyhow` must be declared as a dependency to use the `anyhow!()` macro. - All examples in `examples/http/wasi/` use the same `async fn main` + `#[wstd::http_server]` pattern. @@ -147,3 +172,134 @@ package = "component:simple_fetch" - sdk-reference-rust - examples-simple-request-rust (basic sync HTTP handler, `fastedge` crate) - host-services-rust (outbound fetch via host services) + +## Source Material + +### FILE: examples/http/wasi/simple_fetch/src/lib.rs + +```rust +/* +* Copyright 2025 G-Core Innovations SARL +*/ +/* +Example app demonstrating the WASI-HTTP interface via the wstd crate. + +The app receives an incoming HTTP request and makes an outbound HTTP request +to the URL specified in the `x-fetch-url` header (defaults to https://httpbin.org/get). + +Build with cargo-component: + cargo component build --release +*/ + +use anyhow::anyhow; +use wstd::http::body::Body; +use wstd::http::{Client, Request, Response}; + +#[wstd::http_server] +async fn main(request: Request) -> anyhow::Result> { + let target_url = request + .headers() + .get("x-fetch-url") + .and_then(|v| v.to_str().ok()) + .unwrap_or("https://httpbin.org/get") + .to_string(); + + println!("Fetching: {target_url}"); + + let mut builder = Request::get(&target_url).header("accept", "application/json"); + + if let Some(fetch_header) = request + .headers() + .get("x-fetch-header") + .and_then(|v| v.to_str().ok()) + { + if let Some((key, value)) = fetch_header.split_once(':') { + builder = builder.header(key.trim(), value.trim()); + } + } + + let upstream_req = builder + .body(Body::empty()) + .map_err(|e| anyhow!("failed to build request: {e}"))?; + + let client = Client::new(); + let response = client + .send(upstream_req) + .await + .map_err(|e| anyhow!("request failed: {e}"))?; + + println!("Response status: {}", response.status()); + + Ok(response) +} +``` + + +### FILE: examples/http/wasi/simple_fetch/Cargo.toml + +```toml +[workspace] + +[package] +name = "simple_fetch" +version = "0.1.1" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +anyhow = "1" + +[package.metadata.component] +package = "component:simple_fetch" +``` + + +### FILE: examples/http/wasi/simple_fetch/README.md + +``` +[← Back to examples](../../../README.md) + +# Simple Fetch + +A minimal example demonstrating outbound HTTP requests using the WASI-HTTP interface via the `wstd` crate. + +Uses the WASI component model with an **async** handler and a proper HTTP client (`wstd::http::Client`). The same async pattern is used by all examples in `examples/http/wasi/`. + +## How it works + +The app receives an incoming request, reads the target URL from the `x-fetch-url` header, makes an outbound GET request to that URL, and streams the response back to the caller. + +If the `x-fetch-url` header is absent, it defaults to `https://httpbin.org/get`. + +## Request headers + +| Header | Required | Description | +|--------|----------|-------------| +| `x-fetch-url` | No | URL to fetch. Defaults to `https://httpbin.org/get` | + +## Example + +```bash +curl -H "x-fetch-url: https://httpbin.org/uuid" https:/// +``` + +## Build + +```bash +cargo build --release +# Output: target/wasm32-wasip2/release/simple_fetch.wasm +``` + +## Key differences from basic HTTP examples + +| | Basic HTTP (`fastedge` crate) | WASI HTTP (`wstd` crate) | +|---|---|---| +| Handler | `fn main(req)` — sync | `async fn main(req)` — async | +| Macro | `#[fastedge::http]` | `#[wstd::http_server]` | +| Outbound HTTP | `fastedge::send_request(req)` | `Client::new().send(req).await` | +| Build target | `wasm32-wasip1` | `wasm32-wasip2` | +``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-smart-switch-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-smart-switch-basic-rust.md index caea588..fc7af6b 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-smart-switch-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-smart-switch-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-static-assets-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-static-assets-wasi-rust.md index c3a255f..3174471 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-static-assets-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-static-assets-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-streaming-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-streaming-wasi-rust.md index 8a6e7c9..f972648 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-streaming-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-streaming-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -143,3 +143,105 @@ chunk 4 - examples-streaming-js reference (JavaScript mirror of this example) - wstd HTTP body reference - FastEdge SDK Rust reference + +## Source Material + +### FILE: examples/http/wasi/streaming/src/lib.rs + +```rust +/* + * Copyright 2025 G-Core Innovations SARL + */ +/* +Streaming response example. + +Generates a response body on the fly — five text chunks, one every 200ms — +using `Body::from_stream` backed by a `futures_lite::Stream`. The runtime +polls the stream as the body is sent, so chunks flow to the client as they +are produced instead of all at once at the end. + +Watch it stream with `curl -N https:///` (`-N` disables client-side +buffering). + +Mirror of the FastEdge-sdk-js `streaming` example. +*/ + +use futures_lite::stream; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; +use wstd::time::{Duration, Timer}; + +#[wstd::http_server] +async fn main(_request: Request) -> anyhow::Result> { + let chunk_stream = stream::unfold(0u32, |i| async move { + if i >= 5 { + return None; + } + Timer::after(Duration::from_millis(200)).wait().await; + Some((format!("chunk {i}\n"), i + 1)) + }); + + Ok(Response::builder() + .status(200) + .header("content-type", "text/plain; charset=utf-8") + .body(Body::from_stream(chunk_stream))?) +} +``` + + +### FILE: examples/http/wasi/streaming/Cargo.toml + +```toml +[workspace] + +[package] +name = "streaming_wasi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +anyhow = "1" +futures-lite = "1" +``` + + +### FILE: examples/http/wasi/streaming/README.md + +``` +[← Back to examples](../../../README.md) + +# Streaming Response (WASI) + +Generates a response body on the fly — five text chunks, one every 200 ms — using +`Body::from_stream` backed by a `futures_lite::Stream`. Each chunk flows to the client as it +is produced, not all at once at the end. + +Demonstrates `wstd::http::body::Body::from_stream`, `futures_lite::stream::unfold` for async +stream generation, and `wstd::time::Timer` for per-chunk delays. + +## Testing the streaming behaviour + +```sh +curl -N https://.fastedge.cdn.gc.onl/ +``` + +`-N` disables curl's client-side buffering; without it you won't see chunks appear one at a +time. You should see `chunk 0`…`chunk 4` print at ~200ms intervals. + +## Other streaming patterns + +- **Pass-through streaming** — return an upstream response's body directly. See + [outbound_fetch/](../outbound_fetch/) for the no-buffer variant. +- **Transform streaming** — use `http_body_util::BodyExt::map_frame` on the incoming body, + then `Body::from_http_body` to wrap it back. Useful for chunk-level rewrites. +- **Stream from bytes** — `Body::from_stream(futures_lite::stream::iter(chunks))` where + `chunks` is any iterable of `Into`. + +## Related + +Mirror of `FastEdge-sdk-js/examples/streaming/`. +``` diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-variables-and-secrets-wasi-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-variables-and-secrets-wasi-rust.md index 7fa38af..82558b5 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-variables-and-secrets-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-variables-and-secrets-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-watermark-basic-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-watermark-basic-rust.md index c4910c8..33ae651 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-watermark-basic-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-watermark-basic-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # HTTP Example: Watermark (Basic, Rust) diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/quickstart-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/quickstart-rust.md index 6d38e26..a54981d 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/quickstart-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/quickstart-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # FastEdge Rust SDK — Quickstart diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/sdk-reference-rust.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/sdk-reference-rust.md index 9c8406d..c6cf49c 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/sdk-reference-rust.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/sdk-reference-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> # Rust SDK Reference (`fastedge` crate + `fastedge-derive`) diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/ab-testing-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/ab-testing-rust.md index ba87cbd..958bc44 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/ab-testing-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/ab-testing-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/api-key-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/api-key-rust.md index 4295b4b..f97e840 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/api-key-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/api-key-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -212,3 +212,122 @@ target = "wasm32-wasip1" - FastEdge secrets configuration (dashboard secret variable setup) - auth-jwt-rust reference (JWT-based authentication — use when token expiry and claims are needed) - platform-overview reference (CDN vs HTTP app type distinction) + +## Source Material + +### FILE: examples/cdn/api_key/src/lib.rs + +```rust +/* +* Copyright 2025 G-Core Innovations SARL +*/ +/* +Example CDN app demonstrating API key validation. + +Validates requests using an X-API-Key header checked against a stored +secret. Simpler alternative to JWT when token expiry and claims are +not needed. + +Required configuration: + - Secret: API_KEY +*/ + +use fastedge::proxywasm::secret; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { Box::new(ApiKeyRoot) }); +}} + +struct ApiKeyRoot; + +impl Context for ApiKeyRoot {} + +impl RootContext for ApiKeyRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(ApiKeyContext)) + } +} + +struct ApiKeyContext; + +impl Context for ApiKeyContext {} + +impl HttpContext for ApiKeyContext { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + let expected_key = match secret::get("API_KEY") { + Ok(Some(bytes)) => match String::from_utf8(bytes) { + Ok(s) if !s.is_empty() => s, + _ => { + self.send_http_response(500, vec![], Some(b"App misconfigured")); + return Action::Pause; + } + }, + _ => { + self.send_http_response(500, vec![], Some(b"App misconfigured")); + return Action::Pause; + } + }; + + let provided_key = match self.get_http_request_header("X-API-Key") { + Some(k) if !k.is_empty() => k, + _ => { + self.send_http_response( + 401, + vec![("WWW-Authenticate", "API-Key")], + Some(b"Missing X-API-Key header"), + ); + return Action::Pause; + } + }; + + if provided_key != expected_key { + println!("API key validation failed"); + self.send_http_response(403, vec![], Some(b"Invalid API key")); + return Action::Pause; + } + + // Strip the API key header before forwarding to upstream + self.set_http_request_header("X-API-Key", None); + + println!("API key validated successfully"); + Action::Continue + } +} +``` + + +### FILE: examples/cdn/api_key/Cargo.toml + +```toml +[workspace] + +[package] +name = "api_key" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +fastedge = { version = "0.4", features = ["proxywasm"] } +``` + + +### FILE: examples/cdn/api_key/README.md + +``` +[← Back to examples](../../README.md) + +# API Key (CDN) + +Validates requests using an `X-API-Key` header checked against a stored secret. Returns 401 if missing, 403 if invalid, and strips the header before forwarding to upstream. +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/auth-jwt-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/auth-jwt-rust.md index c3da37d..a1badbb 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/auth-jwt-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/auth-jwt-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -259,142 +259,3 @@ target = "wasm32-wasip1" - FastEdge SDK Rust reference (proxywasm module, secret API) - FastEdge secrets configuration (dashboard secret variable setup) - platform-overview reference (CDN vs HTTP app type distinction) - -## Source Material - -### FILE: examples/cdn/jwt/src/lib.rs - -```rust -use std::time::{SystemTime, UNIX_EPOCH}; -use headers::HeaderValue; -use headers::authorization::{Bearer, Credentials}; - -use fastedge::proxywasm::secret; -use jsonwebtoken::{decode, DecodingKey, Validation}; -use proxy_wasm::traits::*; -use proxy_wasm::types::*; -use serde::Deserialize; - -proxy_wasm::main! {{ - proxy_wasm::set_log_level(LogLevel::Trace); - proxy_wasm::set_root_context(|_| -> Box { Box::new(HttpHeadersRoot) }); -}} - -struct HttpHeadersRoot; - -impl Context for HttpHeadersRoot {} - -impl RootContext for HttpHeadersRoot { - fn create_http_context(&self, _context_id: u32) -> Option> { - Some(Box::new(HttpHeaders {})) - } - - fn get_type(&self) -> Option { - Some(ContextType::HttpContext) - } -} - -struct HttpHeaders {} - -impl Context for HttpHeaders {} - -const UNAUTHORIZED: u32 = 401; -const FORBIDDEN: u32 = 403; -const INTERNAL_SERVER_ERROR: u32 = 500; - -#[derive(Debug, Deserialize, Default)] -struct Claims { - exp: u64, -} - -impl HttpContext for HttpHeaders { - fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { - let Ok(Some(secret)) = secret::get("secret") else { - println!("'secret' param not set"); - self.send_http_response(INTERNAL_SERVER_ERROR, vec![], Some(b"App misconfigured")); - return Action::Pause; - }; - let Some(value) = self.get_http_request_header("Authorization") else { - println!("No auth header"); - self.send_http_response(UNAUTHORIZED, vec![], Some(b"No Authorization header")); - return Action::Pause; - }; - - if value.is_empty() { - println!("Auth header is empty"); - self.send_http_response(UNAUTHORIZED, vec![], Some(b"No Authorization header")); - return Action::Pause; - }; - - let Ok(header) = value.parse::() else { - println!("Auth header is invalid"); - self.send_http_response(UNAUTHORIZED, vec![], Some(b"Invalid Authorization header")); - return Action::Pause; - }; - - - let Some(bearer) = Bearer::decode(&header) else { - println!("Auth header doesn't contain token"); - self.send_http_response(FORBIDDEN, vec![], Some(b"Token not found")); - return Action::Pause; - }; - - let token = bearer.token(); - - let decoding_key = DecodingKey::from_secret(&secret); - let mut validation = Validation::default(); - validation.set_required_spec_claims(&["exp"]); - // skip validation af aud and nbf claims - validation.validate_aud = false; - validation.validate_nbf = false; - validation.validate_exp = false; // will validate expiration separately - - let token_data = match decode::(token, &decoding_key, &validation) { - Ok(token_data) => token_data, - Err(error) => { - println!("Token is invalid"); - self.send_http_response(FORBIDDEN, vec![], Some(format!("Could not decode token {}: {}", token, error).as_bytes())); - return Action::Pause; - } - }; - - let claims = token_data.claims; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - if now > claims.exp { - println!("Token expired"); - self.send_http_response(FORBIDDEN, vec![], Some(b"Token expired")); - return Action::Pause; - } - - println!("Token ok"); - Action::Continue - } -} -``` - - -### FILE: examples/cdn/jwt/Cargo.toml - -```toml -[workspace] - -[package] -name = "jwt" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -proxy-wasm = "0.2" -fastedge = { version = "0.4", features = ["proxywasm"] } -jsonwebtoken = "9" -serde = { version = "1", features = ["derive"] } -headers = "0.4" -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/base-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/base-rust.md index d3ad5df..c74dfa9 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/base-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/base-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -13,8 +13,8 @@ app_type: cdn languages: [rust] template_origin: cdn-base source_repo: https://github.com/G-Core/FastEdge-sdk-rust -source_ref: 6347a7c2fda0d03e66f1214db5eec041c16801b7 -updated: 2026-08-20 +source_ref: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 +updated: 2026-09-22 --- # Base Skeleton: CDN Rust @@ -208,77 +208,3 @@ fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { Action::Continue } ``` - -## Source Material - -### FILE: examples/cdn/hello_world/src/lib.rs - -```rust -use log::info; -use proxy_wasm::traits::*; -use proxy_wasm::types::*; - -proxy_wasm::main! {{ - proxy_wasm::set_log_level(LogLevel::Trace); - proxy_wasm::set_root_context(|_| -> Box { Box::new(HelloWorldRoot) }); -}} - -struct HelloWorldRoot; - -impl Context for HelloWorldRoot {} - -impl RootContext for HelloWorldRoot { - fn get_type(&self) -> Option { - Some(ContextType::HttpContext) - } - - fn create_http_context(&self, _: u32) -> Option> { - Some(Box::new(HelloWorld)) - } -} - -struct HelloWorld; - -impl Context for HelloWorld {} - -impl HttpContext for HelloWorld { - fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { - info!("Hello from on_http_request_headers"); - Action::Continue - } - - fn on_http_request_body(&mut self, _: usize, _: bool) -> Action { - info!("Hello from on_http_request_body"); - Action::Continue - } - - fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { - self.add_http_response_header("x-powered-by", "FastEdge"); - info!("Hello from on_http_response_headers"); - Action::Continue - } - - fn on_http_response_body(&mut self, _: usize, _: bool) -> Action { - info!("Hello from on_http_response_body"); - Action::Continue - } -} -``` - -### FILE: examples/cdn/hello_world/Cargo.toml - -```toml -[workspace] - -[package] -name = "hello_world" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -log = "0.4" -proxy-wasm = "0.2" -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/body-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/body-rust.md index 4b49934..47fbc4a 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/body-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/body-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -243,3 +243,125 @@ Requires `log = "0.4"` in `Cargo.toml`. Log level is set to `Trace` at startup v - proxy-wasm HttpContext trait reference - host-services-rust reference (property API, logging) - platform-overview reference (CDN app lifecycle) + +## Source Material + +### FILE: examples/cdn/body/src/lib.rs + +```rust +use log::info; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Trace); + proxy_wasm::set_root_context(|_| -> Box { Box::new(HttpBodyRoot) }); +}} + +struct HttpBodyRoot; + +impl Context for HttpBodyRoot {} + +impl RootContext for HttpBodyRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(HttpBody)) + } +} + +struct HttpBody; + +impl Context for HttpBody {} + +impl HttpContext for HttpBody { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + self.set_http_request_header("content-length", None); + Action::Continue + } + + fn on_http_request_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { + if !end_of_stream { + // Wait -- we'll be called again when the complete body is buffered + // at the host side. + return Action::Pause; + } + + if let Some(body_bytes) = self.get_http_request_body(0, body_size) { + let body_str = String::from_utf8(body_bytes).unwrap(); + if body_str.contains("Client") { + let new_body = + format!("Client's original message body ({body_size} bytes) redacted.\n"); + self.set_http_request_body(0, body_size, &new_body.into_bytes()); + } + } + Action::Continue + } + + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + // remove content-length as we plan to change the body size + self.set_http_response_header("content-length", None); + // set transfer-encoding to chunked as we don't know body length + self.set_http_response_header("transfer-encoding", Some("Chunked")); + + if let Some(content_type) = self.get_http_response_header("content-type") { + self.set_property(vec!["response.content_type"], Some(content_type.as_bytes())); + } + + Action::Continue + } + + fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { + if !end_of_stream { + return Action::Pause; + } + + let url = if let Some(value) = self.get_property(vec!["request.url"]) { + let url = String::from_utf8_lossy(&value); + info!("url={}", url); + url.to_string() + } else { + "".to_string() + }; + + let content_type = + if let Some(content_type) = self.get_property(vec!["response.content_type"]) { + let content_type = String::from_utf8_lossy(&content_type); + info!("content_type={}", content_type); + content_type.to_string() + } else { + "NONE".to_string() + }; + + if let Some(body_bytes) = self.get_http_response_body(0, body_size) { + let body_str = String::from_utf8(body_bytes).unwrap(); + if body_str.contains("Client") { + let new_body = + format!("Original message body ({body_size} bytes) redacted.\nURL: {url}\nContent-Type: {content_type}\n"); + self.set_http_response_body(0, body_size, &new_body.into_bytes()); + } + } + Action::Continue + } +} +``` + +### FILE: examples/cdn/body/Cargo.toml + +```toml +[workspace] + +[package] +name = "body" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +log = "0.4" +proxy-wasm = "0.2" +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cache-control-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cache-control-rust.md index 0565961..0364038 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cache-control-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cache-control-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cache-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cache-rust.md new file mode 100644 index 0000000..7b6e966 --- /dev/null +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cache-rust.md @@ -0,0 +1,228 @@ + + +# CDN Cache — Rust Example + +## Overview + +Demonstrates cache operations in a CDN app using the proxy-wasm ABI via `fastedge::proxywasm::cache`. The cache has no named stores or handles — every operation is scoped to the calling application and addressed by key alone. This distinguishes it from the KV store, which uses named stores. + +Operations are selected via the `action` query parameter. All responses are JSON. Errors return HTTP 500 with `{"error": "..."}`. + +## App Type + +- **App type**: CDN (proxy-wasm) +- **Language**: Rust +- **Crate type**: `cdylib` +- **Entry point**: `on_http_response_body` — operations run during the response phase + +## Dependencies + +```toml +proxy-wasm = "0.2" +fastedge = { path = "...", features = ["proxywasm"] } +querystring = "1.1" +serde_json = "1" +``` + +Feature flag `proxywasm` must be enabled on the `fastedge` crate. + +## API Reference + +All functions are in the `fastedge::proxywasm::cache` module. + +### `cache::get` + +```rust +pub fn get(key: &str) -> Result>, Error> +``` + +Retrieves cached bytes by key. + +- Returns `Ok(Some(Vec))` when the key exists. +- Returns `Ok(None)` when the key is absent. +- Returns `Err` on host error. + +### `cache::set` + +```rust +pub fn set(key: &str, value: &[u8], ttl_ms: Option) -> Result<(), Error> +``` + +Stores bytes under a key with an optional TTL. + +- `ttl_ms`: milliseconds until expiry. Pass `None` for no expiry. +- Overwrites any existing value for the key. + +### `cache::delete` + +```rust +pub fn delete(key: &str) -> Result<(), Error> +``` + +Removes a key. No-op when the key is absent. + +### `cache::exists` + +```rust +pub fn exists(key: &str) -> Result +``` + +Tests whether a key is present. Returns `true` if the key exists, `false` otherwise. + +### `cache::incr` + +```rust +pub fn incr(key: &str, delta: i64) -> Result +``` + +Atomically increments (or decrements) a numeric counter stored under `key`. + +- `delta` may be negative. +- A missing key is treated as starting at `0`. +- Returns the new value after applying `delta`. + +### `cache::expire` + +```rust +pub fn expire(key: &str, ttl_ms: u64) -> Result +``` + +Sets or updates the expiry of an existing key. + +- `ttl_ms`: milliseconds from now until the key expires. +- Returns `true` if the key existed and was updated, `false` if the key was absent. + +### `cache::purge` + +```rust +pub fn purge() -> Result +``` + +Deletes every key owned by the calling application. + +- Returns the number of keys deleted. + +### `cache::purge_prefix` + +```rust +pub fn purge_prefix(prefix: &str) -> Result +``` + +Deletes all keys owned by the calling application whose names start with `prefix`. + +- Returns the number of keys deleted. + +## Query Parameter Interface + +The example app exposes all cache operations via HTTP query parameters. + +| Query string | Operation | +|---|---| +| `?action=get&key=` | Read a value. `response` is `null` when absent. | +| `?action=set&key=&value=[&ttl=]` | Store a value. Omit `ttl` for no expiry. | +| `?action=delete&key=` | Delete a key. No-op when absent. | +| `?action=exists&key=` | Key membership check. | +| `?action=incr&key=&delta=` | Atomic increment/decrement. Missing key starts at `0`. | +| `?action=expire&key=&ttl=` | Set or update expiry. `response` is `false` when key absent. | +| `?action=purge` | Delete all keys for this app; returns count deleted. | +| `?action=purgePrefix&prefix=` | Delete keys with prefix; returns count deleted. | + +Default action when `action` is omitted: `get`. + +## Response Format + +Successful responses are JSON objects. Shape varies by action: + +**get** +```json +{ "action": "get", "key": "", "response": "" | null } +``` + +**set** +```json +{ "action": "set", "key": "", "value": "", "ttlMs": | null, "response": true } +``` + +**delete** +```json +{ "action": "delete", "key": "", "response": true } +``` + +**exists** +```json +{ "action": "exists", "key": "", "response": true | false } +``` + +**incr** +```json +{ "action": "incr", "key": "", "delta": , "response": } +``` + +**expire** +```json +{ "action": "expire", "key": "", "ttlMs": , "response": true | false } +``` + +**purge** +```json +{ "action": "purge", "response": } +``` + +**purgePrefix** +```json +{ "action": "purgePrefix", "prefix": "", "response": } +``` + +**Error (HTTP 500)** +```json +{ "error": "" } +``` + +## Proxy-wasm Integration + +The app uses the proxy-wasm `HttpContext` trait. Key implementation details: + +- Operations execute in `on_http_response_body`, triggered when `end_of_stream` is `true`. +- The request query string is read via `self.get_property(vec!["request.query"])`. +- Response headers are set in `on_http_response_headers`: removes `content-length`, sets `content-type: application/json`, sets `transfer-encoding: chunked`. +- The upstream response body is replaced entirely using `set_http_response_body`. +- Error status is set via `self.set_property(vec!["response.status"], Some(b"500"))`. +- The app pauses body accumulation (`Action::Pause`) until `end_of_stream` is received. + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip1/release/cache.wasm +``` + +## Usage Examples + +```sh +curl 'https:///?action=set&key=hits&value=0&ttl=60000' +curl 'https:///?action=incr&key=hits&delta=1' +curl 'https:///?action=get&key=hits' +``` + +## Constraints and Behavior Notes + +- Cache scope is per-application. Two apps cannot share cache entries. +- `set` with no `ttl` parameter stores the value with no expiry. +- `incr` operates atomically. The key must store a numeric value; if the key is absent it is initialized to `0` before applying `delta`. +- `expire` returns `false` (not an error) when the target key does not exist. +- `purge` and `purge_prefix` return the count of deleted keys, not an error, when no matching keys exist. +- All values are stored and retrieved as bytes (`&[u8]` / `Vec`). String conversion is the caller's responsibility. + +## See Also + +- CDN KV store example (key_value) — named stores, per-store handles, similar operation set +- `fastedge::proxywasm` module — proxy-wasm host service bindings +- proxy-wasm `HttpContext` trait — lifecycle hooks used by CDN apps +- FastEdge CDN app platform overview — app type selection, deployment model diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/convert-image-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/convert-image-rust.md index e156022..1fa5481 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/convert-image-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/convert-image-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -38,12 +38,12 @@ image = "0.25" ## Environment Variables (Configuration) -| Variable | Required | Type | Constraints | Default | Description | -|-----------------------|----------|-----------------------------|-----------------|---------|------------------------------------------------------------------| -| `FORMATS_TO_TRANSFORM`| Yes | Comma-separated strings | Non-empty | — | File extensions to convert (e.g., `jpg,jpeg,png`). Note: `jpg` and `jpeg` are distinct entries. | -| `IGNORED_UA_LIST` | No | Comma-separated strings | — | — | User-Agent substrings; requests matching any entry are skipped. | -| `AVIF_SPEED` | No | u8 | 1–10 inclusive | 5 | AVIF encoding speed. Values outside range fall back to default. | -| `AVIF_QUALITY` | No | u8 | 1–100 inclusive | 70 | AVIF encoding quality. Values outside range fall back to default.| +| Variable | Required | Type | Constraints | Default | Description | +|------------------------|----------|-------------------------|------------------|---------|---------------------------------------------------------------------------------------------------| +| `FORMATS_TO_TRANSFORM` | Yes | Comma-separated strings | Non-empty | — | File extensions to convert (e.g., `jpg,jpeg,png`). Note: `jpg` and `jpeg` are distinct entries. | +| `IGNORED_UA_LIST` | No | Comma-separated strings | — | — | User-Agent substrings; requests matching any entry are skipped. | +| `AVIF_SPEED` | No | u8 | 1–10 inclusive | 5 | AVIF encoding speed. Values outside range fall back to default. | +| `AVIF_QUALITY` | No | u8 | 1–100 inclusive | 70 | AVIF encoding quality. Values outside range fall back to default. | Parameter helper behavior: if a variable is absent, empty, non-numeric, or out of range, the default is used and a trace log is emitted. @@ -95,7 +95,7 @@ fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action let Ok(ext) = from_utf8(&ext) else { return Action::Continue; }; if ext.is_empty() { return Action::Continue; } ``` - Safe decoding chain: `Option>` → check `None` → decode UTF-8 → check empty. + Safe decoding chain: `Option>` → check `None` → decode UTF-8 → check empty. This extracts the file extension without manual path splitting. 3. Check `FORMATS_TO_TRANSFORM` env var — if the extension is not in the comma-separated list, pass through unchanged. 4. Read `User-Agent` request header — if absent or empty, pass through. 5. Check `IGNORED_UA_LIST` env var — if the UA contains any listed substring, pass through. @@ -243,12 +243,12 @@ Returns `Err(VarError::NotPresent)` if the variable is absent or empty. ## Cross-Hook Signalling Summary -| Signal | Set by | Read by | Mechanism | -|-------------------------|----------------------|---------------------------|------------------------------------------------| -| `Image-Format: original`| request hook (always)| response-headers hook | request header | -| `Image-Format: image/avif` | request hook (on match) | response-headers hook | request header | -| `response.content-type` | response-headers hook| response-body hook | `set_property` / `get_property` | -| `Vary: Image-Format` | response-headers hook| CDN cache | response header | +| Signal | Set by | Read by | Mechanism | +|----------------------------|------------------------------|---------------------------|------------------------------------------| +| `Image-Format: original` | request hook (always) | response-headers hook | request header | +| `Image-Format: image/avif` | request hook (on match) | response-headers hook | request header | +| `response.content-type` | response-headers hook | response-body hook | `set_property` / `get_property` | +| `Vary: Image-Format` | response-headers hook | CDN cache | response header | --- @@ -265,19 +265,19 @@ proxy_wasm::main! {{ ## Error Conditions -| Condition | Behavior | -|----------------------------------------|---------------------------------------------------| -| No file extension in request path | Pass through without transformation | -| Extension not in `FORMATS_TO_TRANSFORM`| Pass through without transformation | -| `FORMATS_TO_TRANSFORM` not set | Pass through without transformation | -| No or empty `User-Agent` header | Pass through without transformation | -| UA matches `IGNORED_UA_LIST` entry | Pass through without transformation | -| Response status not 200 | Pass through without transformation | -| Response status property not exactly 2 bytes | Treat as missing status, pass through | -| No response body (`get_http_response_body` returns None) | Log and continue (original served) | -| Image decode failure (`load_from_memory`) | Log error, serve original body | -| AVIF encode failure | Log error, serve original body | -| `response.content-type` invalid UTF-8 | Send HTTP 500, return `Action::Pause` | +| Condition | Behavior | +|-------------------------------------------------------------------|-----------------------------------------------------| +| No file extension in request path | Pass through without transformation | +| Extension not in `FORMATS_TO_TRANSFORM` | Pass through without transformation | +| `FORMATS_TO_TRANSFORM` not set | Pass through without transformation | +| No or empty `User-Agent` header | Pass through without transformation | +| UA matches `IGNORED_UA_LIST` entry | Pass through without transformation | +| Response status not 200 | Pass through without transformation | +| Response status property not exactly 2 bytes | Treat as missing status, pass through | +| No response body (`get_http_response_body` returns None) | Log and continue (original served) | +| Image decode failure (`load_from_memory`) | Log error, serve original body | +| AVIF encode failure | Log error, serve original body | +| `response.content-type` invalid UTF-8 | Send HTTP 500, return `Action::Pause` | --- @@ -287,3 +287,300 @@ proxy_wasm::main! {{ - scaffold reference for CDN app type - FastEdge SDK Rust host services reference (proxy-wasm ABI, `get_property`, `set_property`) - platform overview (CDN app lifecycle, cache variation with `Vary` headers) + +## Source Material + +### FILE: examples/cdn/convert_image/src/lib.rs + +```rust +use image::*; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use std::{env, env::VarError, io::Cursor, str::from_utf8}; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Trace); + proxy_wasm::set_root_context(|_| -> Box { Box::new(ConvertImageRoot) }); +}} + +struct ConvertImageRoot; + +impl Context for ConvertImageRoot {} + +impl RootContext for ConvertImageRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(ConvertImageContext)) + } +} + +struct ConvertImageContext; + +impl Context for ConvertImageContext {} + +impl HttpContext for ConvertImageContext { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + // this header is used to select correct image version from cache + self.add_http_request_header("Image-Format", "original"); + + // get extension + let path = self.get_property(vec!["request.path"]).map(|v| String::from_utf8(v).unwrap_or_default()).unwrap_or_default(); + println!("request.path={path:?}"); + let raw_ext = self.get_property(vec!["request.extension"]); + println!("request.extension={raw_ext:?}"); + let Some(ext) = raw_ext else { + println!("No extension in request path, not transforming"); + return Action::Continue; + }; + let Ok(ext) = from_utf8(&ext) else { + println!("Invalid UTF-8 in request extension, not transforming"); + return Action::Continue; + }; + if ext.is_empty() { + println!("No extension in request path, not transforming"); + return Action::Continue; + } + + // FORMATS_TO_TRANSFORM contains list of file extensions to transfor + // note that jpg and jpeg are different extensions + let Ok(image_list) = str_param("FORMATS_TO_TRANSFORM") else { + println!("FORMATS_TO_TRANSFORM param is not set, not transforming"); + return Action::Continue; + }; + if !image_list.split(',').any(|entry| entry == ext) { + println!( + "extension {} is not in the list of formats to transform: {}, not transforming", + ext, image_list + ); + return Action::Continue; + } + + // requests from User agents that match substrings in the IGNORED_UA_LIST param are not transformed + let Some(ua) = self.get_http_request_header("User-Agent") else { + println!("User-Agent header is not set, not transforming"); + return Action::Continue; + }; + if ua.is_empty() { + println!("User-Agent header is not set, not transforming"); + return Action::Continue; + } + if let Ok(ua_to_ignore) = str_param("IGNORED_UA_LIST") { + if ua_to_ignore.split(",").any(|entry| ua.contains(entry)) { + println!("User-Agent is in ignore list, not transforming"); + return Action::Continue; + } + } + + // indicator for on_response_headers and for cache key + self.set_http_request_header("Image-Format", Some("image/avif")); + + Action::Continue + } + + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + // only process 200 responses + if let Some(status) = self.rsp_status() { + if status != 200 { + println!( + "Response status is {} instead of expected 200, not transforming", + status + ); + return Action::Continue; + } + } else { + println!("Response status is not set, not transforming"); + return Action::Continue; + } + + // if "Image-Format" request header is not set, don't convert the image + let Some(content_type) = self.get_http_request_header("Image-Format") else { + return Action::Continue; + }; + // instruct cache to vary by this header so "original" and "image/avif" are cached separately + self.add_http_response_header("Vary", "Image-Format"); + + if content_type == "original" { + return Action::Continue; + }; + + // image to be transformed, set headers accordingly + self.set_http_response_header("Content-Length", None); + self.set_http_response_header("Transfer-Encoding", Some("Chunked")); + self.set_http_response_header("Content-Type", Some(content_type.as_str())); + + // indicate to on_http_response_body that transformation is needed + self.set_property(vec!["response.content-type"], Some(content_type.as_bytes())); + + Action::Continue + } + + fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { + if !end_of_stream { + // wait till we get complete body + return Action::Pause; + } + + let Some(content_type) = self.get_property(vec!["response.content-type"]) else { + return Action::Continue; + }; + + let Ok(content_type) = from_utf8(&content_type) else { + // should never happen + println!("Invalid UTF-8 in Content-Type"); + self.send_http_response(500, vec![], None); + return Action::Pause; + }; + + if content_type != "image/avif" { + // should never happen + println!( + "Content-Type {} is not supported, not transforming", + content_type + ); + return Action::Continue; + } + + if let Some(body_bytes) = self.get_http_response_body(0, body_size) { + let buf = body_bytes.as_bytes(); + let img = match load_from_memory(buf) { + Ok(i) => i, + Err(e) => { + println!("cannot load image to memory {}, not converting", e); + return Action::Continue; + } + }; + + let mut out = Vec::new(); + let mut c = Cursor::new(&mut out); + let res = img.write_with_encoder(codecs::avif::AvifEncoder::new_with_speed_quality( + &mut c, + u8_param("AVIF_SPEED", 1, 10, 5), + u8_param("AVIF_QUALITY", 1, 100, 70), + )); + + match res { + Ok(_) => { + println!( + "{} bytes -> {} bytes {}", + body_size, + out.len(), + content_type + ); + self.set_http_response_body(0, body_size, &out) + } + Err(e) => println!("cannot store transformed image {}", e), + } + } else { + println!("No response body to transform"); + } + + Action::Continue + } +} + +impl ConvertImageContext { + fn rsp_status(&mut self) -> Option { + if let Some(status) = self.get_property(vec!["response.status"]) { + if status.len() != 2 { + println!("HTTP status property is not 2 bytes"); + return None; + } + return Some(u16::from_be_bytes([status[0], status[1]])); + } + None + } +} + +fn str_param(name: &str) -> Result { + let val = env::var(name)?; + if val.is_empty() { + return Err(VarError::NotPresent); + } + + Ok(val) +} + +fn u8_param(name: &str, min: u8, max: u8, default: u8) -> u8 { + let Ok(val) = env::var(name) else { + println!("Param {} is not set, using default value {}", name, default); + return default; + }; + if val.is_empty() { + println!("Param {} is not set, using default value {}", name, default); + return default; + } + + let val = match val.parse() { + Err(_) => { + println!( + "Param {} is not a valid number, using default value {}", + name, default + ); + return default; + } + Ok(v) => v, + }; + if val < min { + println!( + "Param {} is below minimum {}, using default value {}", + name, min, default + ); + return default; + } + if val > max { + println!( + "Param {} is above maximum {}, using default value {}", + name, max, default + ); + return default; + } + + val +} +``` + + +### FILE: examples/cdn/convert_image/Cargo.toml + +```toml +[workspace] + +[package] +name = "convert_image" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +image = "0.25" +``` + + +### FILE: examples/cdn/convert_image/README.md + +``` +[← Back to examples](../../README.md) + +# Convert Image (CDN) + +Converts images to AVIF format on the fly using the proxy-wasm ABI. Only transforms requests matching configured file extensions and skips specified user agents. + +## Configuration + +- Environment variable: `FORMATS_TO_TRANSFORM` — comma-separated list of file extensions to convert (e.g. `jpg,jpeg,png`) +- Environment variable: `IGNORED_UA_LIST` — (optional) comma-separated list of User-Agent substrings to skip +- Environment variable: `AVIF_SPEED` — (optional) AVIF encoding speed, 1-10 (default: 5) +- Environment variable: `AVIF_QUALITY` — (optional) AVIF encoding quality, 1-100 (default: 70) + +## How it works + +1. **on_request_headers** — checks file extension and User-Agent, sets `Image-Format` header for cache variation +2. **on_response_headers** — sets response headers for AVIF content type on 200 responses +3. **on_response_body** — decodes the original image and re-encodes it as AVIF +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cors-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cors-rust.md index f518e8f..172455f 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cors-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/cors-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -267,3 +267,178 @@ Output binary: `target/wasm32-wasip1/release/cors.wasm` - headers-rust reference (simpler single-hook CDN filter for header manipulation) - deploy skill reference (uploading and registering the compiled WASM binary) - Platform overview reference (environment variable configuration) + +## Source Material + +### FILE: examples/cdn/cors/src/lib.rs + +```rust +/* +* Copyright 2025 G-Core Innovations SARL +*/ +/* +Example CDN app demonstrating CORS header management. + +Handles preflight OPTIONS requests and adds CORS response headers +for allowed origins. Supports configurable origin allow-lists, +methods, and exposed headers. + +Configuration: + - Environment variable: ALLOWED_ORIGINS (comma-separated origins or "*") + When unset or empty the filter is dormant — requests pass through + without CORS headers, so browsers will block cross-origin access. + - Environment variable: ALLOWED_METHODS (default: "GET, POST, PUT, DELETE, OPTIONS") + - Environment variable: MAX_AGE (default: "86400") + - Environment variable: EXPOSE_HEADERS (response headers to expose) +*/ + +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use std::env; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { Box::new(CorsRoot) }); +}} + +struct CorsRoot; + +impl Context for CorsRoot {} + +impl RootContext for CorsRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(CorsContext)) + } +} + +struct CorsContext; + +impl Context for CorsContext {} + +impl HttpContext for CorsContext { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + let origin = match self.get_http_request_header("Origin") { + Some(o) if !o.is_empty() => o, + _ => return Action::Continue, + }; + + let allowed_origins = env::var("ALLOWED_ORIGINS").unwrap_or_default(); + if !is_origin_allowed(&origin, &allowed_origins) { + return Action::Continue; + } + + // Handle preflight OPTIONS request + let method = self + .get_http_request_header(":method") + .unwrap_or_default(); + + if method == "OPTIONS" { + let allow_methods = env::var("ALLOWED_METHODS") + .unwrap_or_else(|_| "GET, POST, PUT, DELETE, OPTIONS".to_string()); + let allow_headers = self + .get_http_request_header("Access-Control-Request-Headers") + .unwrap_or_else(|| "Content-Type, Authorization".to_string()); + let max_age = env::var("MAX_AGE").unwrap_or_else(|_| "86400".to_string()); + + let effective_origin = if allowed_origins == "*" { + "*".to_string() + } else { + origin + }; + + let mut headers = vec![ + ("Access-Control-Allow-Origin", effective_origin.as_str()), + ("Access-Control-Allow-Methods", allow_methods.as_str()), + ("Access-Control-Allow-Headers", allow_headers.as_str()), + ("Access-Control-Max-Age", max_age.as_str()), + ("Content-Length", "0"), + ]; + + // Vary: Origin is needed when the response varies by origin, + // so shared caches don't serve a cached response for a different origin. + // Not needed when Allow-Origin is "*" (response is the same for all origins). + if effective_origin != "*" { + headers.push(("Vary", "Origin")); + } + + self.send_http_response(204, headers, None); + + return Action::Pause; + } + + Action::Continue + } + + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + let origin = match self.get_http_request_header("Origin") { + Some(o) if !o.is_empty() => o, + _ => return Action::Continue, + }; + + let allowed_origins = env::var("ALLOWED_ORIGINS").unwrap_or_default(); + if !is_origin_allowed(&origin, &allowed_origins) { + return Action::Continue; + } + + let effective_origin = if allowed_origins == "*" { + "*".to_string() + } else { + origin + }; + + self.add_http_response_header("Access-Control-Allow-Origin", &effective_origin); + if effective_origin != "*" { + self.add_http_response_header("Vary", "Origin"); + } + + if let Ok(expose) = env::var("EXPOSE_HEADERS") { + if !expose.is_empty() { + self.add_http_response_header("Access-Control-Expose-Headers", &expose); + } + } + + Action::Continue + } +} + +fn is_origin_allowed(origin: &str, allowed: &str) -> bool { + if allowed.is_empty() { + return false; + } + if allowed == "*" { + return true; + } + allowed.split(',').any(|o| o.trim() == origin) +} +``` + +### FILE: examples/cdn/cors/Cargo.toml + +```toml +[workspace] + +[package] +name = "cors" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +``` + +### FILE: examples/cdn/cors/README.md + +``` +[← Back to examples](../../README.md) + +# CORS (CDN) + +Handles Cross-Origin Resource Sharing: preflight OPTIONS responses and CORS headers on all responses for allowed origins. +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-error-pages-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-error-pages-rust.md index eca26cd..a3463d6 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-error-pages-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-error-pages-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -310,3 +310,207 @@ Edit `public/styles.css` directly. No build tools required. Styles are embedded - host-services-rust reference (property API) - platform-overview reference (CDN app lifecycle) - body-rust blueprint (general body manipulation pattern) + +## Source Material + +### FILE: examples/cdn/custom_error_pages/src/lib.rs + +```rust +use handlebars::Handlebars; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use serde_json::json; +use std::collections::HashMap; +use std::env; + +// Include the generated image map +include!(concat!(env!("OUT_DIR"), "/image_map.rs")); +include!(concat!(env!("OUT_DIR"), "/message_map.rs")); + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Trace); + proxy_wasm::set_root_context(|_| -> Box { Box::new(HttpBodyRoot) }); +}} + +struct HttpBodyRoot; + +impl Context for HttpBodyRoot {} + +impl RootContext for HttpBodyRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(HttpBody)) + } +} + +struct HttpBody; + +impl Context for HttpBody {} + +impl HttpContext for HttpBody { + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + if let Some(status) = self.get_property(vec!["response.status"]) { + if status.len() == 2 { + let status_code = u16::from_be_bytes([status[0], status[1]]); + if (400..600).contains(&status_code) { + // Remove the Content-Length header if it exists, we are going to change the response body + self.set_http_response_header("Content-Length", None); + self.set_http_response_header("Transfer-Encoding", Some("Chunked")); + self.set_http_response_header("Content-Type", Some("text/html")); + } + } + } + Action::Continue + } + + fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { + // only process 4xx/5xx error responses + let Some(status) = self.get_property(vec!["response.status"]) else { + return Action::Continue; + }; + if status.len() != 2 { + return Action::Continue; + } + let status_code = u16::from_be_bytes([status[0], status[1]]); + if !(400..600).contains(&status_code) { + return Action::Continue; + } + + if !end_of_stream { + // wait for complete body + return Action::Pause; + } + + // Get the image and message maps + let image_map = get_image_map(); + let message_map = get_message_map(); + + // Get the Base64-encoded image for the status code or its fallback + let base64_image = image_map + .get(&status_code) + .or_else(|| { + if (400..500).contains(&status_code) { + image_map.get(&4000) + } else if (500..600).contains(&status_code) { + image_map.get(&5000) + } else { + None + } + }) + .unwrap_or(&""); + + // Get the message and description for the status code or its fallback + let (message, description) = message_map + .get(&status_code) + .or_else(|| { + if (400..500).contains(&status_code) { + message_map.get(&4000) + } else if (500..600).contains(&status_code) { + message_map.get(&5000) + } else { + None + } + }) + .map(|(msg, desc)| (msg.to_string(), desc.to_string())) + .unwrap_or_else(|| { + ( + "Unexpected Error".to_string(), + "The server responded with a {{status}} error.".to_string(), + ) + }); + + let mut handlebars = Handlebars::new(); + // Use handlebars to complete message and description text allowing for usage of {{ status }} variable + handlebars + .register_template_string("message_template", message) + .unwrap(); + handlebars + .register_template_string("description_template", description) + .unwrap(); + + let msg_data = json!({ + "status": status_code.to_string(), + }); + + let complete_message = handlebars.render("message_template", &msg_data).unwrap(); + let complete_description = handlebars + .render("description_template", &msg_data) + .unwrap(); + + // Render the error page using Handlebars + let error_template = include_str!("../templates/error_page.hbs"); + handlebars + .register_template_string("error_template", error_template) + .unwrap(); + + let styles = include_str!("../public/styles.css"); + let page_data = json!({ + "styles": styles, + "status": status_code.to_string(), + "message": complete_message, + "description": complete_description, + "image": base64_image, + }); + + let html_body = handlebars.render("error_template", &page_data).unwrap(); + let body = html_body.as_bytes(); + self.set_http_response_body(0, body_size, body); + + Action::Continue + } +} +``` + +### FILE: examples/cdn/custom_error_pages/Cargo.toml + +```toml +[workspace] + +[package] +name = "custom_error_pages" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +handlebars = "6.3" +serde_json = "1.0" +regex = "1.10" + +[build-dependencies] +base64 = "0.22" +``` + +### FILE: examples/cdn/custom_error_pages/README.md + +``` +[← Back to examples](../../README.md) + +# Custom Error Pages (CDN) + +Intercepts 4xx and 5xx error responses and replaces them with branded HTML error pages using Handlebars templates. + +## How it works + +A [build script](./build.rs) runs at compile time to embed images and messages from the `public/` folder into the WASM binary (since there is no filesystem at runtime). + +At runtime, when an error response is detected: +1. **on_response_headers** — sets `Content-Type` to `text/html` for error responses +2. **on_response_body** — looks up the status code in the embedded image/message maps, falls back to generic `4xx`/`5xx` templates, and renders the error page using Handlebars + +## Adding a custom error page + +1. Add an image: `public/images/.jpg` +2. Add a message file: `public/messages/.hbs` (first line = title, second line = description) +3. Recompile and deploy + +## Styling + +Styles are in [`public/styles.css`](./public/styles.css) — plain CSS, no build tools required. Edit directly. +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-rust.md index a95115f..a641e22 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/custom-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/env-secrets-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/env-secrets-rust.md index e8a660a..3da5935 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/env-secrets-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/env-secrets-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -175,3 +175,104 @@ println!("PASSWORD: {}", password); - FastEdge app secrets management (platform docs) - proxy-wasm HttpContext trait reference - fastedge crate proxywasm feature documentation + +## Source Material + +### FILE: examples/cdn/variables_and_secrets/src/lib.rs + +```rust +/* +* Copyright 2025 G-Core Innovations SARL +*/ +/* +Example CDN app demonstrating environment variables and secrets access. + +Reads USERNAME from environment variables and PASSWORD from secrets, +then forwards both as request headers to the upstream origin. + +Required configuration: + - Environment variable: USERNAME + - Secret: PASSWORD +*/ + +use fastedge::proxywasm::secret; +use std::env; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { Box::new(VariablesRoot) }); +}} + +struct VariablesRoot; + +impl Context for VariablesRoot {} + +impl RootContext for VariablesRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(VariablesContext)) + } +} + +struct VariablesContext; + +impl Context for VariablesContext {} + +impl HttpContext for VariablesContext { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + let username = env::var("USERNAME").unwrap_or_default(); + let password = secret::get("PASSWORD") + .ok() + .flatten() + .and_then(|v| String::from_utf8(v).ok()) + .unwrap_or_default(); + + println!("USERNAME: {}", username); + // WARNING: Secrets are stored and retrieved as plaintext. Never log secret values + // in production code — platform logs are visible to operators and may be persisted. + // This line is shown for demonstration only; remove it in any real application. + println!("PASSWORD: {}", password); + + self.add_http_request_header("x-env-username", &username); + // WARNING: Forwarding a secret in a request header exposes it to the upstream origin + // and any intermediary that can inspect headers. Only do this when the upstream + // channel is trusted and the header is required by the destination API. + self.add_http_request_header("x-env-password", &password); + + Action::Continue + } +} +``` + +### FILE: examples/cdn/variables_and_secrets/Cargo.toml + +```toml +[workspace] + +[package] +name = "variables_and_secrets" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +fastedge = { version = "0.4", features = ["proxywasm"] } +``` + +### FILE: examples/cdn/variables_and_secrets/README.md + +``` +[← Back to examples](../../README.md) + +# Variables and Secrets (CDN) + +Reads `USERNAME` from environment variables and `PASSWORD` from secrets for request forwarding using the proxy-wasm ABI. +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geo-redirect-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geo-redirect-rust.md index a71fca0..bc88cd4 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geo-redirect-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geo-redirect-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geoblock-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geoblock-rust.md index 1f39fe8..c14e9cd 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geoblock-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/geoblock-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/headers-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/headers-rust.md index 0fb98ea..c275404 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/headers-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/headers-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -87,7 +87,6 @@ impl Context for HttpHeaders {} impl HttpContext for HttpHeaders { fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { ... } fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { ... } - fn on_log(&mut self) { ... } } ``` @@ -296,18 +295,6 @@ Return `Action::Continue` when processing should proceed normally. --- -## Lifecycle Hook - -```rust -fn on_log(&mut self) { - println!("#{} completed.", self.context_id); -} -``` - -Called after the request/response cycle completes. Use for per-request logging or cleanup. - ---- - ## Constraints - `add_http_request_header` / `add_http_response_header` append new entries; existing entries with the same name are preserved (duplicates allowed). @@ -327,356 +314,3 @@ Called after the request/response cycle completes. Use for per-request logging o - proxy-wasm Rust SDK reference (sdk-reference-rust) - FastEdge CDN app examples index (examples CDN) - Host services Rust reference (host-services-rust) - -## Source Material - -### FILE: examples/cdn/headers/src/lib.rs - -```rust -use proxy_wasm::traits::*; -use proxy_wasm::types::*; -use std::collections::HashSet; - -proxy_wasm::main! {{ - proxy_wasm::set_log_level(LogLevel::Trace); - proxy_wasm::set_root_context(|_| -> Box { Box::new(HttpHeadersRoot) }); -}} - -struct HttpHeadersRoot; - -impl Context for HttpHeadersRoot {} - -impl RootContext for HttpHeadersRoot { - fn create_http_context(&self, context_id: u32) -> Option> { - Some(Box::new(HttpHeaders { context_id })) - } - - fn get_type(&self) -> Option { - Some(ContextType::HttpContext) - } -} - -struct HttpHeaders { - context_id: u32, -} - -impl Context for HttpHeaders {} - -impl HttpContext for HttpHeaders { - fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { - let mut original_headers = HashSet::new(); - let mut original_headers_bytes = HashSet::new(); - - // iterate over the headers and print them - for (name, value) in self.get_http_request_headers().into_iter() { - println!("#{} -> {}: {}", self.context_id, name, value); - original_headers.insert((name, value)); - } - for (name, value) in self.get_http_request_headers_bytes().into_iter() { - println!("#{} -> {}: {:?}", self.context_id, name, value); - original_headers_bytes.insert((name, value)); - } - if original_headers.is_empty() || original_headers_bytes.is_empty() { - self.send_http_response(550, vec![], None); - return Action::Pause; - } - - // check if the host header is present - if self.get_http_request_header("host").is_none() { - self.send_http_response(551, vec![], None); - return Action::Pause; - } - if self.get_http_request_header_bytes("host").is_none() { - self.send_http_response(551, vec![], None); - return Action::Pause; - } - - // add new headers - self.add_http_request_header("new-header-01", "value-01"); - self.add_http_request_header_bytes("new-header-bytes-01", b"value-bytes-01"); - - self.add_http_request_header("new-header-02", "value-02"); - self.add_http_request_header_bytes("new-header-bytes-02", b"value-bytes-02"); - - self.add_http_request_header("new-header-03", "value-03"); - self.add_http_request_header_bytes("new-header-bytes-03", b"value-bytes-03"); - - //remove header new-headter-01, expected empty value - self.set_http_request_header("new-header-01", None); - self.set_http_request_header_bytes("new-header-bytes-01", None); - - // changing header value - self.set_http_request_header("new-header-02", Some("new-value-02")); - self.set_http_request_header_bytes("new-header-bytes-02", Some(b"new-value-bytes-02")); - - // add new header with existing name - self.add_http_request_header("new-header-03", "value-03-a"); - self.add_http_request_header_bytes("new-header-bytes-03", b"value-bytes-03-a"); - - // try to set/add response headers - self.add_http_response_header("new-response-header", "value-01"); - self.set_http_response_header("cache-control", None); - self.set_http_response_header("new-response-header", Some("value-02")); - - // get new headers - let headers = self - .get_http_request_headers() - .into_iter() - .collect::>(); - let headers_bytes = self - .get_http_request_headers_bytes() - .into_iter() - .collect::>(); - - let expected = [ - ("new-header-01".to_string(), "".to_string()), - ("new-header-bytes-01".to_string(), "".to_string()), - ("new-header-02".to_string(), "new-value-02".to_string()), - ( - "new-header-bytes-02".to_string(), - "new-value-bytes-02".to_string(), - ), - ("new-header-03".to_string(), "value-03".to_string()), - ( - "new-header-bytes-03".to_string(), - "value-bytes-03".to_string(), - ), - ("new-header-03".to_string(), "value-03-a".to_string()), - ( - "new-header-bytes-03".to_string(), - "value-bytes-03-a".to_string(), - ), - ]; - - let expected = expected.iter().collect::>(); - - let expected_bytes = [ - ("new-header-01".to_string(), b"".to_vec()), - ("new-header-bytes-01".to_string(), b"".to_vec()), - ("new-header-02".to_string(), b"new-value-02".to_vec()), - ( - "new-header-bytes-02".to_string(), - b"new-value-bytes-02".to_vec(), - ), - ("new-header-03".to_string(), b"value-03".to_vec()), - ( - "new-header-bytes-03".to_string(), - b"value-bytes-03".to_vec(), - ), - ("new-header-03".to_string(), b"value-03-a".to_vec()), - ( - "new-header-bytes-03".to_string(), - b"value-bytes-03-a".to_vec(), - ), - ]; - - let expected_bytes = expected_bytes.iter().collect::>(); - - let diff = headers - .difference(&original_headers) - .collect::>(); - - let diff_bytes = headers_bytes - .difference(&original_headers_bytes) - .collect::>(); - - let diff = diff.difference(&expected).collect::>(); - - if !diff.is_empty() { - println!("different headers: {:?}", diff); - self.send_http_response(552, vec![], None); - return Action::Pause; - } - - let diff_bytes = diff_bytes.difference(&expected_bytes).collect::>(); - if !diff_bytes.is_empty() { - println!("different headers bytes: {:?}", diff_bytes); - self.send_http_response(552, vec![], None); - return Action::Pause; - } - - // check if the response header is not returned - if self.get_http_response_header("host").is_some() { - self.send_http_response(553, vec![], None); - return Action::Pause; - }; - if self.get_http_response_header_bytes("host").is_some() { - self.send_http_response(553, vec![], None); - return Action::Pause; - }; - - let response_headers = self.get_http_response_headers(); - if response_headers.len() != 1 { - self.send_http_response(555, vec![], None); - return Action::Pause; - } - let Some((name, value)) = response_headers.into_iter().next() else { - self.send_http_response(555, vec![], None); - return Action::Pause; - }; - if name != "new-response-header" || value != "value-02" { - self.send_http_response(556, vec![], None); - return Action::Pause; - } - - Action::Continue - } - - fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { - let mut original_headers = HashSet::new(); - let mut original_headers_bytes = HashSet::new(); - - // iterate over the headers and print them - for (name, value) in self.get_http_response_headers().into_iter() { - println!("#{} -> {}: {}", self.context_id, name, value); - original_headers.insert((name, value)); - } - for (name, value) in self.get_http_response_headers_bytes().into_iter() { - println!("#{} -> {}: {:?}", self.context_id, name, value); - original_headers_bytes.insert((name, value)); - } - if original_headers.is_empty() || original_headers_bytes.is_empty() { - self.send_http_response(550, vec![], None); - return Action::Pause; - } - - // check if the host header is present - if self.get_http_response_header("host").is_none() { - self.send_http_response(551, vec![], None); - return Action::Pause; - } - if self.get_http_response_header_bytes("host").is_none() { - self.send_http_response(551, vec![], None); - return Action::Pause; - } - - // add new headers - self.add_http_response_header("new-header-01", "value-01"); - self.add_http_response_header_bytes("new-header-bytes-01", b"value-bytes-01"); - - self.add_http_response_header("new-header-02", "value-02"); - self.add_http_response_header_bytes("new-header-bytes-02", b"value-bytes-02"); - - self.add_http_response_header("new-header-03", "value-03"); - self.add_http_response_header_bytes("new-header-bytes-03", b"value-bytes-03"); - - //remove header new-headter-01, expected empty value - self.set_http_response_header("new-header-01", None); - self.set_http_response_header_bytes("new-header-bytes-01", None); - - // changing header value - self.set_http_response_header("new-header-02", Some("new-value-02")); - self.set_http_response_header_bytes("new-header-bytes-02", Some(b"new-value-bytes-02")); - - // add new header with existing name - self.add_http_response_header("new-header-03", "value-03-a"); - self.add_http_response_header_bytes("new-header-bytes-03", b"value-bytes-03-a"); - - // get new headers - let headers = self - .get_http_response_headers() - .into_iter() - .collect::>(); - let headers_bytes = self - .get_http_response_headers_bytes() - .into_iter() - .collect::>(); - - let expected = [ - ("new-header-01".to_string(), "".to_string()), - ("new-header-bytes-01".to_string(), "".to_string()), - ("new-header-02".to_string(), "new-value-02".to_string()), - ( - "new-header-bytes-02".to_string(), - "new-value-bytes-02".to_string(), - ), - ("new-header-03".to_string(), "value-03".to_string()), - ( - "new-header-bytes-03".to_string(), - "value-bytes-03".to_string(), - ), - ("new-header-03".to_string(), "value-03-a".to_string()), - ( - "new-header-bytes-03".to_string(), - "value-bytes-03-a".to_string(), - ), - ]; - - let expected = expected.iter().collect::>(); - - let expected_bytes = [ - ("new-header-01".to_string(), b"".to_vec()), - ("new-header-bytes-01".to_string(), b"".to_vec()), - ("new-header-02".to_string(), b"new-value-02".to_vec()), - ( - "new-header-bytes-02".to_string(), - b"new-value-bytes-02".to_vec(), - ), - ("new-header-03".to_string(), b"value-03".to_vec()), - ( - "new-header-bytes-03".to_string(), - b"value-bytes-03".to_vec(), - ), - ("new-header-03".to_string(), b"value-03-a".to_vec()), - ( - "new-header-bytes-03".to_string(), - b"value-bytes-03-a".to_vec(), - ), - ]; - - let expected_bytes = expected_bytes.iter().collect::>(); - - let diff = headers - .difference(&original_headers) - .collect::>(); - - let diff_bytes = headers_bytes - .difference(&original_headers_bytes) - .collect::>(); - - let diff = diff.difference(&expected).collect::>(); - - if !diff.is_empty() { - println!("different headers: {:?}", diff); - self.send_http_response(552, vec![], None); - return Action::Pause; - } - - let diff_bytes = diff_bytes.difference(&expected_bytes).collect::>(); - if !diff_bytes.is_empty() { - println!("different headers bytes: {:?}", diff_bytes); - self.send_http_response(552, vec![], None); - return Action::Pause; - } - - Action::Continue - } -} -``` - -### FILE: examples/cdn/headers/Cargo.toml - -```toml -[workspace] - -[package] -name = "headers" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -proxy-wasm = "0.2" -``` - -### FILE: examples/cdn/headers/README.md - -``` -[← Back to examples](../../README.md) - -# Headers (CDN) - -Validates and manipulates HTTP request and response headers using the proxy-wasm ABI. -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/http-call-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/http-call-rust.md index d382b3a..6a480d8 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/http-call-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/http-call-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/kv-store-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/kv-store-rust.md index ae5fef9..e705d21 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/kv-store-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/kv-store-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -111,11 +111,11 @@ fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Ac ## Query Parameter Parsing -Query string is retrieved from the request property path `["request", "query"]`. +Query string is retrieved from the request property path `["request.query"]` (dot-notation single string). ```rust let query = self - .get_property(vec!["request", "query"]) + .get_property(vec!["request.query"]) .and_then(|bytes| String::from_utf8(bytes).ok()) .unwrap_or_default(); @@ -251,12 +251,12 @@ Replaces the full upstream response body. First argument is offset (always `0`), ## Error Handling -Errors are logged via `println!` and returned as `{"error": ""}` JSON. Response status is set to `500` via the `response.status` property. +Errors are logged via `println!` and returned as `{"error": ""}` JSON. Response status is set to `500` via the `response.status` property (dot-notation single string). ```rust fn send_error(&self, msg: &str, body_size: usize) { println!("{}", msg); - self.set_property(vec!["response", "status"], Some(b"500")); + self.set_property(vec!["response.status"], Some(b"500")); let error_body = json!({"error": msg}).to_string(); self.set_http_response_body(0, body_size, error_body.as_bytes()); } @@ -292,6 +292,7 @@ fn send_error(&self, msg: &str, body_size: usize) { - `min` and `max` for `zrange` must be parseable as `f64`; any non-numeric string returns an error. - The store name must correspond to a KV store provisioned and bound to the FastEdge app. Attempting to open an unbound store returns an error. - `send_error` uses `println!` for logging (not `proxy_wasm::hostcalls::log`). +- Property paths use dot-notation single strings: `"request.query"` and `"response.status"` (not separate vector elements). ## See Also @@ -299,3 +300,363 @@ fn send_error(&self, msg: &str, body_size: usize) { - proxy-wasm HttpContext trait (on_http_response_headers, on_http_response_body, set_http_response_body) - cdn-base skeleton (RootContext and HttpContext wiring) - platform-overview reference (KV store provisioning and binding) + +## Source Material + +### FILE: examples/cdn/key_value/src/lib.rs + +```rust +/* +* Copyright 2025 G-Core Innovations SARL +*/ +/* +Example CDN app demonstrating KV Store operations via the proxy-wasm interface. + +Supports all KV Store operations via query parameters: + ?store=&action=get&key= + ?store=&action=scan&match= + ?store=&action=zrange&key=&min=&max= + ?store=&action=zscan&key=&match= + ?store=&action=bfExists&key=&item= + +Defaults to action=get if not specified. +*/ + +use fastedge::proxywasm::key_value::Store; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use serde_json::json; +use std::collections::HashMap; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { Box::new(KvStoreRoot) }); +}} + +struct KvStoreRoot; + +impl Context for KvStoreRoot {} + +impl RootContext for KvStoreRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(KvStoreContext)) + } +} + +struct KvStoreContext; + +impl Context for KvStoreContext {} + +impl HttpContext for KvStoreContext { + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + // Remove content-length since we replace the body + self.set_http_response_header("content-length", None); + self.set_http_response_header("content-type", Some("application/json")); + self.set_http_response_header("transfer-encoding", Some("chunked")); + Action::Continue + } + + fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { + if !end_of_stream { + return Action::Pause; + } + + let query = self + .get_property(vec!["request.query"]) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + + if query.is_empty() { + self.send_error("App must be called with query parameters", body_size); + return Action::Continue; + } + + let params: HashMap<&str, &str> = querystring::querify(&query).into_iter().collect(); + + let Some(store_name) = params.get("store") else { + self.send_error("Missing required param 'store'", body_size); + return Action::Continue; + }; + + let action = params.get("action").copied().unwrap_or("get"); + + let store = match Store::open(store_name) { + Ok(s) => s, + Err(e) => { + self.send_error(&format!("Failed to open KvStore '{}': {}", store_name, e), body_size); + return Action::Continue; + } + }; + + let result = match action { + "get" => self.handle_get(&store, ¶ms), + "scan" => self.handle_scan(&store, ¶ms), + "zrange" => self.handle_zrange(&store, ¶ms), + "zscan" => self.handle_zscan(&store, ¶ms), + "bfExists" => self.handle_bf_exists(&store, ¶ms), + _ => Err(format!( + "Invalid action '{}'. Supported: get, scan, zrange, zscan, bfExists", + action + )), + }; + + let body = match result { + Ok(json) => json, + Err(msg) => { + self.send_error(&msg, body_size); + return Action::Continue; + } + }; + + self.set_http_response_body(0, body_size, body.as_bytes()); + + Action::Continue + } +} + +impl KvStoreContext { + fn handle_get(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'get' action")?; + match store.get(key) { + Ok(Some(value)) => { + let value_str = String::from_utf8_lossy(&value); + Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "get", + "key": key, + "response": value_str.as_ref() + }).to_string()) + } + Ok(None) => Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "get", + "key": key, + "response": null + }).to_string()), + Err(e) => Err(format!("KV get error: {}", e)), + } + } + + fn handle_scan(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let pattern = *params.get("match").ok_or("Missing required param 'match' for 'scan' action")?; + match store.scan(pattern) { + Ok(keys) => Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "scan", + "match": pattern, + "response": keys + }).to_string()), + Err(e) => Err(format!("KV scan error: {}", e)), + } + } + + fn handle_zrange(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'zrange' action")?; + let min: f64 = params + .get("min") + .ok_or("Missing required param 'min' for 'zrange' action")? + .parse() + .map_err(|_| "Invalid 'min' value: must be a number".to_string())?; + let max: f64 = params + .get("max") + .ok_or("Missing required param 'max' for 'zrange' action")? + .parse() + .map_err(|_| "Invalid 'max' value: must be a number".to_string())?; + + match store.zrange_by_score(key, min, max) { + Ok(entries) => { + let entries_json: Vec = entries + .iter() + .map(|(value, score)| { + let value_str = String::from_utf8_lossy(value); + json!({"value": value_str.as_ref(), "score": score}) + }) + .collect(); + Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "zrange", + "key": key, + "min": min, + "max": max, + "response": entries_json + }).to_string()) + } + Err(e) => Err(format!("KV zrange error: {}", e)), + } + } + + fn handle_zscan(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'zscan' action")?; + let pattern = *params.get("match").ok_or("Missing required param 'match' for 'zscan' action")?; + + match store.zscan(key, pattern) { + Ok(entries) => { + let entries_json: Vec = entries + .iter() + .map(|(value, score)| { + let value_str = String::from_utf8_lossy(value); + json!({"value": value_str.as_ref(), "score": score}) + }) + .collect(); + Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "zscan", + "key": key, + "match": pattern, + "response": entries_json + }).to_string()) + } + Err(e) => Err(format!("KV zscan error: {}", e)), + } + } + + fn handle_bf_exists(&self, store: &Store, params: &HashMap<&str, &str>) -> Result { + let key = *params.get("key").ok_or("Missing required param 'key' for 'bfExists' action")?; + let item = *params.get("item").ok_or("Missing required param 'item' for 'bfExists' action")?; + + match store.bf_exists(key, item) { + Ok(exists) => Ok(json!({ + "store": params.get("store").unwrap_or(&""), + "action": "bfExists", + "key": key, + "item": item, + "response": exists + }).to_string()), + Err(e) => Err(format!("KV bfExists error: {}", e)), + } + } + + fn send_error(&self, msg: &str, body_size: usize) { + println!("{}", msg); + self.set_property( + vec!["response.status"], + Some(b"500"), + ); + let error_body = json!({"error": msg}).to_string(); + self.set_http_response_body(0, body_size, error_body.as_bytes()); + } +} +``` + + +### FILE: examples/cdn/key_value/Cargo.toml + +```toml +[workspace] + +[package] +name = "key_value" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +fastedge = { version = "0.4", features = ["proxywasm"] } +querystring = "1.1" +serde_json = "1" +``` + + +### FILE: examples/cdn/key_value/README.md + +``` +[← Back to examples](../../README.md) + +# Key Value (CDN) + +This example shows how to read and write data from a FastEdge KV store from a CDN app. +It intercepts the HTTP response, reads the request query string, and executes a KV operation against a named store. + +## What it does + +The app supports the following actions: + +- `get` — fetch one key +- `scan` — list keys matching a pattern +- `zrange` — read sorted-set entries by score range +- `zscan` — list sorted-set entries matching a pattern +- `bfExists` — check whether a Bloom filter contains an item + +The request must include at least: + +- `store=` — KV store name +- `action=` — optional, defaults to `get` + +For each action, the app validates required parameters and returns a JSON response body. + +## Supported query examples + +### Get a key + +```text +?store=my_store&action=get&key=user:42 +``` + +### List keys by pattern + +```text +?store=my_store&action=scan&match=user:* +``` + +### Read a sorted set by score range + +```text +?store=my_store&action=zrange&key=leaderboard&min=0&max=100 +``` + +### Search sorted-set members by pattern + +```text +?store=my_store&action=zscan&key=leaderboard&match=user:* +``` + +### Check a Bloom filter item + +```text +?store=my_store&action=bfExists&key=visitors&item=alice@example.com +``` + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip1/release/key_value.wasm +``` + +This example is a CDN app, so it targets `wasm32-wasip1`. + +## Response format + +The app replaces the response body with JSON and sets `content-type: application/json`. +A successful response looks like this: + +```json +{ + "store": "my_store", + "action": "get", + "key": "user:42", + "response": "Alice" +} +``` + +If a required parameter is missing or the KV operation fails, the app responds with an error payload: + +```json +{ + "error": "Missing required param 'key' for 'get' action" +} +``` + +## Notes + +- The app requires query parameters to run. +- If `action` is omitted, it defaults to `get`. +- The code uses `fastedge::proxywasm::key_value::Store` and `proxy_wasm` lifecycle hooks to operate on the KV store. +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/large-dictionary-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/large-dictionary-rust.md index dda7cb2..6a7d809 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/large-dictionary-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/large-dictionary-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/log-time-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/log-time-rust.md index 4c8ed4f..d58c4c1 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/log-time-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/log-time-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/md2html-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/md2html-rust.md index 84d4b88..84cb3f2 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/md2html-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/md2html-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/properties-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/properties-rust.md index c29b799..116d81c 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/cdn/properties-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/cdn/properties-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -416,19 +416,31 @@ impl HttpContext for PropertiesContext { let params = querystring::querify(query_str); if let Some(url) = params.iter().find_map(|(k, v)| { - if "url".eq_ignore_ascii_case(k) { Some(v) } else { None } + if "url".eq_ignore_ascii_case(k) { + Some(v) + } else { + None + } }) { self.set_property(vec![REQUEST_URI], Some(url.as_bytes())); } if let Some(host) = params.iter().find_map(|(k, v)| { - if "host".eq_ignore_ascii_case(k) { Some(v) } else { None } + if "host".eq_ignore_ascii_case(k) { + Some(v) + } else { + None + } }) { self.set_property(vec![REQUEST_HOST], Some(host.as_bytes())); } if let Some(path) = params.iter().find_map(|(k, v)| { - if "path".eq_ignore_ascii_case(k) { Some(v) } else { None } + if "path".eq_ignore_ascii_case(k) { + Some(v) + } else { + None + } }) { self.set_property(vec![REQUEST_PATH], Some(path.as_bytes())); } diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/ab-testing-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/ab-testing-wasi-rust.md index 0edcedd..6fc190d 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/ab-testing-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/ab-testing-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -217,214 +217,3 @@ Response::builder() - fastedge-sdk-rust platform overview - host-services-rust reference (outbound HTTP, environment variables) - best-practices reference (cookie security, entropy sources) - -## Source Material - -### FILE: examples/http/wasi/ab_testing/src/lib.rs - -```rust -/* - * Copyright 2025 G-Core Innovations SARL - */ -/* -Cookie-based A/B testing example. - -Reads or creates an `x-fastedge-abid` cookie, uses its value to deterministically -assign the visitor to weighted variants of each configured test, then proxies the -request to `OUTBOUND_URL` with the variant assignments attached as `ab-test-` -headers. The origin response is returned verbatim with a `set-cookie` header so -returning visitors receive the same variants on subsequent visits. - -Required configuration: - - Environment variable: OUTBOUND_URL (downstream origin to proxy to) - -Mirror of the FastEdge-sdk-js `ab-testing` example. -*/ - -use std::env; -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::anyhow; -use wstd::http::body::Body; -use wstd::http::{Client, Request, Response}; - -struct VariantWeight { - variant: &'static str, - weight: f64, -} - -struct AbTest { - name: &'static str, - variants: &'static [VariantWeight], -} - -static TESTS: &[AbTest] = &[ - AbTest { - name: "logo", - variants: &[ - VariantWeight { variant: "hops", weight: 50.0 }, - VariantWeight { variant: "bottle", weight: 50.0 }, - ], - }, - AbTest { - name: "font", - variants: &[ - VariantWeight { variant: "exo2", weight: 40.0 }, - VariantWeight { variant: "gloria", weight: 65.0 }, - VariantWeight { variant: "standard", weight: 45.0 }, - ], - }, -]; - -const AB_COOKIE: &str = "x-fastedge-abid"; - -#[wstd::http_server] -async fn main(req: Request) -> anyhow::Result> { - let outbound_url = match env::var("OUTBOUND_URL") { - Ok(u) if !u.trim().is_empty() => u, - _ => { - return Ok(Response::builder() - .status(500) - .body(Body::from( - "OUTBOUND_URL environment variable is not configured", - ))?); - } - }; - - let raw_cookie = req - .headers() - .get("cookie") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - - let (xid, cleaned_cookie) = match extract_abid(&raw_cookie) { - Some(existing) if is_valid_xid(existing) => { - (existing.to_string(), strip_abid(&raw_cookie)) - } - _ => (generate_xid(), raw_cookie.clone()), - }; - - // Build the outbound request: copy incoming headers except `host` and - // `cookie` (which we handle specially), replace the cookie with a version - // that has the abid stripped (so the origin never sees it), and attach an - // `ab-test-` header for every configured test. - let mut builder = Request::get(&outbound_url); - for (name, value) in req.headers() { - let n = name.as_str(); - if n == "host" || n == "cookie" { - continue; - } - if let Ok(v) = value.to_str() { - builder = builder.header(n, v); - } - } - if !cleaned_cookie.trim().is_empty() { - builder = builder.header("cookie", cleaned_cookie); - } - for test in TESTS { - if let Some(variant) = assign_variant(&xid, test) { - builder = builder.header(format!("ab-test-{}", test.name), variant); - } - } - - let outbound_req = builder - .body(Body::empty()) - .map_err(|e| anyhow!("failed to build outbound request: {e}"))?; - - let outbound_resp = Client::new() - .send(outbound_req) - .await - .map_err(|e| anyhow!("outbound request failed: {e}"))?; - - let (parts, mut body) = outbound_resp.into_parts(); - let body_bytes = body.contents().await?; - - let content_type = parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - - let xid_cookie = - format!("{AB_COOKIE}={xid}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"); - - Ok(Response::builder() - .status(parts.status) - .header("content-type", content_type) - .header("set-cookie", xid_cookie) - .body(Body::from(body_bytes))?) -} - -fn extract_abid(cookie_header: &str) -> Option<&str> { - let needle = format!("{AB_COOKIE}="); - cookie_header - .split(';') - .map(str::trim) - .find_map(|p| p.strip_prefix(needle.as_str())) -} - -fn strip_abid(cookie_header: &str) -> String { - let needle = format!("{AB_COOKIE}="); - cookie_header - .split(';') - .map(str::trim) - .filter(|p| !p.is_empty()) - .filter(|p| !p.starts_with(needle.as_str())) - .collect::>() - .join("; ") -} - -fn is_valid_xid(xid: &str) -> bool { - matches!(xid.parse::(), Ok(v) if (0.0..1.0).contains(&v)) -} - -/// Generate a pseudo-random A/B id of the form `"0.NNNN"`. -/// -/// Uses request-time nanoseconds as a weak entropy source. For production, -/// prefer a cryptographic RNG (e.g. `rand` wired to `wasi-random`). -fn generate_xid() -> String { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - format!("0.{:04}", now.subsec_nanos() % 10000) -} - -fn assign_variant(xid: &str, test: &AbTest) -> Option<&'static str> { - let xid_value: f64 = xid.parse().ok()?; - let xid_percentage = xid_value * 100.0; - let total: f64 = test.variants.iter().map(|v| v.weight).sum(); - if total == 0.0 { - return None; - } - let mut start = 0.0; - for vw in test.variants { - let percentage = (vw.weight / total) * 100.0; - let end = start + percentage; - if xid_percentage >= start && xid_percentage < end { - return Some(vw.variant); - } - start = end; - } - None -} -``` - -### FILE: examples/http/wasi/ab_testing/Cargo.toml - -```toml -[workspace] - -[package] -name = "ab_testing_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -anyhow = "1" -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/base-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/base-rust.md index 671c3e6..cf14454 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/base-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/base-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -13,8 +13,8 @@ app_type: http languages: [rust] template_origin: http-base source_repo: fastedge-sdk-rust -source_ref: 6347a7c2fda0d03e66f1214db5eec041c16801b7 -updated: 2026-08-20 +source_ref: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 +updated: 2026-09-22 --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/bloom-filter-denylist-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/bloom-filter-denylist-wasi-rust.md index c90f65f..0ca1b79 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/bloom-filter-denylist-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/bloom-filter-denylist-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -193,3 +193,117 @@ fn json_response(status: u16, value: serde_json::Value) -> anyhow::Result) -> anyhow::Result> { + let store_name = match env::var("DENYLIST_STORE") { + Ok(s) if !s.trim().is_empty() => s, + _ => { + return json_response( + 500, + json!({ "error": "DENYLIST_STORE environment variable is not configured" }), + ); + } + }; + + let headers = req.headers(); + let client_ip = headers + .get("x-real-ip") + .or_else(|| headers.get("x-forwarded-for")) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + let Some(ip) = client_ip else { + return json_response(500, json!({ "error": "client IP not available" })); + }; + + let store = match Store::open(&store_name) { + Ok(s) => s, + Err(StoreError::AccessDenied) => { + return json_response( + 403, + json!({ "error": "access denied opening denylist store" }), + ); + } + Err(e) => { + return json_response(500, json!({ "error": format!("store open error: {e}") })); + } + }; + + let blocked = store + .bf_exists(BLOOM_KEY, ip) + .map_err(|e| anyhow!("bf_exists error: {e}"))?; + + if blocked { + // Bloom filter says "maybe in set" — a small fraction of hits will be false + // positives. Acceptable for a denylist (you over-block some legitimate users); + // not acceptable for allowlists — use `store.get()` against a regular key instead. + return json_response(403, json!({ "allowed": false, "ip": ip })); + } + + json_response(200, json!({ "allowed": true, "ip": ip })) +} + +fn json_response(status: u16, value: serde_json::Value) -> anyhow::Result> { + Ok(Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(value.to_string()))?) +} +``` + +### FILE: examples/http/wasi/bloom_filter_denylist/Cargo.toml + +```toml +[workspace] + +[package] +name = "bloom_filter_denylist_wasi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +fastedge = "0.4" +anyhow = "1" +serde_json = "1" +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/cache-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/cache-wasi-rust.md index 78af466..93b6a95 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/cache-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/cache-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -288,201 +288,3 @@ cargo build --release - http-base skeleton - outbound-http feature blueprint - platform-overview (environment variable configuration) - -## Source Material - -### FILE: examples/http/wasi/cache/src/lib.rs - -```rust -/* - * Copyright 2025 G-Core Innovations SARL - */ -/* -Example app demonstrating response caching and cache purging via the cache interface. - -The app reads ORIGIN_HOST from the environment, forwards the incoming request -to that origin, and caches the response body keyed by the request path. -On subsequent requests for the same path the cached body is returned directly -without hitting the origin. - -Cache reads and writes use the synchronous `fastedge::cache` API; upstream -HTTP I/O still uses the async `wstd` client. - -Special purge routes (handled before any origin call): - GET /purge — purge all cached keys; returns 200 with deleted count - GET /purge/ — purge keys whose cache key starts with cache:/ - -Environment variables: - ORIGIN_HOST Base URL of the upstream origin, e.g. https://api.example.com - CACHE_TTL_MS How long to cache responses in milliseconds (default: 60000) - -Build: - cargo build --release -*/ - -use std::env; - -use anyhow::anyhow; -use fastedge::cache; -use wstd::http::body::Body; -use wstd::http::{Client, Request, Response}; - -#[wstd::http_server] -async fn main(req: Request) -> anyhow::Result> { - let origin = env::var("ORIGIN_HOST") - .map_err(|_| anyhow!("ORIGIN_HOST environment variable is not set"))?; - - let ttl_ms = req.headers().get("cache-ttl-ms").and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse().ok()) - .unwrap_or_else(|| { - env::var("CACHE_TTL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(60_000) - }); - - // Build cache key from the request path (and query string if present) - let path_and_query = req - .uri() - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); - - - // Handle purge requests before any cache/origin logic - if path_and_query == "/purge" { - let deleted = cache::purge()?; - println!("purge all: {deleted} keys removed"); - return Ok(Response::builder() - .status(204) - .body(Body::empty())?); - } - if let Some(prefix) = path_and_query.strip_prefix("/purge/") { - let prefix = format!("cache:/{prefix}"); - let deleted = cache::purge_prefix(&prefix)?; - println!("purge prefix '{prefix}': {deleted} keys removed"); - return Ok(Response::builder() - .status(204) - .body(Body::empty())?); - } - - if let Some(prefix) = path_and_query.strip_prefix("/delete/") { - let prefix = format!("cache:/{prefix}"); - cache::delete(&prefix)?; - println!("prefix '{prefix}': removed"); - return Ok(Response::builder() - .status(204) - .body(Body::empty())?); - } - - let cache_key = format!("cache:{path_and_query}"); - - // Return cached response if available - if let Some(cached) = cache::get(&cache_key)? { - println!("cache hit: {cache_key}"); - return Ok(Response::builder() - .status(200) - .header("content-type", "application/octet-stream") - .header("x-cache", "hit") - .body(Body::from(cached))?); - } - - // Cache miss — forward request to origin - let upstream_url = format!("{}{}", origin.trim_end_matches('/'), path_and_query); - println!("cache miss: {cache_key} → {upstream_url}"); - - let upstream_req = Request::get(&upstream_url) - .body(Body::empty()) - .map_err(|e| anyhow!("failed to build upstream request: {e}"))?; - - let upstream_resp = Client::new() - .send(upstream_req) - .await - .map_err(|e| anyhow!("upstream request failed: {e}"))?; - - let status = upstream_resp.status(); - let headers: Vec<(String, String)> = upstream_resp - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - // Read body bytes - let mut body = upstream_resp.into_body(); - let body_bytes = body.contents().await?.to_vec(); - - // Only cache successful responses - if status.is_success() { - cache::set(&cache_key, &body_bytes, Some(ttl_ms))?; - } - - // Replay original response - let mut builder = Response::builder() - .status(status) - .header("x-cache", "miss"); - for (k, v) in &headers { - builder = builder.header(k, v); - } - Ok(builder.body(Body::from(body_bytes))?) -} -``` - - -### FILE: examples/http/wasi/cache/Cargo.toml - -```toml -[workspace] - -[package] -name = "cache_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -fastedge = "0.4" -anyhow = "1" -``` - - -### FILE: examples/http/wasi/cache/README.md - -``` -[← Back to examples](../../../README.md) - -# Cache (WASI) - -Demonstrates the cache-aside pattern with origin forwarding using `fastedge::cache`. Forwards incoming requests to `ORIGIN_HOST`, caches successful response bodies keyed by path and query string, and serves cached bytes directly on subsequent matching requests. - -## Configuration - -| Env var | Required | Description | -|---|---|---| -| `ORIGIN_HOST` | Yes | Base URL of the upstream origin (e.g. `https://api.example.com`). Returns 500 if unset. | -| `CACHE_TTL_MS` | No | How long to cache responses in milliseconds. Default: `60000` (60 s). | - -## How it works - -``` -GET /data?id=1 → cache miss → forward to ORIGIN_HOST/data?id=1 → cache 2xx body → 200 (x-cache: miss) -GET /data?id=1 → cache hit → return cached body → 200 (x-cache: hit) -``` - -Cache key is `cache:?`. Only 2xx responses from the origin are cached — error responses pass through without being stored. The origin's response headers are replayed on cache miss; cache-hit responses use `content-type: application/octet-stream` since the original content-type is not stored alongside the body bytes. - -## Build - -```sh -cargo build --release -# Output: target/wasm32-wasip2/release/cache_wasi.wasm -``` - -## APIs used - -- `fastedge::cache::get(key)` — retrieve cached bytes by key; returns `Ok(Option>)` -- `fastedge::cache::set(key, bytes, ttl_ms)` — store bytes with optional TTL in milliseconds; `None` means no expiry -- `wstd::http::Client::new().send(req).await` — async outbound HTTP request to origin -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/diagnostic-logging-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/diagnostic-logging-wasi-rust.md index c4395fb..fb3c0c9 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/diagnostic-logging-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/diagnostic-logging-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -190,3 +190,104 @@ async fn main(req: Request) -> anyhow::Result> { - http-base skeleton (base handler structure, entry point macro) - platform-overview (log viewer, per-request diagnostics context) - fastedge SDK Rust reference (full `fastedge::utils` API surface) + +## Source Material + +### FILE: examples/http/wasi/diagnostic_logging/src/lib.rs + +```rust +/* + * Copyright 2025 G-Core Innovations SARL + */ +/* +Diagnostic logging example. + +Tiny pass-through proxy that writes a single `set_user_diag` tag per request +summarising the outcome (config_error, origin_unreachable, or proxied). The +tag appears in the FastEdge platform's per-request log viewer and is distinct +from stdout — it's intended for filterable outcome labels, not verbose +traces. + +Required configuration: + - Environment variable: ORIGIN_URL (origin to proxy to) + +Uses `logfmt`-ish formatting (`outcome= key=value ...`) so the tag is +easy to slice in log search tooling. +*/ + +use std::env; + +use fastedge::utils::set_user_diag; +use wstd::http::body::Body; +use wstd::http::{Client, Request, Response}; + +#[wstd::http_server] +async fn main(req: Request) -> anyhow::Result> { + let method = req.method().as_str().to_string(); + let path = req.uri().path().to_string(); + + let origin = match env::var("ORIGIN_URL") { + Ok(u) if !u.trim().is_empty() => u, + _ => { + set_user_diag("outcome=config_error reason=origin_missing"); + return Ok(Response::builder() + .status(500) + .header("content-type", "text/plain; charset=utf-8") + .body(Body::from("ORIGIN_URL is not configured"))?); + } + }; + + let outbound = Request::get(&origin).body(Body::empty())?; + let resp = match Client::new().send(outbound).await { + Ok(r) => r, + Err(e) => { + set_user_diag(&format!( + "outcome=origin_unreachable method={method} path={path} err={e}" + )); + return Ok(Response::builder() + .status(502) + .header("content-type", "text/plain; charset=utf-8") + .body(Body::from("origin unreachable"))?); + } + }; + + let status = resp.status().as_u16(); + set_user_diag(&format!( + "outcome=proxied method={method} path={path} status={status}" + )); + + let (parts, mut body) = resp.into_parts(); + let bytes = body.contents().await?; + let content_type = parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + Ok(Response::builder() + .status(parts.status) + .header("content-type", content_type) + .body(Body::from(bytes))?) +} +``` + + +### FILE: examples/http/wasi/diagnostic_logging/Cargo.toml + +```toml +[workspace] + +[package] +name = "diagnostic_logging_wasi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +fastedge = "0.4" +anyhow = "1" +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/geo-redirect-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/geo-redirect-wasi-rust.md index 4554a90..b36860f 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/geo-redirect-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/geo-redirect-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -154,119 +154,3 @@ cargo build --release - http-base skeleton reference - FastEdge platform overview (geoip-country-code header injection) - deploy skill reference (uploading WASM binary and setting environment variables) - -## Source Material - -### FILE: examples/http/wasi/geo_redirect/src/lib.rs - -```rust -/* -* Copyright 2025 G-Core Innovations SARL -*/ -/* -Example WASI-HTTP app demonstrating geo-based redirects. - -Reads the country code from the geoip-country-code request header -and redirects to a country-specific origin URL. Falls back to -BASE_ORIGIN when no country-specific mapping is configured. - -Required configuration: - - Environment variable: BASE_ORIGIN (fallback origin URL) - - Environment variable: (optional per-country origin URLs, e.g. US, DE, GB) -*/ - -use std::env; -use wstd::http::body::Body; -use wstd::http::{Request, Response}; - -#[wstd::http_server] -async fn main(req: Request) -> anyhow::Result> { - let base_origin = match env::var("BASE_ORIGIN") { - Ok(origin) => origin, - Err(_) => { - return Ok(Response::builder() - .status(500) - .body(Body::from("BASE_ORIGIN is not set"))?); - } - }; - - let country_code = req - .headers() - .get("geoip-country-code") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - - let redirect_origin = if !country_code.is_empty() { - env::var(&country_code).unwrap_or(base_origin) - } else { - base_origin - }; - - Ok(Response::builder() - .status(302) - .header("location", &redirect_origin) - .body(Body::empty())?) -} -``` - - -### FILE: examples/http/wasi/geo_redirect/Cargo.toml - -```toml -[workspace] - -[package] -name = "geo_redirect_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -anyhow = "1" -``` - - -### FILE: examples/http/wasi/geo_redirect/README.md - -``` -[← Back to examples](../../../README.md) - -# Geo Redirect (WASI) - -Redirects requests to country-specific origins based on the `geoip-country-code` request header. Falls back to `BASE_ORIGIN` when no country-specific mapping is configured. - -Demonstrates reading request headers, reading environment variables, and returning redirect responses. - -## Configuration - -| Env var | Required | Description | -|---|---|---| -| `BASE_ORIGIN` | Yes | Fallback redirect URL (e.g. `https://example.com`). Returns 500 if unset. | -| `` | No | Per-country redirect URL, keyed by 2-letter country code (e.g. `DE`, `US`, `GB`). Falls back to `BASE_ORIGIN` if not set. | - -## How it works - -``` -geoip-country-code: DE → env var DE is set → 302 to DE value -geoip-country-code: FR → env var FR not set → 302 to BASE_ORIGIN -(no header) → 302 to BASE_ORIGIN -BASE_ORIGIN not set → 500 -``` - -## Build - -```sh -cargo build --release -# Output: target/wasm32-wasip2/release/geo_redirect_wasi.wasm -``` - -## APIs used - -- `request.headers().get("geoip-country-code")` — read geo header injected by the FastEdge edge -- `std::env::var(country_code)` — dynamic env var lookup by country code -- `Response::builder().status(302).header("location", url)` — redirect response -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/headers-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/headers-wasi-rust.md index 1adeb05..dfce358 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/headers-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/headers-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/kv-store-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/kv-store-wasi-rust.md index a23be87..b6fde53 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/kv-store-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/kv-store-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -350,218 +350,3 @@ target = "wasm32-wasip1" - fastedge-sdk-rust key_value module documentation - http-base skeleton reference - wstd HTTP server documentation - -## Source Material - -### FILE: examples/http/wasi/key_value/src/lib.rs - -```rust -/* -* Copyright 2025 G-Core Innovations SARL -*/ -/* -Example app demonstrating KV Store operations via the WASI-HTTP interface. - -Supports all KV Store operations via query parameters: - ?store=&action=get&key= - ?store=&action=scan&match= - ?store=&action=zrange&key=&min=&max= - ?store=&action=zscan&key=&match= - ?store=&action=bfExists&key=&item= - -Defaults to action=get if not specified. -*/ - -use std::collections::HashMap; - -use anyhow::anyhow; -use fastedge::key_value::{Store, Error as StoreError}; -use serde_json::json; -use wstd::http::body::Body; -use wstd::http::{Request, Response}; - -#[wstd::http_server] -async fn main(req: Request) -> anyhow::Result> { - let query = req.uri().query().ok_or(anyhow!("no query parameters"))?; - let params: HashMap<&str, &str> = querystring::querify(query).into_iter().collect(); - - let store_name = *params - .get("store") - .ok_or(anyhow!("missing param 'store'"))?; - - let action = params.get("action").copied().unwrap_or("get"); - - let store = match Store::open(store_name) { - Ok(s) => s, - Err(StoreError::AccessDenied) => { - return Ok(Response::builder() - .status(403) - .header("content-type", "application/json") - .body(Body::from(json!({"error": "access denied"}).to_string()))?); - } - Err(e) => { - return Ok(Response::builder() - .status(500) - .header("content-type", "application/json") - .body(Body::from(json!({"error": format!("store open error: {e}")}).to_string()))?); - } - }; - - let body = match action { - "get" => handle_get(&store, ¶ms)?, - "scan" => handle_scan(&store, ¶ms)?, - "zrange" => handle_zrange(&store, ¶ms)?, - "zscan" => handle_zscan(&store, ¶ms)?, - "bfExists" => handle_bf_exists(&store, ¶ms)?, - _ => { - return Ok(Response::builder() - .status(400) - .header("content-type", "application/json") - .body(Body::from(json!({"error": format!("Invalid action '{action}'. Supported: get, scan, zrange, zscan, bfExists")}).to_string()))?); - } - }; - - Ok(Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from(body))?) -} - -fn handle_get(store: &Store, params: &HashMap<&str, &str>) -> anyhow::Result { - let key = *params.get("key").ok_or(anyhow!("missing param 'key'"))?; - match store.get(key) { - Ok(Some(value)) => { - let value_str = String::from_utf8_lossy(&value); - Ok(json!({ - "store": params.get("store").unwrap_or(&""), - "action": "get", - "key": key, - "response": value_str.as_ref() - }).to_string()) - } - Ok(None) => Ok(json!({ - "store": params.get("store").unwrap_or(&""), - "action": "get", - "key": key, - "response": null - }).to_string()), - Err(e) => Err(anyhow!("KV get error: {e}")), - } -} - -fn handle_scan(store: &Store, params: &HashMap<&str, &str>) -> anyhow::Result { - let pattern = *params - .get("match") - .ok_or(anyhow!("missing param 'match'"))?; - match store.scan(pattern) { - Ok(keys) => Ok(json!({ - "store": params.get("store").unwrap_or(&""), - "action": "scan", - "match": pattern, - "response": keys - }).to_string()), - Err(e) => Err(anyhow!("KV scan error: {e}")), - } -} - -fn handle_zrange(store: &Store, params: &HashMap<&str, &str>) -> anyhow::Result { - let key = *params.get("key").ok_or(anyhow!("missing param 'key'"))?; - let min: f64 = params - .get("min") - .ok_or(anyhow!("missing param 'min'"))? - .parse() - .map_err(|_| anyhow!("invalid 'min': must be a number"))?; - let max: f64 = params - .get("max") - .ok_or(anyhow!("missing param 'max'"))? - .parse() - .map_err(|_| anyhow!("invalid 'max': must be a number"))?; - - match store.zrange_by_score(key, min, max) { - Ok(entries) => { - let entries_json: Vec = entries - .iter() - .map(|(value, score)| { - let value_str = String::from_utf8_lossy(value); - json!({"value": value_str.as_ref(), "score": score}) - }) - .collect(); - Ok(json!({ - "store": params.get("store").unwrap_or(&""), - "action": "zrange", - "key": key, - "min": min, - "max": max, - "response": entries_json - }).to_string()) - } - Err(e) => Err(anyhow!("KV zrange error: {e}")), - } -} - -fn handle_zscan(store: &Store, params: &HashMap<&str, &str>) -> anyhow::Result { - let key = *params.get("key").ok_or(anyhow!("missing param 'key'"))?; - let pattern = *params - .get("match") - .ok_or(anyhow!("missing param 'match'"))?; - - match store.zscan(key, pattern) { - Ok(entries) => { - let entries_json: Vec = entries - .iter() - .map(|(value, score)| { - let value_str = String::from_utf8_lossy(value); - json!({"value": value_str.as_ref(), "score": score}) - }) - .collect(); - Ok(json!({ - "store": params.get("store").unwrap_or(&""), - "action": "zscan", - "key": key, - "match": pattern, - "response": entries_json - }).to_string()) - } - Err(e) => Err(anyhow!("KV zscan error: {e}")), - } -} - -fn handle_bf_exists(store: &Store, params: &HashMap<&str, &str>) -> anyhow::Result { - let key = *params.get("key").ok_or(anyhow!("missing param 'key'"))?; - let item = *params - .get("item") - .ok_or(anyhow!("missing param 'item'"))?; - - match store.bf_exists(key, item) { - Ok(exists) => Ok(json!({ - "store": params.get("store").unwrap_or(&""), - "action": "bfExists", - "key": key, - "item": item, - "response": exists - }).to_string()), - Err(e) => Err(anyhow!("KV bfExists error: {e}")), - } -} -``` - -### FILE: examples/http/wasi/key_value/Cargo.toml - -```toml -[workspace] - -[package] -name = "key_value_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -fastedge = "0.4" -anyhow = "1" -querystring = "1.1" -serde_json = "1" -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/large-env-variable-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/large-env-variable-wasi-rust.md index c8849da..11bc794 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/large-env-variable-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/large-env-variable-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -131,3 +131,92 @@ The crate type must be `cdylib` for WASM/WASI compilation. - http-base reference (base skeleton for WASI HTTP apps) - fastedge-sdk-rust SDK reference (full `fastedge` crate API) - platform-overview reference (environment variable limits and configuration) + +## Source Material + +### FILE: examples/http/wasi/large_env_variable/src/lib.rs + +```rust +/* +* Copyright 2025 G-Core Innovations SARL +*/ +/* +Example WASI-HTTP app demonstrating access to large environment variables. + +Uses `fastedge::dictionary` to read environment variables that may exceed +the 64KB WASI environment variable size limit. + +For normal-sized environment variables (< 64KB), prefer `std::env::var()` +instead. The dictionary API is only required when your variable value +may be larger than 64KB. + +Required configuration: + - Environment variable: LARGE_CONFIG (a large configuration payload, e.g. JSON) +*/ + +use fastedge::dictionary; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +#[wstd::http_server] +async fn main(_request: Request) -> anyhow::Result> { + // Use dictionary::get for environment variables that may exceed 64KB. + // For normal-sized env vars, use std::env::var() instead. + let config = dictionary::get("LARGE_CONFIG").unwrap_or_default(); + + let size = config.len(); + + Ok(Response::builder() + .status(200) + .body(Body::from(format!( + "LARGE_CONFIG loaded: {} bytes", + size + )))?) +} +``` + + +### FILE: examples/http/wasi/large_env_variable/Cargo.toml + +```toml +[workspace] + +[package] +name = "large_env_variable" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +fastedge = "0.4" +anyhow = "1" +``` + + +### FILE: examples/http/wasi/large_env_variable/README.md + +``` +[← Back to examples](../../../README.md) + +# Large Environment Variable (WASI) + +Demonstrates how to read **large environment variables** (> 64KB) using `fastedge::dictionary`. + +## When to use `dictionary` vs `std::env` + +| Method | Use when | +|--------|----------| +| `std::env::var("KEY")` | Variable value is under 64KB (most cases) | +| `fastedge::dictionary::get("KEY")` | Variable value may exceed the 64KB WASI env var size limit | + +The WASI environment variable interface has a **64KB size limit** per variable. If your app needs to read larger values (e.g. large JSON configs, certificates, policy documents), use the `dictionary` API which bypasses this limit. + +For all other environment variable access, prefer `std::env::var()` as it is the standard, idiomatic Rust approach. + +## Required configuration + +- **Environment variable**: `LARGE_CONFIG` - a large configuration payload (e.g. JSON, PEM certificate) +``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-fetch-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-fetch-wasi-rust.md index aeaede1..6f84bac 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-fetch-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-fetch-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -127,90 +127,3 @@ Both errors propagate via `?` and result in a 500-class response from the FastEd - `outbound-modify-response` (WASI, Rust) — fetches upstream and reshapes the body into a new JSON response - `streaming` (WASI, Rust) — handler that generates its own streaming response without an upstream fetch - `outbound-fetch` (JavaScript) — mirror of this example in the FastEdge SDK JS - -## Source Material - -### FILE: examples/http/wasi/outbound_fetch/src/lib.rs - -```rust -/* - * Copyright 2025 G-Core Innovations SARL - */ -/* -Minimal outbound fetch example. - -Makes a GET request to an upstream HTTP origin and returns the upstream -response verbatim — status, headers, and body pass through unchanged. - -For a variant that reads and transforms the upstream body, see -`outbound_modify_response/`. For a streaming-response demo, see `streaming/`. - -Mirror of the FastEdge-sdk-js `outbound-fetch` example. -*/ - -use anyhow::anyhow; -use wstd::http::body::Body; -use wstd::http::{Client, Request, Response}; - -#[wstd::http_server] -async fn main(_request: Request) -> anyhow::Result> { - let upstream_req = Request::get("http://jsonplaceholder.typicode.com/users") - .body(Body::empty()) - .map_err(|e| anyhow!("failed to build request: {e}"))?; - - let upstream_resp = Client::new() - .send(upstream_req) - .await - .map_err(|e| anyhow!("outbound request failed: {e}"))?; - - // Return the upstream response verbatim. The body is passed through - // without calling `.contents()`, so it streams to the client as upstream - // produces it. - let (parts, body) = upstream_resp.into_parts(); - let mut response = Response::new(body); - *response.status_mut() = parts.status; - *response.headers_mut() = parts.headers; - Ok(response) -} -``` - - -### FILE: examples/http/wasi/outbound_fetch/Cargo.toml - -```toml -[workspace] - -[package] -name = "outbound_fetch" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -anyhow = "1" -``` - - -### FILE: examples/http/wasi/outbound_fetch/README.md - -``` -[← Back to examples](../../../README.md) - -# Outbound Fetch (WASI) - -Fetch data from an outbound HTTP origin and return the response directly — status, headers, -and body pass through unchanged. - -The body is never buffered (no `.contents().await`), so upstream chunks stream to the client -as they arrive. - -## Related - -- [outbound_modify_response](../outbound_modify_response/) — same fetch, but reads the body - and reshapes it into a new JSON response. -- [streaming](../streaming/) — a handler that generates its own streaming response body. -- Mirror of `FastEdge-sdk-js/examples/outbound-fetch/`. -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-modify-response-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-modify-response-wasi-rust.md index c33ea71..eb8fd8f 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-modify-response-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-modify-response-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -216,82 +216,3 @@ async fn main(_request: Request) -> anyhow::Result> { - outbound-fetch-wasi-rust (simpler variant — passes upstream response through unchanged) - http-base skeleton (base handler structure, `#[wstd::http_server]`, `Body`, `Request`, `Response`) - sdk-reference-rust (full `wstd` API surface) - -## Source Material - -### FILE: examples/http/wasi/outbound_modify_response/src/lib.rs - -```rust -/* - * Copyright 2025 G-Core Innovations SARL - */ -/* -Outbound fetch with response transformation. - -Fetches JSON from an upstream origin, reads and parses the body, reshapes it -into a new JSON object (first 5 users with pagination metadata), and returns -it with a fresh `content-type: application/json` header. - -This is the stepping-stone beyond `outbound_fetch/` which just passes the -upstream response through unchanged. - -Mirror of the FastEdge-sdk-js `outbound-modify-response` example. -*/ - -use anyhow::anyhow; -use serde_json::{Value, json}; -use wstd::http::body::Body; -use wstd::http::{Client, Request, Response}; - -#[wstd::http_server] -async fn main(_request: Request) -> anyhow::Result> { - let upstream_req = Request::get("http://jsonplaceholder.typicode.com/users") - .body(Body::empty()) - .map_err(|e| anyhow!("failed to build request: {e}"))?; - - let upstream_resp = Client::new() - .send(upstream_req) - .await - .map_err(|e| anyhow!("outbound request failed: {e}"))?; - - let (_, mut body) = upstream_resp.into_parts(); - let body_bytes = body.contents().await?; - let users: Value = serde_json::from_slice(body_bytes)?; - - let sliced_users = match users.as_array() { - Some(arr) => Value::Array(arr.iter().take(5).cloned().collect()), - None => Value::Array(vec![]), - }; - - let result = json!({ - "users": sliced_users, - "total": 5, - "skip": 0, - "limit": 30, - }); - - Ok(Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from(result.to_string()))?) -} -``` - -### FILE: examples/http/wasi/outbound_modify_response/Cargo.toml - -```toml -[workspace] - -[package] -name = "outbound_modify_response_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -anyhow = "1" -serde_json = "1" -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/secret-rollover-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/secret-rollover-wasi-rust.md index 17c459e..0888c6e 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/secret-rollover-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/secret-rollover-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/simple-fetch-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/simple-fetch-wasi-rust.md index cf34807..2ea2f92 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/simple-fetch-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/simple-fetch-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -18,11 +18,11 @@ source_example: FastEdge-sdk-rust/examples/http/wasi/simple_fetch # Simple Fetch (WASI HTTP, Rust) -Outbound HTTP request to a caller-supplied URL, returning the upstream response directly. Uses the WASI-HTTP interface via the `wstd` crate with an async handler. +Outbound HTTP request to a caller-supplied URL, returning the upstream response directly. Supports optional header forwarding from the incoming request to the upstream request. Uses the WASI-HTTP interface via the `wstd` crate with an async handler. ## When to Use -Use this pattern when the app must make an outbound HTTP request to a URL specified by the incoming request (via the `x-fetch-url` header) and return the upstream response directly to the caller without decomposing it. +Use this pattern when the app must make an outbound HTTP request to a URL specified by the incoming request (via the `x-fetch-url` header) and return the upstream response directly to the caller without decomposing it. Optionally forwards a caller-specified header to the upstream request via `x-fetch-header`. ## Crate Dependencies @@ -69,17 +69,30 @@ let target_url = request - `.and_then(|v| v.to_str().ok())` — converts to `Option<&str>`, discarding invalid UTF-8 - `.unwrap_or(default)` — fallback value when header is absent or invalid -### Build Outbound Request +### Build Outbound Request with Conditional Header Forwarding ```rust -let upstream_req = Request::get(&target_url) - .header("accept", "application/json") +let mut builder = Request::get(&target_url).header("accept", "application/json"); + +if let Some(fetch_header) = request + .headers() + .get("x-fetch-header") + .and_then(|v| v.to_str().ok()) +{ + if let Some((key, value)) = fetch_header.split_once(':') { + builder = builder.header(key.trim(), value.trim()); + } +} + +let upstream_req = builder .body(Body::empty()) .map_err(|e| anyhow!("failed to build request: {e}"))?; ``` - `Request::get(url)` — initiates a GET request builder -- `.header(key, value)` — appends a request header +- `.header(key, value)` — appends a request header; chainable +- `mut builder` — builder is held as a mutable variable to allow conditional header addition before finalizing +- `fetch_header.split_once(':')` — splits `"Key: Value"` into `("Key", " Value")`; `.trim()` strips whitespace - `.body(Body::empty())` — finalizes with an empty body; returns `Result` - `.map_err(...)` — converts builder error into `anyhow::Error` @@ -104,6 +117,7 @@ Ok(response) | Header | Required | Default | Description | |--------|----------|---------|-------------| | `x-fetch-url` | No | `https://httpbin.org/get` | URL to fetch outbound | +| `x-fetch-header` | No | — | A single header to forward to the upstream request, in `Key: Value` format | ## Imports @@ -130,6 +144,7 @@ use wstd::http::{Client, Request, Response}; | Outbound send failure | `map_err` converts to `anyhow::Error`; propagated via `?` | | Missing `x-fetch-url` header | Falls back to `https://httpbin.org/get` | | Non-UTF-8 header value | `to_str().ok()` returns `None`; fallback default applied | +| `x-fetch-header` missing `:` separator | `split_once(':')` returns `None`; header is silently ignored | ## Build @@ -142,6 +157,7 @@ cargo component build --release ```bash curl -H "x-fetch-url: https://httpbin.org/uuid" https:/// +curl -H "x-fetch-url: https://httpbin.org/get" -H "x-fetch-header: x-custom-key: myvalue" https:/// ``` ## Source Material @@ -177,8 +193,19 @@ async fn main(request: Request) -> anyhow::Result> { println!("Fetching: {target_url}"); - let upstream_req = Request::get(&target_url) - .header("accept", "application/json") + let mut builder = Request::get(&target_url).header("accept", "application/json"); + + if let Some(fetch_header) = request + .headers() + .get("x-fetch-header") + .and_then(|v| v.to_str().ok()) + { + if let Some((key, value)) = fetch_header.split_once(':') { + builder = builder.header(key.trim(), value.trim()); + } + } + + let upstream_req = builder .body(Body::empty()) .map_err(|e| anyhow!("failed to build request: {e}"))?; @@ -201,7 +228,7 @@ async fn main(request: Request) -> anyhow::Result> { [package] name = "simple_fetch" -version = "0.1.0" +version = "0.1.1" edition = "2021" publish = false @@ -266,6 +293,7 @@ cargo build --release - http-base skeleton (base_skeleton for this feature) - outbound-fetch capability reference +- header-driven-routing capability reference - wstd crate (crates.io/crates/wstd) - WASI-HTTP interface specification (WebAssembly/wasi-http) - FastEdge-sdk-rust examples/http/wasi/ for other WASI async patterns diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/static-assets-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/static-assets-wasi-rust.md index 1eecd03..191997a 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/static-assets-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/static-assets-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/streaming-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/streaming-wasi-rust.md index 956f361..ca4dcdc 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/streaming-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/streaming-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -141,67 +141,3 @@ async fn main(_request: Request) -> anyhow::Result> { - FastEdge-sdk-rust HTTP examples (other HTTP feature blueprints) - wstd crate documentation (Runtime, Body, Timer APIs) - futures-lite crate documentation (stream combinators, unfold) - -## Source Material - -### FILE: examples/http/wasi/streaming/src/lib.rs - -```rust -/* - * Copyright 2025 G-Core Innovations SARL - */ -/* -Streaming response example. - -Generates a response body on the fly — five text chunks, one every 200ms — -using `Body::from_stream` backed by a `futures_lite::Stream`. The runtime -polls the stream as the body is sent, so chunks flow to the client as they -are produced instead of all at once at the end. - -Watch it stream with `curl -N https:///` (`-N` disables client-side -buffering). - -Mirror of the FastEdge-sdk-js `streaming` example. -*/ - -use futures_lite::stream; -use wstd::http::body::Body; -use wstd::http::{Request, Response}; -use wstd::time::{Duration, Timer}; - -#[wstd::http_server] -async fn main(_request: Request) -> anyhow::Result> { - let chunk_stream = stream::unfold(0u32, |i| async move { - if i >= 5 { - return None; - } - Timer::after(Duration::from_millis(200)).wait().await; - Some((format!("chunk {i}\n"), i + 1)) - }); - - Ok(Response::builder() - .status(200) - .header("content-type", "text/plain; charset=utf-8") - .body(Body::from_stream(chunk_stream))?) -} -``` - - -### FILE: examples/http/wasi/streaming/Cargo.toml - -```toml -[workspace] - -[package] -name = "streaming_wasi" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -anyhow = "1" -futures-lite = "1" -``` diff --git a/plugins/gcore-fastedge/skills/scaffold/reference/http/variables-and-secrets-wasi-rust.md b/plugins/gcore-fastedge/skills/scaffold/reference/http/variables-and-secrets-wasi-rust.md index 00d44d9..df8a8c7 100644 --- a/plugins/gcore-fastedge/skills/scaffold/reference/http/variables-and-secrets-wasi-rust.md +++ b/plugins/gcore-fastedge/skills/scaffold/reference/http/variables-and-secrets-wasi-rust.md @@ -3,8 +3,8 @@ sources: - id: fastedge-sdk-rust ref: main - commit: 6347a7c2fda0d03e66f1214db5eec041c16801b7 - updated: 2026-08-20 + commit: 6eedcca9d5c0ddd4ff79ca475965393891da2d75 + updated: 2026-09-22 --> --- @@ -135,89 +135,3 @@ cargo build --release - deploy skill reference (uploading the compiled `.wasm` binary) - manage skill reference (setting environment variables and secrets on an app) - FastEdge platform overview (secret storage model) - -## Source Material - -### FILE: examples/http/wasi/variables_and_secrets/src/lib.rs - -```rust -use fastedge::secret; -use std::env; -use wstd::http::body::Body; -use wstd::http::{Request, Response}; - -#[wstd::http_server] -async fn main(_request: Request) -> anyhow::Result> { - let username = env::var("USERNAME").unwrap_or_default(); - let password = match secret::get("PASSWORD") { - Ok(Some(value)) => value, - _ => String::new(), - }; - - Ok(Response::builder() - .status(200) - .body(Body::from(format!( - "Username: {username}, Password: {password}" - )))?) -} -``` - - -### FILE: examples/http/wasi/variables_and_secrets/Cargo.toml - -```toml -[workspace] - -[package] -name = "variables_and_secrets" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wstd = "0.6" -fastedge = "0.4" -anyhow = "1" -``` - - -### FILE: examples/http/wasi/variables_and_secrets/README.md - -``` -[← Back to examples](../../../README.md) - -# Variables and Secrets (WASI) - -Demonstrates reading an environment variable (`USERNAME`) and a secret (`PASSWORD`), returning both in the response body. - -Environment variables are set via the FastEdge app configuration and accessed with `std::env::var`. Secrets are stored encrypted and accessed with `fastedge::secret::get` — they are never exposed in platform logs or configuration UIs. - -## Configuration - -| Key | Type | Required | Description | -|---|---|---|---| -| `USERNAME` | Environment variable | No | Username to include in response. Empty string if unset. | -| `PASSWORD` | Secret | No | Password to include in response. Empty string if unset or unavailable. | - -## What it returns - -``` -HTTP/1.1 200 OK - -Username: , Password: -``` - -## Build - -```sh -cargo build --release -# Output: target/wasm32-wasip2/release/variables_and_secrets.wasm -``` - -## APIs used - -- `std::env::var("USERNAME").unwrap_or_default()` — read env var with fallback -- `fastedge::secret::get("PASSWORD")` — read secret by name; returns `Ok(Some(String))` on success -```