diff --git a/Makefile b/Makefile index 534e163..48ffe80 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ EXAMPLES_DIR := examples COMPUTE_WIT := wit/deps/fastly/compute.wit # Define all available examples (add new ones here) -EXAMPLES := bottle-app flask-app backend-requests game-of-life +EXAMPLES := bottle-app flask-app backend-requests game-of-life config-store # Default example for serve target EXAMPLE ?= bottle-app diff --git a/examples/config-store/config-store.py b/examples/config-store/config-store.py new file mode 100644 index 0000000..5d97929 --- /dev/null +++ b/examples/config-store/config-store.py @@ -0,0 +1,65 @@ +"""Config Store example application. + +Demonstrates Fastly Config Store usage with minimal test endpoints. +""" + +import json +import traceback +from typing import Any + +from bottle import Bottle, response + +from fastly_compute.config_store import ConfigStore +from fastly_compute.wsgi import WsgiHttpIncoming + +app = Bottle() + + +def json_response(data: dict[str, Any], status_code: int = 200) -> str: + """Create a JSON response.""" + response.content_type = "application/json" + response.status = status_code + return json.dumps(data, indent=2) + + +def handle_request(handler): + """Decorator to handle common request/response patterns.""" + + def wrapper(*args, **kwargs): + try: + result = handler(*args, **kwargs) + return json_response(result) + except Exception as e: + return json_response( + { + "error": repr(e), + "error_type": type(e).__name__, + "traceback": traceback.format_exc(), + }, + status_code=500, + ) + + return wrapper + + +@app.route("/get//") +@app.route("/get///") +@handle_request +def test_get(store_name, key, default=None): + """Proxy endpoint to issue ConfigStore gets with optional default.""" + with ConfigStore.open(store_name) as config: + value = config.get(key, default) + return {"value": value} + + +@app.route("/contains//") +@handle_request +def test_contains(store_name, key): + """Proxy endpoint to test contains.""" + config = ConfigStore.open(store_name) + contains = key in config + return {"contains": contains} + + +# Create the HTTP handler for Fastly Compute +HttpIncoming = WsgiHttpIncoming(app) diff --git a/examples/config-store/pyproject.toml b/examples/config-store/pyproject.toml new file mode 100644 index 0000000..ab88e20 --- /dev/null +++ b/examples/config-store/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "config-store" +version = "0.1.0" +description = "Fastly Compute example demonstrating Config Store usage" +requires-python = ">=3.12" +dependencies = [ + "bottle>=0.12.25", + "fastly-compute", +] + +[tool.uv.sources] +fastly-compute = { path = "../../", editable = true } + +[tool.fastly-compute] +entry = "config-store" diff --git a/examples/config-store/uv.lock b/examples/config-store/uv.lock new file mode 100644 index 0000000..6254801 --- /dev/null +++ b/examples/config-store/uv.lock @@ -0,0 +1,49 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + +[[package]] +name = "config-store" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "bottle" }, + { name = "fastly-compute" }, +] + +[package.metadata] +requires-dist = [ + { name = "bottle", specifier = ">=0.12.25" }, + { name = "fastly-compute", editable = "../../" }, +] + +[[package]] +name = "fastly-compute" +version = "0.1.0" +source = { editable = "../../" } + +[package.metadata] +requires-dist = [ + { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, + { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, + { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.0,<9.0.0" }, + { name = "requests", marker = "extra == 'test'", specifier = ">=2.32.5,<3.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.11,<0.13.0" }, + { name = "syrupy", marker = "extra == 'test'", specifier = "==5.0.0" }, + { name = "tomli-w", marker = "extra == 'test'", specifier = ">=1.0.0,<2.0.0" }, +] +provides-extras = ["test", "dev", "examples"] + +[package.metadata.requires-dev] +dev = [{ name = "maturin", specifier = ">=1.11.5" }] diff --git a/fastly_compute/config_store.py b/fastly_compute/config_store.py new file mode 100644 index 0000000..13b4390 --- /dev/null +++ b/fastly_compute/config_store.py @@ -0,0 +1,118 @@ +"""Config Store API for Fastly Compute + +This module provides access to Fastly Config Stores, which allow you to store +configuration data that can be updated without redeploying your service. + +Example:: + + from fastly_compute.config_store import ConfigStore + + with ConfigStore.open("my-config") as config: + api_url = config.get("api_url", "https://api.example.com") +""" + +from wit_world.imports import config_store as wit_config_store + +# The maximum value for a u32, used to signal that we don't want to cap +# the length of values returned by the host. In practice, this limit +# is at 8KB, though that could change. +_MAX_U32 = 0xFFFFFFFF + + +class ConfigStore: + """Interface to Fastly Config Store. + + Config Stores provide read-only access to configuration data that can be + updated without redeploying your service. + + Example:: + + with ConfigStore.open("app-config") as config: + api_url = config.get("api_url", "https://api.example.com") + """ + + def __init__(self, store: wit_config_store.Store): + """Private constructor. Use ConfigStore.open() instead.""" + self._store = store + + @classmethod + def open(cls, name: str) -> "ConfigStore": + """Open a config store by name. + + :param name: The name of the config store + :return: ConfigStore instance + :raises ~fastly_compute.exceptions.types.open_error.NotFound: If the config store doesn't exist + :raises ~fastly_compute.exceptions.types.open_error.InvalidSyntax: If the name is invalid + :raises ~fastly_compute.exceptions.types.open_error.NameTooLong: If the name is too long + + Example:: + + config = ConfigStore.open("my-config") + """ + store = wit_config_store.Store.open(name) + return cls(store) + + def get(self, key: str, default: str | None = None) -> str | None: + """Get a configuration value. + + :param key: The configuration key + :param default: Default value if key not found + :return: Configuration value or default if not found + :raises ~fastly_compute.exceptions.types.error.InvalidArgument: If the key is invalid + + Example:: + + config = ConfigStore.open("app-config") + api_url = config.get("api_url", "https://api.example.com") + """ + result = self._store.get(key, _MAX_U32) + if result is None: + result = default + + return result + + def __contains__(self, key: str) -> bool: + """Check if a key exists in the config store. + + :param key: The configuration key + :return: True if the key exists, False otherwise + :raises ~fastly_compute.exceptions.types.error.InvalidArgument: If the key is invalid + :raises KeyError: If the key is not a str + + Example:: + + config = ConfigStore.open("app-config") + if "feature_flag" in config: + print("Feature flag exists") + """ + if not isinstance(key, str): + raise KeyError("Key must be a str") + return self.get(key) is not None + + def close(self) -> None: + """Explicitly close the config store, releasing its resources. + + This is called automatically when using the config store as a context + manager. If not called explicitly, resources will eventually be freed + by the garbage collector. + + Note: Attempting to use the config store after it is closed will result + in a trap. + """ + self._store.__exit__(None, None, None) + + def __enter__(self) -> "ConfigStore": + """Context manager entry. + + Allows use of ConfigStore in a 'with' statement. + """ + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit. + + Use of the context manager will free up the underlying host resource on + exit. Referencing the resource after context manager exit will result in + a trap. + """ + self.close() diff --git a/tests/test_config_store.py b/tests/test_config_store.py new file mode 100644 index 0000000..4f740b5 --- /dev/null +++ b/tests/test_config_store.py @@ -0,0 +1,107 @@ +"""Integration tests for Config Store functionality.""" + +from fastly_compute.testing import ViceroyTestBase + + +class TestConfigStore(ViceroyTestBase): + """Config store integration tests.""" + + WASM_FILE = "build/config-store.composed.wasm" + + VICEROY_CONFIG = { + "local_server": { + "config_stores": { + "test-config": { + "format": "inline-toml", + "contents": { + "string_key": "hello world", + "empty_string": "", + "whitespace": " ", + "special_chars": "!@#$%^&*()", + "unicode": "Hello δΈ–η•Œ 🌍", + "large_value": "x" * 8000, # 8000 byte value for buffer tests + }, + } + } + } + } + + def assert_get_value(self, store: str, key: str, expected: str | None) -> None: + """Assert that getting a key returns the expected value.""" + response = self.get(f"/get/{store}/{key}") + assert response.status_code == 200 + assert response.json() == {"value": expected} + + def assert_get_value_with_default( + self, store: str, key: str, default: str, expected: str + ) -> None: + """Assert that getting a key with a default returns the expected value.""" + response = self.get(f"/get/{store}/{key}/{default}") + assert response.status_code == 200 + assert response.json() == {"value": expected} + + def assert_get_error(self, store: str, key: str, error_type: str) -> None: + """Assert that getting a key raises an error.""" + response = self.get(f"/get/{store}/{key}") + assert response.status_code == 500 + data = response.json() + assert data["error_type"] == error_type + + def test_open_nonexistent_store(self): + """Test opening a non-existent config store raises error.""" + response = self.get("/get/nonexistent/key") + assert response.status_code == 500 + data = response.json() + assert data["error_type"] == "NotFound" + + def test_get_string_value(self): + """Test getting a string value.""" + self.assert_get_value("test-config", "string_key", "hello world") + + def test_get_nonexistent_key(self): + """Test getting a nonexistent key returns None.""" + self.assert_get_value("test-config", "nonexistent", None) + + def test_get_with_default(self): + """Test getting with a default value.""" + self.assert_get_value_with_default( + "test-config", "nonexistent", "my_default", "my_default" + ) + + def test_empty_string_value(self): + """Test handling of empty string values.""" + self.assert_get_value("test-config", "empty_string", "") + + def test_whitespace_value(self): + """Test handling of whitespace values.""" + self.assert_get_value("test-config", "whitespace", " ") + + def test_special_characters(self): + """Test handling of special characters.""" + self.assert_get_value("test-config", "special_chars", "!@#$%^&*()") + + def test_unicode_support(self): + """Test handling of unicode characters.""" + self.assert_get_value("test-config", "unicode", "Hello δΈ–η•Œ 🌍") + + def test_large_values(self): + """Test that large values are handled correctly.""" + self.assert_get_value("test-config", "large_value", "x" * 8000) + + def test_contains_existing_key(self): + """Test that contains returns True for existing keys.""" + response = self.get("/contains/test-config/string_key") + assert response.status_code == 200 + assert response.json() == {"contains": True} + + def test_contains_nonexistent_key(self): + """Test that contains returns False for non-existent keys.""" + response = self.get("/contains/test-config/nonexistent") + assert response.status_code == 200 + assert response.json() == {"contains": False} + + def test_contains_empty_string_value(self): + """Test that contains returns True for keys with empty string values.""" + response = self.get("/contains/test-config/empty_string") + assert response.status_code == 200 + assert response.json() == {"contains": True}