Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions examples/config-store/config-store.py
Original file line number Diff line number Diff line change
@@ -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/<store_name>/<key>")
@app.route("/get/<store_name>/<key>/<default>")
@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/<store_name>/<key>")
@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)
15 changes: 15 additions & 0 deletions examples/config-store/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
49 changes: 49 additions & 0 deletions examples/config-store/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 118 additions & 0 deletions fastly_compute/config_store.py
Original file line number Diff line number Diff line change
@@ -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()
107 changes: 107 additions & 0 deletions tests/test_config_store.py
Original file line number Diff line number Diff line change
@@ -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}
Loading