From 6d40191fe2ceb20c5e9f0464ada1a0f75a22fd01 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 5 Sep 2025 16:12:15 +0000 Subject: [PATCH 01/13] Add early backends support with requests-style API Here we introduc two new examples; in the first we do some basic work direclty against the WIT bindings to feel out its edges and understand its behavior. In the second example, we use a newly introduced fastly_compute.requests module that, at this point, provides a synchronous interface for making backend requests that aims to be compatible with existing requests package usages (so users may be able to import our requests module as requets). A few other small changes have also been rolled into this commit. --- Makefile | 6 +- examples/backend-simple.py | 340 ++++++++++++++++++++++++++ examples/requests-simple.py | 198 +++++++++++++++ fastly_compute/requests/__init__.py | 298 ++++++++++++++++++++++ fastly_compute/requests/backend.py | 184 ++++++++++++++ fastly_compute/requests/exceptions.py | 79 ++++++ fastly_compute/requests/response.py | 220 +++++++++++++++++ fastly_compute/test_server.py | 221 +++++++++++++++++ fastly_compute/testing.py | 106 +++++++- pyproject.toml | 1 + test.toml | 10 + tests/test_backend_simple.py | 162 ++++++++++++ tests/test_requests_simple.py | 189 ++++++++++++++ uv.lock | 11 + 14 files changed, 2015 insertions(+), 10 deletions(-) create mode 100644 examples/backend-simple.py create mode 100644 examples/requests-simple.py create mode 100644 fastly_compute/requests/__init__.py create mode 100644 fastly_compute/requests/backend.py create mode 100644 fastly_compute/requests/exceptions.py create mode 100644 fastly_compute/requests/response.py create mode 100644 fastly_compute/test_server.py create mode 100644 test.toml create mode 100644 tests/test_backend_simple.py create mode 100644 tests/test_requests_simple.py diff --git a/Makefile b/Makefile index 88ab02e..7826abd 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ BUILD_DIR := build EXAMPLES_DIR := examples # Define all available examples (add new ones here) -EXAMPLES := bottle-app flask-app game-of-life +EXAMPLES := bottle-app flask-app backend-simple requests-simple game-of-life # Default example for serve target EXAMPLE ?= bottle-app @@ -17,6 +17,8 @@ WASM_FILE := $(BUILD_DIR)/$(EXAMPLE).composed.wasm TARGET_WORLD := fastly:compute/service +VICEROY ?= viceroy + # Generate WASM file paths for all examples EXAMPLE_WASMS := $(foreach example,$(EXAMPLES),$(BUILD_DIR)/$(example).wasm) @@ -54,7 +56,7 @@ serve: $(WASM_FILE) # Test all examples (requires all WASM files to be built) test: $(COMPOSED_WASMS) - uv run --extra test pytest + VICEROY=$(VICEROY) uv run --extra test pytest # List available examples list-examples: diff --git a/examples/backend-simple.py b/examples/backend-simple.py new file mode 100644 index 0000000..7aa5396 --- /dev/null +++ b/examples/backend-simple.py @@ -0,0 +1,340 @@ +""" +Backend Simple Example + +This example demonstrates using Fastly backends to make HTTP requests to external services +using the raw WIT bindings. It shows both static and dynamic backend patterns. + +Static backends are pre-configured in the viceroy configuration file. +Dynamic backends are created programmatically at runtime. +""" + +import json +from dataclasses import dataclass + +from bottle import Bottle +from wit_world.imports import backend, compute_runtime, http_body, http_req + +from fastly_compute.wsgi import WsgiHttpIncoming + + +@dataclass +class SimpleResponse: + """Simple response container for backend requests""" + + status: int + body: bytes + + +app = Bottle() + + +def make_static_backend_request(backend_name: str, path: str) -> SimpleResponse: + """ + Make a request using a static backend (pre-configured in viceroy.toml). + + Args: + backend_name: Name of the static backend from configuration + path: Path to request (e.g., "/get", "/post") + + Returns: + SimpleResponse with status and raw body bytes + + Raises: + Exception if backend doesn't exist or request fails + """ + # Check if backend exists + if not backend.exists(backend_name): + raise ValueError(f"Static backend '{backend_name}' does not exist") + + # Create a new request + request = http_req.Request.new() + + # Set request details + request.set_method("GET") + request.set_uri(path) + request.insert_header("User-Agent", b"FastlyCompute-BackendExample/1.0") + + # TODO: this shouldn't be required and is a bug in viceroy for component + # model interactions, most likely. + request.insert_header("Host", b"localhost") + + # Create empty body for GET request + body = http_body.new() + + # Send request to static backend + response, response_body = http_req.send(request, body, backend_name) + + # Read response + status = response.get_status() + + # Read response body + response_data = b"" + chunk_size = 4096 + while True: + chunk = http_body.read(response_body, chunk_size) + if not chunk: + break + response_data += chunk + + return SimpleResponse(status=status, body=response_data) + + +def make_dynamic_backend_request( + target_url: str, backend_prefix: str = "dynamic" +) -> SimpleResponse: + """ + Make a request using a dynamic backend (created at runtime). + + Args: + target_url: Full URL to request (e.g., "https://httpbin.org/get") + backend_prefix: Prefix for the dynamic backend name + + Returns: + SimpleResponse with status and raw body bytes + + Raises: + ValueError if URL is invalid + Exception if backend registration or request fails + """ + # Parse URL to get host for backend registration + if not target_url.startswith(("http://", "https://")): + raise ValueError("Dynamic backend requires full URL with scheme") + + # Extract scheme and host + scheme = "https" if target_url.startswith("https://") else "http" + url_without_scheme = target_url[len(scheme + "://") :] + host_and_path = url_without_scheme.split("/", 1) + host = host_and_path[0] + path = "/" + (host_and_path[1] if len(host_and_path) > 1 else "") + + # Create backend name (replace dots and colons for valid backend names) + backend_name = f"{backend_prefix}_{host.replace('.', '_').replace(':', '_')}" + + # Register dynamic backend if it doesn't exist + if not backend.exists(backend_name): + # Create backend options + options = http_req.DynamicBackendOptions() + + # Configure TLS if HTTPS + if scheme == "https": + options.use_tls(True) + + # Set reasonable timeouts (in milliseconds) + options.connect_timeout(30000) # 30 seconds + options.first_byte_timeout(60000) # 60 seconds + options.between_bytes_timeout(10000) # 10 seconds + + # Register the backend + http_req.register_dynamic_backend( + prefix=backend_name, target=f"{scheme}://{host}", options=options + ) + + # Create request + request = http_req.Request.new() + request.set_method("GET") + request.set_uri(path) + request.insert_header("User-Agent", b"FastlyCompute-BackendExample/1.0") + request.insert_header("Host", host.encode("utf-8")) + + # Create empty body + body = http_body.new() + + # Send request + response, response_body = http_req.send(request, body, backend_name) + + # Read response + status = response.get_status() + + # Read response body + response_data = b"" + chunk_size = 4096 + while True: + chunk = http_body.read(response_body, chunk_size) + if not chunk: + break + response_data += chunk + + return SimpleResponse(status=status, body=response_data) + + +def make_dynamic_post_request(target_url: str, post_data: dict) -> SimpleResponse: + """ + Make a POST request using a dynamic backend with JSON data. + + Args: + target_url: Full URL to POST to + post_data: Data to send as JSON + + Returns: + SimpleResponse with status and raw body bytes + + Raises: + ValueError if URL is invalid + Exception if backend registration or request fails + """ + # Parse URL similar to GET request + if not target_url.startswith(("http://", "https://")): + raise ValueError("Dynamic backend requires full URL with scheme") + + scheme = "https" if target_url.startswith("https://") else "http" + url_without_scheme = target_url[len(scheme + "://") :] + host_and_path = url_without_scheme.split("/", 1) + host = host_and_path[0] + path = "/" + (host_and_path[1] if len(host_and_path) > 1 else "") + + backend_name = f"dynamic_{host.replace('.', '_').replace(':', '_')}" + + # Register backend if needed (same as GET) + if not backend.exists(backend_name): + options = http_req.DynamicBackendOptions() + if scheme == "https": + options.use_tls(True) + options.connect_timeout(30000) + options.first_byte_timeout(60000) + options.between_bytes_timeout(10000) + + http_req.register_dynamic_backend( + prefix=backend_name, target=f"{scheme}://{host}", options=options + ) + + # Create POST request + request = http_req.Request.new() + request.set_method("POST") + request.set_uri(path) + request.insert_header("User-Agent", b"FastlyCompute-BackendExample/1.0") + request.insert_header("Host", host.encode("utf-8")) + request.insert_header("Content-Type", b"application/json") + + # Create body with JSON data + json_str = json.dumps(post_data) + json_bytes = json_str.encode("utf-8") + + body = http_body.new() + http_body.write(body, json_bytes, http_body.WriteEnd.BACK) + + # Send request + response, response_body = http_req.send(request, body, backend_name) + + # Read response + status = response.get_status() + + # Read response body + response_data = b"" + chunk_size = 4096 + while True: + chunk = http_body.read(response_body, chunk_size) + if not chunk: + break + response_data += chunk + + return SimpleResponse(status=status, body=response_data) + + +@app.route("/static") +def test_static_backend(): + """Test static backend (requires backend named 'test-be' in viceroy.toml)""" + try: + response = make_static_backend_request("test-be", "/get") + + # Try to parse response as JSON + try: + response_data = json.loads(response.body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + response_data = {"text": response.body.decode("utf-8", errors="replace")} + + return { + "backend_type": "static", + "backend_name": "test-be", + "status": response.status, + "data": response_data, + } + except Exception as e: + return {"backend_type": "static", "backend_name": "test-be", "error": str(e)} + + +@app.route("/dynamic") +def test_dynamic_backend(): + """Test dynamic backend to a public API""" + from bottle import request + + # Get target from query parameter (required) + target = request.query.get('target') + if not target: + return { + "backend_type": "dynamic", + "error": "target query parameter is required (e.g., ?target=https://httpbin.org/get)" + } + + try: + response = make_dynamic_backend_request(target) + + # Try to parse response as JSON + try: + response_data = json.loads(response.body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + response_data = {"text": response.body.decode("utf-8", errors="replace")} + + return { + "backend_type": "dynamic", + "target": target, + "status": response.status, + "data": response_data, + } + except Exception as e: + return { + "backend_type": "dynamic", + "target": target, + "error": str(e), + } + + +@app.route("/dynamic-post") +def test_dynamic_post(): + """Test dynamic backend POST""" + from bottle import request + + # Get target from query parameter (required) + target = request.query.get('target') + if not target: + return { + "backend_type": "dynamic", + "method": "POST", + "error": "target query parameter is required (e.g., ?target=https://httpbin.org/post)" + } + + vcpu_time = compute_runtime.get_vcpu_ms() + test_data = { + "message": "Hello from Fastly Compute!", + "timestamp": vcpu_time, + "test": True, + } + + try: + response = make_dynamic_post_request(target, test_data) + + # Try to parse response as JSON + try: + response_data = json.loads(response.body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + response_data = {"text": response.body.decode("utf-8", errors="replace")} + + return { + "backend_type": "dynamic", + "method": "POST", + "target": target, + "status": response.status, + "sent_data": test_data, + "data": response_data, + } + except Exception as e: + return { + "backend_type": "dynamic", + "method": "POST", + "target": target, + "error": str(e), + } + + +# Create the HTTP handler using the shared WSGI infrastructure +# Use basic environ for Bottle (doesn't need enhanced WSGI variables like Flask) +HttpIncoming = WsgiHttpIncoming(app) diff --git a/examples/requests-simple.py b/examples/requests-simple.py new file mode 100644 index 0000000..cf0d2cc --- /dev/null +++ b/examples/requests-simple.py @@ -0,0 +1,198 @@ +""" +Simple Requests Demo - Example using fastly_compute.requests with Bottle + +This example demonstrates the requests-compatible HTTP client for making +backend requests in Fastly Compute using Bottle (which has fewer dependencies than Flask). +""" + +from bottle import Bottle +from wit_world.imports import compute_runtime + +# Import Fastly Compute modules +import fastly_compute.requests as requests +from fastly_compute.wsgi import WsgiHttpIncoming + +app = Bottle() + + +@app.route("/static-get") +def static_get(): + """Demo GET request using static backend.""" + try: + # Use static backend (requires 'test-be' backend in viceroy.toml) + response = requests.get("/get", backend="test-be") + + return { + "demo": "static-get", + "backend_type": "static", + "backend_name": "test-be", + "status_code": response.status_code, + "success": response.ok, + "url": response.url, + "headers_count": len(response.headers), + "content_length": len(response.content), + "response_preview": response.text[:200] + "..." + if len(response.text) > 200 + else response.text, + } + except Exception as e: + return {"demo": "static-get", "error": str(e), "error_type": type(e).__name__} + + +@app.route("/static-post") +def static_post(): + """Demo POST request using static backend.""" + try: + # POST JSON data to static backend + post_data = { + "message": "Hello from Fastly Compute!", + "demo": "static-post", + "vcpu_time": compute_runtime.get_vcpu_ms(), + } + + response = requests.post("/post", backend="test-be", json=post_data) + + return { + "demo": "static-post", + "backend_type": "static", + "backend_name": "test-be", + "status_code": response.status_code, + "success": response.ok, + "sent_data": post_data, + "content_length": len(response.content), + "response_preview": response.text[:200] + "..." + if len(response.text) > 200 + else response.text, + } + except Exception as e: + return {"demo": "static-post", "error": str(e), "error_type": type(e).__name__} + + +@app.route("/dynamic-get") +def dynamic_get(): + """Demo GET request using dynamic backend.""" + from bottle import request + + # Get target from query parameter (required) + target = request.query.get('target') + if not target: + return { + "demo": "dynamic-get", + "error": "target query parameter is required (e.g., ?target=https://http-me.fastly.dev/get)" + } + + try: + # Make request to external service (creates dynamic backend) + response = requests.get( + target, + headers={"User-Agent": "FastlyCompute-SimpleDemo/1.0"}, + ) + + return { + "demo": "dynamic-get", + "backend_type": "dynamic", + "target_url": target, + "status_code": response.status_code, + "success": response.ok, + "url": response.url, + "headers": dict(list(response.headers.items())[:5]), # Show first 5 headers + "content_length": len(response.content), + "response_preview": response.text[:200] + "..." + if len(response.text) > 200 + else response.text, + } + except Exception as e: + return {"demo": "dynamic-get", "error": str(e), "error_type": type(e).__name__} + + +@app.route("/dynamic-post") +def dynamic_post(): + """Demo POST request using dynamic backend.""" + from bottle import request + + # Get target from query parameter (required) + target = request.query.get('target') + if not target: + return { + "demo": "dynamic-post", + "error": "target query parameter is required (e.g., ?target=https://http-me.fastly.dev/post)" + } + + try: + # POST to external service + post_data = { + "service": "fastly-compute", + "demo": "dynamic-post", + "timestamp": compute_runtime.get_vcpu_ms(), + "message": "Dynamic backend POST from Fastly Compute", + } + + response = requests.post( + target, + json=post_data, + headers={ + "User-Agent": "FastlyCompute-SimpleDemo/1.0", + "X-Demo": "fastly-compute-requests", + }, + ) + + return { + "demo": "dynamic-post", + "backend_type": "dynamic", + "target_url": target, + "status_code": response.status_code, + "success": response.ok, + "sent_data": post_data, + "content_length": len(response.content), + "response_preview": response.text[:200] + "..." + if len(response.text) > 200 + else response.text, + } + except Exception as e: + return {"demo": "dynamic-post", "error": str(e), "error_type": type(e).__name__} + + +@app.route("/error-demo") +def error_demo(): + """Demo error handling scenarios.""" + results = [] + + # Test case 1: Invalid static backend + try: + response = requests.get("/test", backend="nonexistent-backend") + results.append( + { + "test": "invalid-static-backend", + "status": "unexpected_success", + "status_code": response.status_code, + } + ) + except Exception as e: + results.append( + { + "test": "invalid-static-backend", + "status": "expected_error", + "error": str(e), + "error_type": type(e).__name__, + } + ) + + # Test case 2: Invalid URL format + try: + response = requests.get("not-a-url") + results.append({"test": "invalid-url-format", "status": "unexpected_success"}) + except Exception as e: + results.append( + { + "test": "invalid-url-format", + "status": "expected_error", + "error": str(e), + "error_type": type(e).__name__, + } + ) + + return {"demo": "error-demo", "test_results": results} + + +# Create the HTTP handler +HttpIncoming = WsgiHttpIncoming(app) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py new file mode 100644 index 0000000..139c911 --- /dev/null +++ b/fastly_compute/requests/__init__.py @@ -0,0 +1,298 @@ +""" +fastly_compute.requests - A requests-compatible HTTP client for Fastly Compute + +This module provides a familiar requests-like API while leveraging Fastly's +backend architecture and WIT bindings for optimal performance. + +Usage: + import fastly_compute.requests as requests + + # Static backend (pre-configured) + response = requests.get("/api/users", backend="api-backend") + + # Dynamic backend (external URLs) + response = requests.get("https://http-me.fastly.dev/get") + + # POST with JSON + response = requests.post("https://http-me.fastly.dev/post", + json={"key": "value"}) +""" + +import json as json_module +import urllib.parse +from typing import Any + +from .backend import BackendResolver +from .exceptions import ConnectionError, HTTPError, RequestException, Timeout +from .response import FastlyResponse + + +def get( + url: str, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + backend: str | None = None, + timeout: int | None = None, + **kwargs, +) -> FastlyResponse: + """Send a GET request. + + Args: + url: URL for the request. Can be a path (for static backends) or full URL (for dynamic) + params: Query parameters to append to the URL + headers: HTTP headers to send with the request + backend: Static backend name (optional, will use dynamic backend if not provided) + timeout: Request timeout in seconds + **kwargs: Additional arguments (for requests compatibility) + + Returns: + FastlyResponse object + + Raises: + RequestException: For general request errors + ConnectionError: For connection-related errors + Timeout: For timeout errors + """ + return request( + "GET", + url, + params=params, + headers=headers, + backend=backend, + timeout=timeout, + **kwargs, + ) + + +def post( + url: str, + data: str | bytes | dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + backend: str | None = None, + timeout: int | None = None, + **kwargs, +) -> FastlyResponse: + """Send a POST request. + + Args: + url: URL for the request + data: Form data to send in the body + json: JSON data to send in the body (mutually exclusive with data) + params: Query parameters to append to the URL + headers: HTTP headers to send with the request + backend: Static backend name (optional) + timeout: Request timeout in seconds + **kwargs: Additional arguments + + Returns: + FastlyResponse object + """ + return request( + "POST", + url, + data=data, + json=json, + params=params, + headers=headers, + backend=backend, + timeout=timeout, + **kwargs, + ) + + +def put( + url: str, + data: str | bytes | dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + **kwargs, +) -> FastlyResponse: + """Send a PUT request.""" + return request("PUT", url, data=data, json=json, **kwargs) + + +def delete(url: str, **kwargs) -> FastlyResponse: + """Send a DELETE request.""" + return request("DELETE", url, **kwargs) + + +def patch( + url: str, + data: str | bytes | dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + **kwargs, +) -> FastlyResponse: + """Send a PATCH request.""" + return request("PATCH", url, data=data, json=json, **kwargs) + + +def head(url: str, **kwargs) -> FastlyResponse: + """Send a HEAD request.""" + return request("HEAD", url, **kwargs) + + +def options(url: str, **kwargs) -> FastlyResponse: + """Send an OPTIONS request.""" + return request("OPTIONS", url, **kwargs) + + +def request( + method: str, + url: str, + params: dict[str, Any] | None = None, + data: str | bytes | dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + backend: str | None = None, + timeout: int | None = None, + **kwargs, +) -> FastlyResponse: + """Send an HTTP request. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + url: URL for the request + params: Query parameters + data: Form data for the request body + json: JSON data for the request body (mutually exclusive with data) + headers: HTTP headers + backend: Static backend name (if not provided, will use dynamic backend) + timeout: Request timeout in seconds + **kwargs: Additional arguments + + Returns: + FastlyResponse object + + Raises: + RequestException: For general request errors + ValueError: For invalid arguments + """ + # Import WIT modules + from wit_world.imports import http_body, http_req + + # Validate arguments + if data is not None and json is not None: + raise ValueError("Cannot specify both 'data' and 'json' parameters") + + # Initialize resolver + resolver = BackendResolver() + + try: + # Resolve backend and final URL + backend_name, final_url = resolver.resolve(url, backend) + + # Add query parameters if provided + if params: + # Parse existing query parameters + parsed_url = urllib.parse.urlparse(final_url) + query_params = urllib.parse.parse_qs(parsed_url.query) + + # Add new parameters + for key, value in params.items(): + if isinstance(value, list): + query_params[key] = value + else: + query_params[key] = [str(value)] + + # Rebuild URL with parameters + new_query = urllib.parse.urlencode(query_params, doseq=True) + final_url = urllib.parse.urlunparse( + ( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + parsed_url.params, + new_query, + parsed_url.fragment, + ) + ) + + # Create WIT request + wit_request = http_req.Request.new() + wit_request.set_method(method.upper()) + wit_request.set_uri(final_url) + + # Set Host header (may be required by viceroy, but let's test without it) + # TODO: Investigate if Host header is actually required by the WIT spec + # or if this is a viceroy-specific requirement + if backend is not None: + # Static backend - use localhost as host + wit_request.insert_header("Host", b"localhost") + else: + # Dynamic backend - extract host from original URL + parsed_url = urllib.parse.urlparse(url) + host = parsed_url.netloc.encode("utf-8") + wit_request.insert_header("Host", host) + + # Set default headers + wit_request.insert_header("User-Agent", b"FastlyCompute-Requests/1.0") + + # Add custom headers + if headers: + for name, value in headers.items(): + wit_request.insert_header(name, value.encode("utf-8")) + + # Prepare request body + request_body = http_body.new() + + if json is not None: + # JSON data - use the json module, not the parameter + json_str = json_module.dumps(json) if not isinstance(json, str) else json + json_bytes = json_str.encode("utf-8") + wit_request.insert_header("Content-Type", b"application/json") + http_body.write(request_body, json_bytes, http_body.WriteEnd.BACK) + + elif data is not None: + if isinstance(data, dict): + # Form data + form_data = urllib.parse.urlencode(data).encode("utf-8") + wit_request.insert_header( + "Content-Type", b"application/x-www-form-urlencoded" + ) + http_body.write(request_body, form_data, http_body.WriteEnd.BACK) + elif isinstance(data, str | bytes): + # Raw data + data_bytes = data.encode("utf-8") if isinstance(data, str) else data + http_body.write(request_body, data_bytes, http_body.WriteEnd.BACK) + else: + raise ValueError(f"Unsupported data type: {type(data)}") + + # Send request + wit_response, response_body = http_req.send( + wit_request, request_body, backend_name + ) + + # Wrap in FastlyResponse + return FastlyResponse(wit_response, response_body, final_url) + + except Exception as e: + # TODO: revisit finer-grained exception handling and top-level + # WIT exception mapping. + + # Map WIT errors to requests-compatible exceptions + error_msg = str(e).lower() + if "timeout" in error_msg: + raise Timeout(str(e)) from e + elif "connection" in error_msg or "network" in error_msg: + raise ConnectionError(str(e)) from e + else: + raise RequestException(str(e)) from e + + +# Export main API +__all__ = [ + "get", + "post", + "put", + "delete", + "patch", + "head", + "options", + "request", + "FastlyResponse", + "RequestException", + "ConnectionError", + "Timeout", + "HTTPError", +] diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py new file mode 100644 index 0000000..7fa5b78 --- /dev/null +++ b/fastly_compute/requests/backend.py @@ -0,0 +1,184 @@ +""" +Backend resolution logic for fastly_compute.requests + +Handles the logic for determining whether to use static or dynamic backends +based on URL patterns and backend availability. +""" + +import urllib.parse + +from wit_world.imports import backend as wit_backend +from wit_world.imports import http_req + + +class BackendResolver: + """Resolves backend names and URLs for requests.""" + + def __init__(self): + """Initialize the backend resolver.""" + self._dynamic_backends = set() # Track registered dynamic backends + + def resolve(self, url: str, backend: str | None = None) -> tuple[str, str]: + """Resolve backend name and final URL for a request. + + Args: + url: The URL to request (can be path-only or full URL) + backend: Optional static backend name + + Returns: + Tuple of (backend_name, final_url) + + Raises: + ValueError: If backend resolution fails + """ + # If explicit backend is provided, use static backend pattern + if backend is not None: + return self._resolve_static_backend(url, backend) + + # If URL looks like a full URL, use dynamic backend pattern + if self._is_full_url(url): + return self._resolve_dynamic_backend(url) + + # Path-only URL without explicit backend - this is an error + raise ValueError( + "Path-only URL requires explicit 'backend' parameter. " + f"Either provide backend='backend-name' or use full URL like 'https://example.com{url}'" + ) + + def _resolve_static_backend(self, url: str, backend_name: str) -> tuple[str, str]: + """Resolve a static backend request. + + Args: + url: URL (can be path-only or full URL) + backend_name: Name of the static backend + + Returns: + Tuple of (backend_name, final_url) + + Raises: + ValueError: If static backend doesn't exist + """ + # Check if backend exists + if not wit_backend.exists(backend_name): + raise ValueError(f"Static backend '{backend_name}' does not exist") + + # For static backends, we typically use path-only URLs + if self._is_full_url(url): + # Extract path from full URL for static backend + parsed = urllib.parse.urlparse(url) + final_url = parsed.path if parsed.path else "/" + if parsed.query: + final_url += "?" + parsed.query + if parsed.fragment: + final_url += "#" + parsed.fragment + else: + # Already a path, use as-is (ensure it starts with /) + final_url = url if url.startswith("/") else "/" + url + + return backend_name, final_url + + def _resolve_dynamic_backend(self, url: str) -> tuple[str, str]: + """Resolve a dynamic backend request. + + Args: + url: Full URL (must include scheme and host) + + Returns: + Tuple of (backend_name, final_url) + + Raises: + ValueError: If URL is invalid for dynamic backend + """ + if not self._is_full_url(url): + raise ValueError("Dynamic backend requires full URL with scheme and host") + + parsed = urllib.parse.urlparse(url) + + if not parsed.scheme or not parsed.netloc: + raise ValueError(f"Invalid URL for dynamic backend: {url}") + + # Generate backend name from host + host = parsed.netloc + backend_name = f"dynamic_{self._sanitize_backend_name(host)}" + + # Register dynamic backend if not already registered + if backend_name not in self._dynamic_backends: + self._register_dynamic_backend(backend_name, parsed.scheme, host) + self._dynamic_backends.add(backend_name) + + # For dynamic backends, we use the path portion as the URL + final_url = parsed.path if parsed.path else "/" + if parsed.query: + final_url += "?" + parsed.query + if parsed.fragment: + final_url += "#" + parsed.fragment + + return backend_name, final_url + + def _register_dynamic_backend( + self, backend_name: str, scheme: str, host: str + ) -> None: + """Register a new dynamic backend. + + Args: + backend_name: Name for the dynamic backend + scheme: URL scheme (http or https) + host: Target host + + Raises: + Exception: If backend registration fails + """ + # Create backend options + options = http_req.DynamicBackendOptions() + + # Configure TLS for HTTPS + if scheme == "https": + options.use_tls(True) + + # Set reasonable timeouts (in milliseconds) + options.connect_timeout(30000) # 30 seconds + options.first_byte_timeout(60000) # 60 seconds + options.between_bytes_timeout(10000) # 10 seconds + + # Register the backend + target = f"{scheme}://{host}" + http_req.register_dynamic_backend( + prefix=backend_name, target=target, options=options + ) + + def _is_full_url(self, url: str) -> bool: + """Check if URL is a full URL with scheme and netloc.""" + parsed = urllib.parse.urlparse(url) + return bool(parsed.scheme and parsed.netloc) + + def _sanitize_backend_name(self, host: str) -> str: + """Sanitize hostname for use as backend name. + + Args: + host: Hostname (may include port) + + Returns: + Sanitized backend name + """ + # Replace dots, colons, and other special chars with underscores + # Keep only alphanumeric chars and underscores + sanitized = "" + for char in host.lower(): + if char.isalnum(): + sanitized += char + elif char in ".-:": + sanitized += "_" + # Skip other special characters + + # Remove multiple consecutive underscores + while "__" in sanitized: + sanitized = sanitized.replace("__", "_") + + # Remove leading/trailing underscores + sanitized = sanitized.strip("_") + + # Ensure it's not empty + if not sanitized: + sanitized = "unknown_host" + + return sanitized diff --git a/fastly_compute/requests/exceptions.py b/fastly_compute/requests/exceptions.py new file mode 100644 index 0000000..b824146 --- /dev/null +++ b/fastly_compute/requests/exceptions.py @@ -0,0 +1,79 @@ +""" +Exceptions for fastly_compute.requests - compatible with requests library +""" + + + +class RequestException(Exception): + """Base exception for all requests-related errors.""" + + def __init__(self, message: str, response=None): + """Initialize RequestException. + + Args: + message: Error message + response: Optional response object that caused the error + """ + super().__init__(message) + self.response = response + + +class ConnectionError(RequestException): + """Exception for connection-related errors.""" + + pass + + +class Timeout(RequestException): + """Exception for timeout errors.""" + + pass + + +class HTTPError(RequestException): + """Exception for HTTP error responses (4xx, 5xx status codes).""" + + def __init__(self, message: str, response=None): + """Initialize HTTPError. + + Args: + message: Error message + response: Response object that caused the error + """ + super().__init__(message, response) + + +class TooManyRedirects(RequestException): + """Exception for too many redirects.""" + + pass + + +class InvalidURL(RequestException, ValueError): + """Exception for invalid URLs.""" + + pass + + +class InvalidHeader(RequestException, ValueError): + """Exception for invalid headers.""" + + pass + + +class ChunkedEncodingError(RequestException): + """Exception for chunked encoding errors.""" + + pass + + +class ContentDecodingError(RequestException): + """Exception for content decoding errors.""" + + pass + + +class StreamConsumedError(RequestException, TypeError): + """Exception for attempting to read a consumed stream.""" + + pass diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py new file mode 100644 index 0000000..276a846 --- /dev/null +++ b/fastly_compute/requests/response.py @@ -0,0 +1,220 @@ +""" +FastlyResponse - A requests-compatible response object for Fastly Compute +""" + +import json +from typing import Any + +from wit_world.imports import http_body + + +class FastlyResponse: + """A requests.Response-compatible response object. + + This class wraps WIT response objects to provide the familiar + requests.Response interface. + """ + + def __init__(self, wit_response, response_body, url: str): + """Initialize FastlyResponse. + + Args: + wit_response: The WIT response object + response_body: The WIT response body + url: The final URL that was requested + """ + self._wit_response = wit_response + self._response_body = response_body + self._url = url + self._content: bytes | None = None + self._text: str | None = None + self._headers: dict[str, str] | None = None + self._json_data: Any | None = None + + @property + def status_code(self) -> int: + """HTTP status code.""" + return self._wit_response.get_status() + + @property + def url(self) -> str: + """Final URL that was requested.""" + return self._url + + @property + def headers(self) -> dict[str, str]: + """Response headers as a case-insensitive dict.""" + if self._headers is None: + self._headers = {} + cursor = 0 + + # Read all headers using WIT API + while True: + try: + header_names, next_cursor = self._wit_response.get_header_names( + 4096, cursor + ) + if not header_names: + break + + # Split header names (they're null-separated) + names = header_names.split("\0")[:-1] # Remove empty last element + + for name in names: + if name: # Skip empty names + try: + value = self._wit_response.get_header_value(name, 4096) + if value: + # Convert to string and store with lowercase key for case-insensitive access + self._headers[name.lower()] = value.decode( + "utf-8", errors="replace" + ) + except Exception: + # Skip headers that can't be read + pass + + if not next_cursor: + break + cursor = next_cursor + + except Exception: + # If header reading fails, break out of loop + break + + return self._headers + + @property + def content(self) -> bytes: + """Response body as bytes.""" + if self._content is None: + self._content = self._read_body() + return self._content + + @property + def text(self) -> str: + """Response body as unicode string.""" + if self._text is None: + content = self.content + + # Try to determine encoding from headers + encoding = "utf-8" # Default encoding + content_type = self.headers.get("content-type", "") + if "charset=" in content_type: + try: + encoding = content_type.split("charset=")[1].split(";")[0].strip() + except (IndexError, ValueError): + encoding = "utf-8" + + try: + self._text = content.decode(encoding) + except UnicodeDecodeError: + # Fallback to utf-8 with error replacement + self._text = content.decode("utf-8", errors="replace") + + return self._text + + def json(self, **kwargs) -> Any: + """Parse response body as JSON. + + Args: + **kwargs: Additional arguments passed to json.loads() + + Returns: + Parsed JSON data + + Raises: + json.JSONDecodeError: If response is not valid JSON + """ + if self._json_data is None: + self._json_data = json.loads(self.text, **kwargs) + return self._json_data + + @property + def ok(self) -> bool: + """True if status code is less than 400.""" + return 200 <= self.status_code < 400 + + @property + def is_redirect(self) -> bool: + """True if status code is a redirect (3xx).""" + return 300 <= self.status_code < 400 + + @property + def is_permanent_redirect(self) -> bool: + """True if status code is a permanent redirect.""" + return self.status_code in (301, 308) + + def raise_for_status(self) -> None: + """Raise an HTTPError for bad responses. + + Raises: + HTTPError: If response status indicates an error + """ + from .exceptions import HTTPError + + if not self.ok: + raise HTTPError( + f"{self.status_code} Client Error: {self.reason} for url: {self.url}", + response=self, + ) + + @property + def reason(self) -> str: + """HTTP status reason phrase.""" + # WIT doesn't provide reason phrases, so we'll use standard ones + status_phrases = { + 200: "OK", + 201: "Created", + 202: "Accepted", + 204: "No Content", + 301: "Moved Permanently", + 302: "Found", + 304: "Not Modified", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 409: "Conflict", + 422: "Unprocessable Entity", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + } + return status_phrases.get(self.status_code, "Unknown") + + @property + def encoding(self) -> str | None: + """Response encoding.""" + content_type = self.headers.get("content-type", "") + if "charset=" in content_type: + try: + return content_type.split("charset=")[1].split(";")[0].strip() + except (IndexError, ValueError): + pass + return None + + def _read_body(self) -> bytes: + """Read the complete response body from WIT.""" + body_data = b"" + chunk_size = 4096 + + try: + while True: + chunk = http_body.read(self._response_body, chunk_size) + if not chunk: + break + body_data += chunk + except Exception: + # If reading fails, return what we have + pass + + return body_data + + def __bool__(self) -> bool: + """Boolean evaluation returns ok status.""" + return self.ok + + def __repr__(self) -> str: + """String representation of the response.""" + return f"" diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py new file mode 100644 index 0000000..55fda12 --- /dev/null +++ b/fastly_compute/test_server.py @@ -0,0 +1,221 @@ +""" +Local test server helper for backend testing. + +Provides a simple HTTP server that can act as a backend for viceroy testing. +""" + +import json +import socket +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + + +@dataclass +class LocalTestServerConfig: + """Configuration for test server""" + + host: str = "127.0.0.1" + port: int = 0 # 0 = auto-assign port + responses: dict[str, dict[str, Any]] = None + + def __post_init__(self): + if self.responses is None: + self.responses = {} + + +class TestRequestHandler(BaseHTTPRequestHandler): + """HTTP request handler for test server""" + + def do_GET(self): + """Handle GET requests""" + self._handle_request("GET") + + def do_POST(self): + """Handle POST requests""" + self._handle_request("POST") + + def do_PUT(self): + """Handle PUT requests""" + self._handle_request("PUT") + + def do_DELETE(self): + """Handle DELETE requests""" + self._handle_request("DELETE") + + def _handle_request(self, method: str): + """Generic request handler""" + # Parse request + parsed_url = urlparse(self.path) + path = parsed_url.path + query_params = parse_qs(parsed_url.query) + + # Read request body for POST/PUT + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) if content_length > 0 else b"" + + # Check if we have a configured response for this path + responses = getattr(self.server, "responses", {}) + if path in responses: + configured_response = responses[path] + status = configured_response.get("status", 200) + headers = configured_response.get("headers", {}) + response_body = configured_response.get("body", {}) + else: + # Default httpbin-like response + status = 200 + headers = {"Content-Type": "application/json"} + + # Create httpbin-like response + request_headers = dict(self.headers) + + response_body = { + "args": { + k: v[0] if len(v) == 1 else v for k, v in query_params.items() + }, + "headers": request_headers, + "origin": self.client_address[0], + "url": f"http://{self.headers.get('Host', 'localhost')}{self.path}", + "method": method, + "path": path, + } + + # Add body data for POST/PUT + if body: + try: + # Try to parse as JSON + json_data = json.loads(body.decode("utf-8")) + response_body["json"] = json_data + except (json.JSONDecodeError, UnicodeDecodeError): + # Store as raw data + response_body["data"] = body.decode("utf-8", errors="replace") + + # Send response + self.send_response(status) + + # Set headers + if isinstance(headers, dict): + for header_name, header_value in headers.items(): + self.send_header(header_name, header_value) + + # Default content-type if not set + if "Content-Type" not in headers: + self.send_header("Content-Type", "application/json") + + self.end_headers() + + # Send body + if isinstance(response_body, dict | list): + response_json = json.dumps(response_body, indent=2) + self.wfile.write(response_json.encode("utf-8")) + else: + self.wfile.write(str(response_body).encode("utf-8")) + + def log_message(self, format, *args): + """Override to reduce log noise in tests""" + pass + + +class LocalTestServer: + """Local HTTP server for backend testing""" + + def __init__(self, config: LocalTestServerConfig | None = None): + self.config = config or LocalTestServerConfig() + self.server: HTTPServer | None = None + self.thread: threading.Thread | None = None + self._running = False + + def start(self) -> str: + """ + Start the test server. + + Returns: + The base URL of the started server (e.g., "http://127.0.0.1:12345") + """ + if self._running: + raise RuntimeError("Server is already running") + + # Create server + self.server = HTTPServer( + (self.config.host, self.config.port), TestRequestHandler + ) + + # Set responses on server for handler access + self.server.responses = self.config.responses + + # Get actual port (important when port=0 for auto-assignment) + actual_port = self.server.server_address[1] + base_url = f"http://{self.config.host}:{actual_port}" + + # Start server in background thread + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self._running = True + + # Wait a bit for server to be ready + time.sleep(0.1) + + return base_url + + def stop(self): + """Stop the test server""" + if not self._running: + return + + if self.server: + self.server.shutdown() + self.server.server_close() + + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=1.0) + + self._running = False + + def __enter__(self): + """Context manager entry""" + return self.start() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit""" + self.stop() + + @property + def base_url(self) -> str: + """Get the base URL of the running server""" + if not self._running or not self.server: + raise RuntimeError("Server is not running") + + host, port = self.server.server_address + return f"http://{host}:{port}" + + +def find_free_port() -> int: + """Find a free port on localhost""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + port = s.getsockname()[1] + return port + + +# Convenience functions for common test server patterns +def create_httpbin_server(host: str = "127.0.0.1", port: int = 0) -> LocalTestServer: + """Create a server that mimics httpbin.org behavior""" + config = LocalTestServerConfig(host=host, port=port) + return LocalTestServer(config) + + +def create_mock_server( + responses: dict[str, dict[str, Any]], host: str = "127.0.0.1", port: int = 0 +) -> LocalTestServer: + """ + Create a server with predefined responses. + + Args: + responses: Dict mapping paths to response configs + e.g., {"/api/test": {"status": 200, "body": {"success": True}}} + """ + config = LocalTestServerConfig(host=host, port=port, responses=responses) + return LocalTestServer(config) diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index d8cd07d..e38299e 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -8,8 +8,10 @@ pytest_plugins = ["fastly_compute.pytest_plugin"] """ +import os import socket import subprocess +import tempfile import threading import time from dataclasses import dataclass @@ -18,6 +20,7 @@ import pytest import requests +import tomli_w @dataclass @@ -54,6 +57,10 @@ def test_my_endpoint(self): WASM_FILE = "build/bottle-app.composed.wasm" # Default to the main example server: ViceroyServer = None # Will be set by the fixture + # Configuration for backend testing + VICEROY_CONFIG = None # Dict with viceroy config, or None for no config + _config_file_path = None # Will store temp config file path + @staticmethod def _find_free_port() -> int: """Find an available port on localhost.""" @@ -62,6 +69,68 @@ def _find_free_port() -> int: port = s.getsockname()[1] return port + @classmethod + def _create_viceroy_config(cls, backends: dict[str, str] | None = None) -> str: + """ + Create a temporary viceroy configuration file. + + Args: + backends: Dict mapping backend names to URLs + e.g., {"httpbin": "http://127.0.0.1:8080"} + + Returns: + Path to the temporary configuration file + """ + config_dict = {} + + # Add backends if provided + if backends: + config_dict["local_server"] = { + "backends": {name: {"url": url} for name, url in backends.items()} + } + + # Add any additional config from class + if cls.VICEROY_CONFIG: + # Merge with class config + if "local_server" in config_dict and "local_server" in cls.VICEROY_CONFIG: + # Merge local_server sections + for key, value in cls.VICEROY_CONFIG["local_server"].items(): + if key == "backends" and "backends" in config_dict["local_server"]: + # Merge backends + config_dict["local_server"]["backends"].update(value) + else: + config_dict["local_server"][key] = value + else: + # Add other sections + config_dict.update(cls.VICEROY_CONFIG) + + # Generate TOML content + toml_content = tomli_w.dumps(config_dict) + + # Create temporary file + fd, temp_path = tempfile.mkstemp(suffix=".toml", prefix="viceroy_config_") + try: + with os.fdopen(fd, "w") as f: + f.write(toml_content) + except: + os.close(fd) # Close if write failed + raise + + return temp_path + + @classmethod + def setup_backends(cls, backends: dict[str, str]): + """ + Set up backends for testing. + + Call this in setUpClass or as a class-level setup. + + Args: + backends: Dict mapping backend names to URLs + e.g., {"httpbin": "http://127.0.0.1:8080"} + """ + cls._test_backends = backends + @pytest.fixture(scope="class", autouse=True) @classmethod def viceroy_server(cls) -> ViceroyServer: @@ -89,16 +158,29 @@ def viceroy_server(cls) -> ViceroyServer: output_lock = threading.Lock() stop_capture = threading.Event() + # Create config file if needed + config_file_path = None + if hasattr(cls, "_test_backends") or cls.VICEROY_CONFIG: + # Get backends from test setup (if any) + backends = getattr(cls, "_test_backends", None) + config_file_path = cls._create_viceroy_config(backends) + cls._config_file_path = config_file_path + + # Build viceroy command + viceroy_cmd = [ + os.getenv("VICEROY", "viceroy"), + "serve", + cls.WASM_FILE, + "--addr", + f"127.0.0.1:{port}", + "-v", + ] + if config_file_path: + viceroy_cmd.extend(["-C", config_file_path]) + # Start viceroy process process = subprocess.Popen( - [ - environ.get("VICEROY", "viceroy"), - "serve", - cls.WASM_FILE, - "--addr", - f"127.0.0.1:{port}", - "-v", - ], + viceroy_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, @@ -171,6 +253,14 @@ def capture_output_thread(): process.kill() process.wait() + # Clean up config file if we created one + if cls._config_file_path and os.path.exists(cls._config_file_path): + try: + os.unlink(cls._config_file_path) + except OSError: + pass # Ignore cleanup errors + cls._config_file_path = None + def get(self, path: str, **kwargs) -> requests.Response: """Make a GET request to the viceroy server. diff --git a/pyproject.toml b/pyproject.toml index 8617b33..21a9327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ test = [ "pytest (>=8.4.0,<9.0.0)", "requests (>=2.32.5,<3.0.0)", + "tomli-w (>=1.0.0,<2.0.0)", ] dev = [ "ruff (>=0.12.11,<0.13.0)", diff --git a/test.toml b/test.toml new file mode 100644 index 0000000..27c472a --- /dev/null +++ b/test.toml @@ -0,0 +1,10 @@ +# Fastly service configuration +name = "my-compute-service" +description = "Example Fastly Compute service" +authors = ["paul.osborne@fastly.com"] +language = "python" + +[local_server] +[local_server.backends] +[local_server.backends.httpbin] +url = "http://httpbin.org/" diff --git a/tests/test_backend_simple.py b/tests/test_backend_simple.py new file mode 100644 index 0000000..e39baf5 --- /dev/null +++ b/tests/test_backend_simple.py @@ -0,0 +1,162 @@ +"""Tests for backend-simple.py example with local server backends.""" + +import pytest + +from fastly_compute.test_server import LocalTestServer, LocalTestServerConfig +from fastly_compute.testing import ViceroyTestBase + + +class TestBackendSimple(ViceroyTestBase): + """Integration tests for backend-simple.py with local backends.""" + + WASM_FILE = "build/backend-simple.composed.wasm" + + @classmethod + def setup_class(cls): + """Set up local test servers and configure backends.""" + # Create a local test server that mimics httpbin + cls.test_server = LocalTestServer( + LocalTestServerConfig(host="127.0.0.1", port=0) + ) + cls.test_server_url = cls.test_server.start() + + # Configure the backend for viceroy + cls.setup_backends({"test-be": cls.test_server_url}) + + @classmethod + def teardown_class(cls): + """Clean up test servers.""" + if hasattr(cls, "test_server"): + cls.test_server.stop() + + def test_static_backend_request(self): + """Test static backend request to local test server.""" + response = self.get("/static") + assert response.status_code == 200 + + data = response.json() + assert data["backend_type"] == "static" + assert data["backend_name"] == "test-be" + assert data["status"] == 200 + + # Check that we got httpbin-like response data + response_data = data["data"] + assert "url" in response_data + assert "headers" in response_data + assert "method" in response_data + assert response_data["method"] == "GET" + + def test_dynamic_backend_request(self): + """Test dynamic backend request (should work without static backend config).""" + response = self.get("/dynamic?target=https://http-me.fastly.dev/get") + assert response.status_code == 200 + + data = response.json() + assert data["backend_type"] == "dynamic" + assert data["target"] == "https://http-me.fastly.dev/get" + + # This might fail if external http-me.fastly.dev is not accessible + # But the test should at least show our code handling the dynamic backend + if "error" in data: + # External request failed - verify error handling + assert "error" in data + else: + # External request succeeded + assert data["status"] == 200 + + def test_dynamic_backend_no_target(self): + """Test dynamic backend request without target parameter.""" + response = self.get("/dynamic") + assert response.status_code == 200 + + data = response.json() + assert data["backend_type"] == "dynamic" + assert "error" in data + assert "target query parameter is required" in data["error"] + + def test_dynamic_post_request(self): + """Test dynamic backend POST request.""" + response = self.get("/dynamic-post?target=https://http-me.fastly.dev/post") + assert response.status_code == 200 + + data = response.json() + assert data["backend_type"] == "dynamic" + assert data["method"] == "POST" + assert data["target"] == "https://http-me.fastly.dev/post" + + # Similar to GET test - external dependency + if "error" in data: + # External request failed - verify error handling + assert "error" in data + else: + # External request succeeded + assert "sent_data" in data + assert data["sent_data"]["test"] is True + + def test_dynamic_post_no_target(self): + """Test dynamic backend POST request without target parameter.""" + response = self.get("/dynamic-post") + assert response.status_code == 200 + + data = response.json() + assert data["backend_type"] == "dynamic" + assert data["method"] == "POST" + assert "error" in data + assert "target query parameter is required" in data["error"] + + +class TestBackendSimpleWithMockResponses(ViceroyTestBase): + """Test backend-simple.py with controlled mock responses.""" + + WASM_FILE = "build/backend-simple.composed.wasm" + + @classmethod + def setup_class(cls): + """Set up mock server with predefined responses.""" + # Create mock responses that match what http-me.fastly.dev would return + mock_responses = { + "/get": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": { + "args": {}, + "headers": {"User-Agent": "FastlyCompute-BackendExample/1.0"}, + "origin": "127.0.0.1", + "url": "http://localhost/get", + "method": "GET", + "path": "/get", + }, + } + } + + # Start mock server + config = LocalTestServerConfig( + host="127.0.0.1", port=0, responses=mock_responses + ) + cls.mock_server = LocalTestServer(config) + cls.mock_server_url = cls.mock_server.start() + + # Configure backend + cls.setup_backends({"test-be": cls.mock_server_url}) + + @classmethod + def teardown_class(cls): + """Clean up mock server.""" + if hasattr(cls, "mock_server"): + cls.mock_server.stop() + + def test_static_backend_with_mock_response(self): + """Test static backend with controlled mock response.""" + response = self.get("/static") + assert response.status_code == 200 + + data = response.json() + assert data["backend_type"] == "static" + assert data["backend_name"] == "test-be" + assert data["status"] == 200 + + # Check the mock response data + response_data = data["data"] + assert response_data["method"] == "GET" + assert response_data["path"] == "/get" + assert response_data["url"] == "http://localhost/get" diff --git a/tests/test_requests_simple.py b/tests/test_requests_simple.py new file mode 100644 index 0000000..f743c0d --- /dev/null +++ b/tests/test_requests_simple.py @@ -0,0 +1,189 @@ +"""Tests for the requests-simple example application.""" + +import pytest + +from fastly_compute.test_server import LocalTestServer, LocalTestServerConfig +from fastly_compute.testing import ViceroyTestBase + + +class TestRequestsSimple(ViceroyTestBase): + """Integration tests for the requests-simple example.""" + + WASM_FILE = "build/requests-simple.composed.wasm" + + @classmethod + def setup_class(cls): + """Set up local test server for httpbin backend.""" + # Create httpbin-like responses + mock_responses = { + "/get": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": { + "args": {}, + "headers": { + "User-Agent": "FastlyCompute-Requests/1.0", + "Host": "localhost", + }, + "method": "GET", + "origin": "127.0.0.1", + "url": "http://localhost/get", + }, + }, + "/post": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": { + "args": {}, + "data": "", + "files": {}, + "form": {}, + "headers": { + "User-Agent": "FastlyCompute-Requests/1.0", + "Content-Type": "application/json", + }, + "json": {}, # Will be populated with actual request data + "method": "POST", + "origin": "127.0.0.1", + "url": "http://localhost/post", + }, + }, + } + + # Set up mock server + config = LocalTestServerConfig( + host="127.0.0.1", port=0, responses=mock_responses + ) + cls.test_server = LocalTestServer(config) + cls.test_server_url = cls.test_server.start() + + # Configure test-be backend for static backend tests + cls.setup_backends({"test-be": cls.test_server_url}) + + @classmethod + def teardown_class(cls): + """Clean up test server.""" + if hasattr(cls, "test_server"): + cls.test_server.stop() + + def test_static_get_request(self): + """Test static backend GET request.""" + response = self.get("/static-get") + assert response.status_code == 200 + + data = response.json() + assert data["demo"] == "static-get" + assert data["backend_type"] == "static" + assert data["backend_name"] == "test-be" + assert data["status_code"] == 200 + assert data["success"] is True + assert "url" in data + assert "content_length" in data + + def test_static_post_request(self): + """Test static backend POST request.""" + response = self.get("/static-post") + assert response.status_code == 200 + + data = response.json() + + if "error" in data: + # Request failed - check error handling + assert data["demo"] == "static-post" + assert "error_type" in data + else: + # Request succeeded + assert data["demo"] == "static-post" + assert data["backend_type"] == "static" + assert data["backend_name"] == "test-be" + assert data["status_code"] == 200 + assert data["success"] is True + + # Check that post data was sent + assert "sent_data" in data + sent_data = data["sent_data"] + assert sent_data["message"] == "Hello from Fastly Compute!" + assert sent_data["demo"] == "static-post" + + def test_dynamic_get_request(self): + """Test dynamic backend GET request.""" + response = self.get("/dynamic-get?target=https://http-me.fastly.dev/get") + assert response.status_code == 200 + + data = response.json() + assert data["demo"] == "dynamic-get" + + # This test might fail if external http-me.fastly.dev is not accessible + if "error" in data: + # External request failed - verify error handling + assert "error" in data + assert "error_type" in data + else: + # External request succeeded + assert data["backend_type"] == "dynamic" + assert data["target_url"] == "https://http-me.fastly.dev/get" + assert data["status_code"] == 200 + assert data["success"] is True + assert "url" in data + assert "headers" in data + + def test_dynamic_get_no_target(self): + """Test dynamic backend GET request without target parameter.""" + response = self.get("/dynamic-get") + assert response.status_code == 200 + + data = response.json() + assert data["demo"] == "dynamic-get" + assert "error" in data + assert "target query parameter is required" in data["error"] + + def test_dynamic_post_request(self): + """Test dynamic backend POST request.""" + response = self.get("/dynamic-post?target=https://http-me.fastly.dev/post") + assert response.status_code == 200 + + data = response.json() + assert data["demo"] == "dynamic-post" + + # External dependency - should handle gracefully + if "error" in data: + assert "error" in data + assert "error_type" in data + else: + assert data["backend_type"] == "dynamic" + assert data["target_url"] == "https://http-me.fastly.dev/post" + assert "sent_data" in data + sent_data = data["sent_data"] + assert sent_data["service"] == "fastly-compute" + assert sent_data["demo"] == "dynamic-post" + + def test_dynamic_post_no_target(self): + """Test dynamic backend POST request without target parameter.""" + response = self.get("/dynamic-post") + assert response.status_code == 200 + + data = response.json() + assert data["demo"] == "dynamic-post" + assert "error" in data + assert "target query parameter is required" in data["error"] + + def test_error_handling(self): + """Test error handling scenarios.""" + response = self.get("/error-demo") + assert response.status_code == 200 + + data = response.json() + assert data["demo"] == "error-demo" + assert "test_results" in data + + # Should have at least 2 test cases + test_results = data["test_results"] + assert len(test_results) >= 2 + + # Check that errors are properly caught and reported + for result in test_results: + assert "test" in result + assert "status" in result + if result["status"] == "expected_error": + assert "error" in result + assert "error_type" in result diff --git a/uv.lock b/uv.lock index 9eaa403..836f59e 100644 --- a/uv.lock +++ b/uv.lock @@ -137,6 +137,7 @@ dev = [ test = [ { name = "pytest" }, { name = "requests" }, + { name = "tomli-w" }, ] [package.metadata] @@ -147,6 +148,7 @@ requires-dist = [ { 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 = "tomli-w", marker = "extra == 'test'", specifier = ">=1.0.0,<2.0.0" }, ] provides-extras = ["test", "dev"] @@ -353,6 +355,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" From 1a2b03f6a6c0898f7f99f830f0e818e0da4be9a9 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 24 Oct 2025 21:50:25 +0000 Subject: [PATCH 02/13] Add ruff lints for docs and fix warnings --- examples/backend-simple.py | 28 +++++++--------- examples/requests-simple.py | 11 +++---- fastly_compute/requests/__init__.py | 5 ++- fastly_compute/requests/backend.py | 3 +- fastly_compute/requests/exceptions.py | 5 +-- fastly_compute/requests/response.py | 4 +-- fastly_compute/test_server.py | 46 +++++++++++++-------------- fastly_compute/testing.py | 6 ++-- fastly_compute/wsgi.py | 1 + main.py | 6 ---- pyproject.toml | 9 ++++++ tests/test_backend_simple.py | 2 -- tests/test_requests_simple.py | 2 -- 13 files changed, 57 insertions(+), 71 deletions(-) delete mode 100644 main.py diff --git a/examples/backend-simple.py b/examples/backend-simple.py index 7aa5396..a1f629c 100644 --- a/examples/backend-simple.py +++ b/examples/backend-simple.py @@ -1,5 +1,4 @@ -""" -Backend Simple Example +"""Simple backend example. This example demonstrates using Fastly backends to make HTTP requests to external services using the raw WIT bindings. It shows both static and dynamic backend patterns. @@ -19,7 +18,7 @@ @dataclass class SimpleResponse: - """Simple response container for backend requests""" + """Simple response container for backend requests.""" status: int body: bytes @@ -29,8 +28,7 @@ class SimpleResponse: def make_static_backend_request(backend_name: str, path: str) -> SimpleResponse: - """ - Make a request using a static backend (pre-configured in viceroy.toml). + """Make a request using a static backend (pre-configured in viceroy.toml). Args: backend_name: Name of the static backend from configuration @@ -82,8 +80,7 @@ def make_static_backend_request(backend_name: str, path: str) -> SimpleResponse: def make_dynamic_backend_request( target_url: str, backend_prefix: str = "dynamic" ) -> SimpleResponse: - """ - Make a request using a dynamic backend (created at runtime). + """Make a request using a dynamic backend (created at runtime). Args: target_url: Full URL to request (e.g., "https://httpbin.org/get") @@ -158,8 +155,7 @@ def make_dynamic_backend_request( def make_dynamic_post_request(target_url: str, post_data: dict) -> SimpleResponse: - """ - Make a POST request using a dynamic backend with JSON data. + """Make a POST request using a dynamic backend with JSON data. Args: target_url: Full URL to POST to @@ -232,7 +228,7 @@ def make_dynamic_post_request(target_url: str, post_data: dict) -> SimpleRespons @app.route("/static") def test_static_backend(): - """Test static backend (requires backend named 'test-be' in viceroy.toml)""" + """Test static backend (requires backend named 'test-be' in viceroy.toml).""" try: response = make_static_backend_request("test-be", "/get") @@ -254,15 +250,15 @@ def test_static_backend(): @app.route("/dynamic") def test_dynamic_backend(): - """Test dynamic backend to a public API""" + """Test dynamic backend to a public API.""" from bottle import request # Get target from query parameter (required) - target = request.query.get('target') + target = request.query.get("target") if not target: return { "backend_type": "dynamic", - "error": "target query parameter is required (e.g., ?target=https://httpbin.org/get)" + "error": "target query parameter is required (e.g., ?target=https://httpbin.org/get)", } try: @@ -290,16 +286,16 @@ def test_dynamic_backend(): @app.route("/dynamic-post") def test_dynamic_post(): - """Test dynamic backend POST""" + """Test dynamic backend POST.""" from bottle import request # Get target from query parameter (required) - target = request.query.get('target') + target = request.query.get("target") if not target: return { "backend_type": "dynamic", "method": "POST", - "error": "target query parameter is required (e.g., ?target=https://httpbin.org/post)" + "error": "target query parameter is required (e.g., ?target=https://httpbin.org/post)", } vcpu_time = compute_runtime.get_vcpu_ms() diff --git a/examples/requests-simple.py b/examples/requests-simple.py index cf0d2cc..295d970 100644 --- a/examples/requests-simple.py +++ b/examples/requests-simple.py @@ -1,5 +1,4 @@ -""" -Simple Requests Demo - Example using fastly_compute.requests with Bottle +"""Simple Requests Demo - Example using fastly_compute.requests with Bottle This example demonstrates the requests-compatible HTTP client for making backend requests in Fastly Compute using Bottle (which has fewer dependencies than Flask). @@ -74,11 +73,11 @@ def dynamic_get(): from bottle import request # Get target from query parameter (required) - target = request.query.get('target') + target = request.query.get("target") if not target: return { "demo": "dynamic-get", - "error": "target query parameter is required (e.g., ?target=https://http-me.fastly.dev/get)" + "error": "target query parameter is required (e.g., ?target=https://http-me.fastly.dev/get)", } try: @@ -111,11 +110,11 @@ def dynamic_post(): from bottle import request # Get target from query parameter (required) - target = request.query.get('target') + target = request.query.get("target") if not target: return { "demo": "dynamic-post", - "error": "target query parameter is required (e.g., ?target=https://http-me.fastly.dev/post)" + "error": "target query parameter is required (e.g., ?target=https://http-me.fastly.dev/post)", } try: diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index 139c911..1ac52bb 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -1,5 +1,4 @@ -""" -fastly_compute.requests - A requests-compatible HTTP client for Fastly Compute +"""A requests-compatible HTTP client for Fastly Compute. This module provides a familiar requests-like API while leveraging Fastly's backend architecture and WIT bindings for optimal performance. @@ -217,7 +216,7 @@ def request( # TODO: Investigate if Host header is actually required by the WIT spec # or if this is a viceroy-specific requirement if backend is not None: - # Static backend - use localhost as host + # Static backend - use localhost as host wit_request.insert_header("Host", b"localhost") else: # Dynamic backend - extract host from original URL diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index 7fa5b78..e94d417 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -1,5 +1,4 @@ -""" -Backend resolution logic for fastly_compute.requests +"""Backend resolution logic for fastly_compute.requests. Handles the logic for determining whether to use static or dynamic backends based on URL patterns and backend availability. diff --git a/fastly_compute/requests/exceptions.py b/fastly_compute/requests/exceptions.py index b824146..21bef31 100644 --- a/fastly_compute/requests/exceptions.py +++ b/fastly_compute/requests/exceptions.py @@ -1,7 +1,4 @@ -""" -Exceptions for fastly_compute.requests - compatible with requests library -""" - +"""Exceptions for fastly_compute.requests - compatible with requests library.""" class RequestException(Exception): diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py index 276a846..b9559bb 100644 --- a/fastly_compute/requests/response.py +++ b/fastly_compute/requests/response.py @@ -1,6 +1,4 @@ -""" -FastlyResponse - A requests-compatible response object for Fastly Compute -""" +"""A requests-compatible response object for Fastly Compute.""" import json from typing import Any diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py index 55fda12..d3512f3 100644 --- a/fastly_compute/test_server.py +++ b/fastly_compute/test_server.py @@ -1,5 +1,4 @@ -""" -Local test server helper for backend testing. +"""Local test server helper for backend testing. Provides a simple HTTP server that can act as a backend for viceroy testing. """ @@ -16,38 +15,39 @@ @dataclass class LocalTestServerConfig: - """Configuration for test server""" + """Configuration for test server.""" host: str = "127.0.0.1" port: int = 0 # 0 = auto-assign port responses: dict[str, dict[str, Any]] = None def __post_init__(self): + """Initialize responses to empty dict if not provided.""" if self.responses is None: self.responses = {} class TestRequestHandler(BaseHTTPRequestHandler): - """HTTP request handler for test server""" + """HTTP request handler for test server.""" def do_GET(self): - """Handle GET requests""" + """Handle GET requests.""" self._handle_request("GET") def do_POST(self): - """Handle POST requests""" + """Handle POST requests.""" self._handle_request("POST") def do_PUT(self): - """Handle PUT requests""" + """Handle PUT requests.""" self._handle_request("PUT") def do_DELETE(self): - """Handle DELETE requests""" + """Handle DELETE requests.""" self._handle_request("DELETE") def _handle_request(self, method: str): - """Generic request handler""" + """Generic request handler.""" # Parse request parsed_url = urlparse(self.path) path = parsed_url.path @@ -115,22 +115,22 @@ def _handle_request(self, method: str): self.wfile.write(str(response_body).encode("utf-8")) def log_message(self, format, *args): - """Override to reduce log noise in tests""" + """Override to reduce log noise in tests.""" pass class LocalTestServer: - """Local HTTP server for backend testing""" + """Local HTTP server for backend testing.""" def __init__(self, config: LocalTestServerConfig | None = None): + """Construct a new test server.""" self.config = config or LocalTestServerConfig() self.server: HTTPServer | None = None self.thread: threading.Thread | None = None self._running = False def start(self) -> str: - """ - Start the test server. + """Start the test server. Returns: The base URL of the started server (e.g., "http://127.0.0.1:12345") @@ -161,7 +161,7 @@ def start(self) -> str: return base_url def stop(self): - """Stop the test server""" + """Stop the test server.""" if not self._running: return @@ -175,16 +175,16 @@ def stop(self): self._running = False def __enter__(self): - """Context manager entry""" + """Context manager entry.""" return self.start() def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit""" + """Context manager exit.""" self.stop() @property def base_url(self) -> str: - """Get the base URL of the running server""" + """Get the base URL of the running server.""" if not self._running or not self.server: raise RuntimeError("Server is not running") @@ -193,7 +193,7 @@ def base_url(self) -> str: def find_free_port() -> int: - """Find a free port on localhost""" + """Find a free port on localhost.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) port = s.getsockname()[1] @@ -202,7 +202,7 @@ def find_free_port() -> int: # Convenience functions for common test server patterns def create_httpbin_server(host: str = "127.0.0.1", port: int = 0) -> LocalTestServer: - """Create a server that mimics httpbin.org behavior""" + """Create a server that mimics httpbin.org behavior.""" config = LocalTestServerConfig(host=host, port=port) return LocalTestServer(config) @@ -210,12 +210,12 @@ def create_httpbin_server(host: str = "127.0.0.1", port: int = 0) -> LocalTestSe def create_mock_server( responses: dict[str, dict[str, Any]], host: str = "127.0.0.1", port: int = 0 ) -> LocalTestServer: - """ - Create a server with predefined responses. + """Create a server with predefined responses. Args: - responses: Dict mapping paths to response configs - e.g., {"/api/test": {"status": 200, "body": {"success": True}}} + responses: Dict mapping paths to response configs. + host: The host to bind to. + port: The port to bind to. """ config = LocalTestServerConfig(host=host, port=port, responses=responses) return LocalTestServer(config) diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index e38299e..5f0e26f 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -71,8 +71,7 @@ def _find_free_port() -> int: @classmethod def _create_viceroy_config(cls, backends: dict[str, str] | None = None) -> str: - """ - Create a temporary viceroy configuration file. + """Create a temporary viceroy configuration file. Args: backends: Dict mapping backend names to URLs @@ -120,8 +119,7 @@ def _create_viceroy_config(cls, backends: dict[str, str] | None = None) -> str: @classmethod def setup_backends(cls, backends: dict[str, str]): - """ - Set up backends for testing. + """Set up backends for testing. Call this in setUpClass or as a class-level setup. diff --git a/fastly_compute/wsgi.py b/fastly_compute/wsgi.py index 13717fd..7dcdc7f 100644 --- a/fastly_compute/wsgi.py +++ b/fastly_compute/wsgi.py @@ -154,6 +154,7 @@ def __init__( self.reuse_sandboxes_for_ms = reuse_sandboxes_for_ms def __call__(self): + """Return self to make the instance callable.""" return self def handle(self, request: Any, body: Any) -> None: diff --git a/main.py b/main.py deleted file mode 100644 index af8030a..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from compute-sdk-python!") - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 21a9327..d0002d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions "UP", # pyupgrade + "D", # docstrings ] ignore = [ "E501", # line too long, handled by formatter @@ -56,6 +57,14 @@ indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" +[tool.ruff.lint.pydocstyle] +convention = "google" + +# Ignore doc lints for tests/examples +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["D"] +"examples/*" = ["D"] + [build-system] requires = [ "setuptools (>=80.9.0,<81.0.0)", diff --git a/tests/test_backend_simple.py b/tests/test_backend_simple.py index e39baf5..e28e721 100644 --- a/tests/test_backend_simple.py +++ b/tests/test_backend_simple.py @@ -1,7 +1,5 @@ """Tests for backend-simple.py example with local server backends.""" -import pytest - from fastly_compute.test_server import LocalTestServer, LocalTestServerConfig from fastly_compute.testing import ViceroyTestBase diff --git a/tests/test_requests_simple.py b/tests/test_requests_simple.py index f743c0d..17c9a65 100644 --- a/tests/test_requests_simple.py +++ b/tests/test_requests_simple.py @@ -1,7 +1,5 @@ """Tests for the requests-simple example application.""" -import pytest - from fastly_compute.test_server import LocalTestServer, LocalTestServerConfig from fastly_compute.testing import ViceroyTestBase From affebd899d7cdfbe35a1100469b284a03fa62ef7 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 18 Nov 2025 17:48:10 -0600 Subject: [PATCH 03/13] Clean up code style and formatting issues - Remove blank lines and pass statements from exception classes - Fix Google-style docstring format by adding colons after exception classes - Remove nested imports, moving them to top-level imports - Remove boilerplate Returns sections that just repeat type annotations - Add notes about ignored kwargs parameters for requests compatibility - Remove unused pytest.mark.integration decorators and unused pytest imports - Fix integration marker configuration in pytest settings --- examples/backend-simple.py | 8 ++++---- fastly_compute/requests/__init__.py | 20 +++++--------------- fastly_compute/requests/exceptions.py | 16 ---------------- fastly_compute/requests/response.py | 4 ++-- fastly_compute/test_server.py | 1 - 5 files changed, 11 insertions(+), 38 deletions(-) diff --git a/examples/backend-simple.py b/examples/backend-simple.py index a1f629c..a1fc24a 100644 --- a/examples/backend-simple.py +++ b/examples/backend-simple.py @@ -90,8 +90,8 @@ def make_dynamic_backend_request( SimpleResponse with status and raw body bytes Raises: - ValueError if URL is invalid - Exception if backend registration or request fails + ValueError: If URL is invalid + Exception: If backend registration or request fails """ # Parse URL to get host for backend registration if not target_url.startswith(("http://", "https://")): @@ -165,8 +165,8 @@ def make_dynamic_post_request(target_url: str, post_data: dict) -> SimpleRespons SimpleResponse with status and raw body bytes Raises: - ValueError if URL is invalid - Exception if backend registration or request fails + ValueError: If URL is invalid + Exception: If backend registration or request fails """ # Parse URL similar to GET request if not target_url.startswith(("http://", "https://")): diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index 1ac52bb..0eb0011 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -21,6 +21,8 @@ import urllib.parse from typing import Any +from wit_world.imports import http_body, http_req + from .backend import BackendResolver from .exceptions import ConnectionError, HTTPError, RequestException, Timeout from .response import FastlyResponse @@ -42,10 +44,7 @@ def get( headers: HTTP headers to send with the request backend: Static backend name (optional, will use dynamic backend if not provided) timeout: Request timeout in seconds - **kwargs: Additional arguments (for requests compatibility) - - Returns: - FastlyResponse object + **kwargs: Additional arguments (for requests compatibility, ignored) Raises: RequestException: For general request errors @@ -83,10 +82,7 @@ def post( headers: HTTP headers to send with the request backend: Static backend name (optional) timeout: Request timeout in seconds - **kwargs: Additional arguments - - Returns: - FastlyResponse object + **kwargs: Additional arguments (for requests compatibility, ignored) """ return request( "POST", @@ -158,18 +154,12 @@ def request( headers: HTTP headers backend: Static backend name (if not provided, will use dynamic backend) timeout: Request timeout in seconds - **kwargs: Additional arguments - - Returns: - FastlyResponse object + **kwargs: Additional arguments (for requests compatibility, ignored) Raises: RequestException: For general request errors ValueError: For invalid arguments """ - # Import WIT modules - from wit_world.imports import http_body, http_req - # Validate arguments if data is not None and json is not None: raise ValueError("Cannot specify both 'data' and 'json' parameters") diff --git a/fastly_compute/requests/exceptions.py b/fastly_compute/requests/exceptions.py index 21bef31..f2978c4 100644 --- a/fastly_compute/requests/exceptions.py +++ b/fastly_compute/requests/exceptions.py @@ -18,14 +18,10 @@ def __init__(self, message: str, response=None): class ConnectionError(RequestException): """Exception for connection-related errors.""" - pass - class Timeout(RequestException): """Exception for timeout errors.""" - pass - class HTTPError(RequestException): """Exception for HTTP error responses (4xx, 5xx status codes).""" @@ -43,34 +39,22 @@ def __init__(self, message: str, response=None): class TooManyRedirects(RequestException): """Exception for too many redirects.""" - pass - class InvalidURL(RequestException, ValueError): """Exception for invalid URLs.""" - pass - class InvalidHeader(RequestException, ValueError): """Exception for invalid headers.""" - pass - class ChunkedEncodingError(RequestException): """Exception for chunked encoding errors.""" - pass - class ContentDecodingError(RequestException): """Exception for content decoding errors.""" - pass - class StreamConsumedError(RequestException, TypeError): """Exception for attempting to read a consumed stream.""" - - pass diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py index b9559bb..e99a1d6 100644 --- a/fastly_compute/requests/response.py +++ b/fastly_compute/requests/response.py @@ -5,6 +5,8 @@ from wit_world.imports import http_body +from .exceptions import HTTPError + class FastlyResponse: """A requests.Response-compatible response object. @@ -148,8 +150,6 @@ def raise_for_status(self) -> None: Raises: HTTPError: If response status indicates an error """ - from .exceptions import HTTPError - if not self.ok: raise HTTPError( f"{self.status_code} Client Error: {self.reason} for url: {self.url}", diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py index d3512f3..624c2ad 100644 --- a/fastly_compute/test_server.py +++ b/fastly_compute/test_server.py @@ -116,7 +116,6 @@ def _handle_request(self, method: str): def log_message(self, format, *args): """Override to reduce log noise in tests.""" - pass class LocalTestServer: From f9f6591b0a180ba2c5c85b9261a27353d6b02ee2 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 18 Nov 2025 18:17:28 -0600 Subject: [PATCH 04/13] Improve requests library compatibility - Make RequestException subclass IOError instead of Exception for better requests compatibility - Add self.request attribute to RequestException and HTTPError constructors - Reverse tuple order in BackendResolver.resolve() to (final_url, backend_name) for consistency with parameter order (url, backend) --- fastly_compute/requests/__init__.py | 2 +- fastly_compute/requests/backend.py | 10 +++++----- fastly_compute/requests/exceptions.py | 11 +++++++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index 0eb0011..4347cc9 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -169,7 +169,7 @@ def request( try: # Resolve backend and final URL - backend_name, final_url = resolver.resolve(url, backend) + final_url, backend_name = resolver.resolve(url, backend) # Add query parameters if provided if params: diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index e94d417..0c62f11 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -25,7 +25,7 @@ def resolve(self, url: str, backend: str | None = None) -> tuple[str, str]: backend: Optional static backend name Returns: - Tuple of (backend_name, final_url) + Tuple of (final_url, backend_name) Raises: ValueError: If backend resolution fails @@ -52,7 +52,7 @@ def _resolve_static_backend(self, url: str, backend_name: str) -> tuple[str, str backend_name: Name of the static backend Returns: - Tuple of (backend_name, final_url) + Tuple of (final_url, backend_name) Raises: ValueError: If static backend doesn't exist @@ -74,7 +74,7 @@ def _resolve_static_backend(self, url: str, backend_name: str) -> tuple[str, str # Already a path, use as-is (ensure it starts with /) final_url = url if url.startswith("/") else "/" + url - return backend_name, final_url + return final_url, backend_name def _resolve_dynamic_backend(self, url: str) -> tuple[str, str]: """Resolve a dynamic backend request. @@ -83,7 +83,7 @@ def _resolve_dynamic_backend(self, url: str) -> tuple[str, str]: url: Full URL (must include scheme and host) Returns: - Tuple of (backend_name, final_url) + Tuple of (final_url, backend_name) Raises: ValueError: If URL is invalid for dynamic backend @@ -112,7 +112,7 @@ def _resolve_dynamic_backend(self, url: str) -> tuple[str, str]: if parsed.fragment: final_url += "#" + parsed.fragment - return backend_name, final_url + return final_url, backend_name def _register_dynamic_backend( self, backend_name: str, scheme: str, host: str diff --git a/fastly_compute/requests/exceptions.py b/fastly_compute/requests/exceptions.py index f2978c4..cdd08a6 100644 --- a/fastly_compute/requests/exceptions.py +++ b/fastly_compute/requests/exceptions.py @@ -1,18 +1,20 @@ """Exceptions for fastly_compute.requests - compatible with requests library.""" -class RequestException(Exception): +class RequestException(IOError): """Base exception for all requests-related errors.""" - def __init__(self, message: str, response=None): + def __init__(self, message: str, response=None, request=None): """Initialize RequestException. Args: message: Error message response: Optional response object that caused the error + request: Optional request object that caused the error """ super().__init__(message) self.response = response + self.request = request class ConnectionError(RequestException): @@ -26,14 +28,15 @@ class Timeout(RequestException): class HTTPError(RequestException): """Exception for HTTP error responses (4xx, 5xx status codes).""" - def __init__(self, message: str, response=None): + def __init__(self, message: str, response=None, request=None): """Initialize HTTPError. Args: message: Error message response: Response object that caused the error + request: Request object that caused the error """ - super().__init__(message, response) + super().__init__(message, response, request) class TooManyRedirects(RequestException): From dac511d27e900d8b5dfa084d8eacff56e80b6f0b Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 18 Nov 2025 18:32:05 -0600 Subject: [PATCH 05/13] Factor out common patterns and reduce code duplication - Create shared read_response_body utility to eliminate duplicate response reading logic - Factor out charset parsing into _parse_charset helper method - Remove duplicate find_free_port function from test_server.py - Factor out dynamic backend registration logic into _register_dynamic_backend helper - Simplify make_dynamic_post_request by using shared backend registration code --- examples/backend-simple.py | 140 +++++++++++++--------------- fastly_compute/requests/response.py | 36 ++----- fastly_compute/test_server.py | 9 -- fastly_compute/utils.py | 28 ++++++ 4 files changed, 101 insertions(+), 112 deletions(-) create mode 100644 fastly_compute/utils.py diff --git a/examples/backend-simple.py b/examples/backend-simple.py index a1fc24a..53beeed 100644 --- a/examples/backend-simple.py +++ b/examples/backend-simple.py @@ -13,6 +13,7 @@ from bottle import Bottle from wit_world.imports import backend, compute_runtime, http_body, http_req +from fastly_compute.utils import read_response_body from fastly_compute.wsgi import WsgiHttpIncoming @@ -27,6 +28,59 @@ class SimpleResponse: app = Bottle() +def _register_dynamic_backend( + target_url: str, backend_prefix: str = "dynamic" +) -> tuple[str, str, str, str]: + """ + Parse URL and register dynamic backend if needed. + + Args: + target_url: Full URL to parse and register backend for + backend_prefix: Prefix for the backend name + + Returns: + Tuple of (scheme, host, path, backend_name) + + Raises: + ValueError: If URL is invalid + Exception: If backend registration fails + """ + # Parse URL to get host for backend registration + if not target_url.startswith(("http://", "https://")): + raise ValueError("Dynamic backend requires full URL with scheme") + + # Extract scheme and host + scheme = "https" if target_url.startswith("https://") else "http" + url_without_scheme = target_url[len(scheme + "://") :] + host_and_path = url_without_scheme.split("/", 1) + host = host_and_path[0] + path = "/" + (host_and_path[1] if len(host_and_path) > 1 else "") + + # Create backend name (replace dots and colons for valid backend names) + backend_name = f"{backend_prefix}_{host.replace('.', '_').replace(':', '_')}" + + # Register dynamic backend if it doesn't exist + if not backend.exists(backend_name): + # Create backend options + options = http_req.DynamicBackendOptions() + + # Configure TLS if HTTPS + if scheme == "https": + options.use_tls(True) + + # Set reasonable timeouts (in milliseconds) + options.connect_timeout(30000) # 30 seconds + options.first_byte_timeout(60000) # 60 seconds + options.between_bytes_timeout(10000) # 10 seconds + + # Register the backend + http_req.register_dynamic_backend( + prefix=backend_name, target=f"{scheme}://{host}", options=options + ) + + return scheme, host, path, backend_name + + def make_static_backend_request(backend_name: str, path: str) -> SimpleResponse: """Make a request using a static backend (pre-configured in viceroy.toml). @@ -66,13 +120,7 @@ def make_static_backend_request(backend_name: str, path: str) -> SimpleResponse: status = response.get_status() # Read response body - response_data = b"" - chunk_size = 4096 - while True: - chunk = http_body.read(response_body, chunk_size) - if not chunk: - break - response_data += chunk + response_data = read_response_body(response_body) return SimpleResponse(status=status, body=response_data) @@ -93,38 +141,10 @@ def make_dynamic_backend_request( ValueError: If URL is invalid Exception: If backend registration or request fails """ - # Parse URL to get host for backend registration - if not target_url.startswith(("http://", "https://")): - raise ValueError("Dynamic backend requires full URL with scheme") - - # Extract scheme and host - scheme = "https" if target_url.startswith("https://") else "http" - url_without_scheme = target_url[len(scheme + "://") :] - host_and_path = url_without_scheme.split("/", 1) - host = host_and_path[0] - path = "/" + (host_and_path[1] if len(host_and_path) > 1 else "") - - # Create backend name (replace dots and colons for valid backend names) - backend_name = f"{backend_prefix}_{host.replace('.', '_').replace(':', '_')}" - - # Register dynamic backend if it doesn't exist - if not backend.exists(backend_name): - # Create backend options - options = http_req.DynamicBackendOptions() - - # Configure TLS if HTTPS - if scheme == "https": - options.use_tls(True) - - # Set reasonable timeouts (in milliseconds) - options.connect_timeout(30000) # 30 seconds - options.first_byte_timeout(60000) # 60 seconds - options.between_bytes_timeout(10000) # 10 seconds - - # Register the backend - http_req.register_dynamic_backend( - prefix=backend_name, target=f"{scheme}://{host}", options=options - ) + # Parse URL and register backend + scheme, host, path, backend_name = _register_dynamic_backend( + target_url, backend_prefix + ) # Create request request = http_req.Request.new() @@ -143,13 +163,7 @@ def make_dynamic_backend_request( status = response.get_status() # Read response body - response_data = b"" - chunk_size = 4096 - while True: - chunk = http_body.read(response_body, chunk_size) - if not chunk: - break - response_data += chunk + response_data = read_response_body(response_body) return SimpleResponse(status=status, body=response_data) @@ -168,30 +182,8 @@ def make_dynamic_post_request(target_url: str, post_data: dict) -> SimpleRespons ValueError: If URL is invalid Exception: If backend registration or request fails """ - # Parse URL similar to GET request - if not target_url.startswith(("http://", "https://")): - raise ValueError("Dynamic backend requires full URL with scheme") - - scheme = "https" if target_url.startswith("https://") else "http" - url_without_scheme = target_url[len(scheme + "://") :] - host_and_path = url_without_scheme.split("/", 1) - host = host_and_path[0] - path = "/" + (host_and_path[1] if len(host_and_path) > 1 else "") - - backend_name = f"dynamic_{host.replace('.', '_').replace(':', '_')}" - - # Register backend if needed (same as GET) - if not backend.exists(backend_name): - options = http_req.DynamicBackendOptions() - if scheme == "https": - options.use_tls(True) - options.connect_timeout(30000) - options.first_byte_timeout(60000) - options.between_bytes_timeout(10000) - - http_req.register_dynamic_backend( - prefix=backend_name, target=f"{scheme}://{host}", options=options - ) + # Parse URL and register backend + scheme, host, path, backend_name = _register_dynamic_backend(target_url) # Create POST request request = http_req.Request.new() @@ -215,13 +207,7 @@ def make_dynamic_post_request(target_url: str, post_data: dict) -> SimpleRespons status = response.get_status() # Read response body - response_data = b"" - chunk_size = 4096 - while True: - chunk = http_body.read(response_body, chunk_size) - if not chunk: - break - response_data += chunk + response_data = read_response_body(response_body) return SimpleResponse(status=status, body=response_data) diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py index e99a1d6..534cdfc 100644 --- a/fastly_compute/requests/response.py +++ b/fastly_compute/requests/response.py @@ -3,8 +3,7 @@ import json from typing import Any -from wit_world.imports import http_body - +from ..utils import read_response_body from .exceptions import HTTPError @@ -97,13 +96,7 @@ def text(self) -> str: content = self.content # Try to determine encoding from headers - encoding = "utf-8" # Default encoding - content_type = self.headers.get("content-type", "") - if "charset=" in content_type: - try: - encoding = content_type.split("charset=")[1].split(";")[0].strip() - except (IndexError, ValueError): - encoding = "utf-8" + encoding = self._parse_charset() or "utf-8" try: self._text = content.decode(encoding) @@ -181,9 +174,8 @@ def reason(self) -> str: } return status_phrases.get(self.status_code, "Unknown") - @property - def encoding(self) -> str | None: - """Response encoding.""" + def _parse_charset(self) -> str | None: + """Parse charset from Content-Type header.""" content_type = self.headers.get("content-type", "") if "charset=" in content_type: try: @@ -192,22 +184,14 @@ def encoding(self) -> str | None: pass return None + @property + def encoding(self) -> str | None: + """Response encoding.""" + return self._parse_charset() + def _read_body(self) -> bytes: """Read the complete response body from WIT.""" - body_data = b"" - chunk_size = 4096 - - try: - while True: - chunk = http_body.read(self._response_body, chunk_size) - if not chunk: - break - body_data += chunk - except Exception: - # If reading fails, return what we have - pass - - return body_data + return read_response_body(self._response_body) def __bool__(self) -> bool: """Boolean evaluation returns ok status.""" diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py index 624c2ad..47d84fd 100644 --- a/fastly_compute/test_server.py +++ b/fastly_compute/test_server.py @@ -4,7 +4,6 @@ """ import json -import socket import threading import time from dataclasses import dataclass @@ -191,14 +190,6 @@ def base_url(self) -> str: return f"http://{host}:{port}" -def find_free_port() -> int: - """Find a free port on localhost.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - port = s.getsockname()[1] - return port - - # Convenience functions for common test server patterns def create_httpbin_server(host: str = "127.0.0.1", port: int = 0) -> LocalTestServer: """Create a server that mimics httpbin.org behavior.""" diff --git a/fastly_compute/utils.py b/fastly_compute/utils.py new file mode 100644 index 0000000..3bc45f5 --- /dev/null +++ b/fastly_compute/utils.py @@ -0,0 +1,28 @@ +"""Utility functions for fastly_compute package.""" + +from wit_world.imports import http_body + + +def read_response_body(response_body, chunk_size: int = 4096) -> bytes: + """Read the complete response body from a WIT response body object. + + Args: + response_body: WIT response body object to read from + chunk_size: Size of chunks to read at a time (default: 4096) + + Returns: + Complete response body as bytes + """ + body_data = b"" + + try: + while True: + chunk = http_body.read(response_body, chunk_size) + if not chunk: + break + body_data += chunk + except Exception: + # If reading fails, return what we have so far + pass + + return body_data From 34e33d0adf49ea3a8d6f12042afe28e11cff4673 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 18 Nov 2025 18:35:28 -0600 Subject: [PATCH 06/13] Improve URL handling and parsing - Replace manual URL parsing with stdlib urllib.parse for better reliability - Use urlparse() instead of string manipulation for scheme/host/path extraction - Add proper URL validation for scheme and netloc requirements --- examples/backend-simple.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/backend-simple.py b/examples/backend-simple.py index 53beeed..443326d 100644 --- a/examples/backend-simple.py +++ b/examples/backend-simple.py @@ -9,6 +9,7 @@ import json from dataclasses import dataclass +from urllib.parse import urlparse from bottle import Bottle from wit_world.imports import backend, compute_runtime, http_body, http_req @@ -45,16 +46,15 @@ def _register_dynamic_backend( ValueError: If URL is invalid Exception: If backend registration fails """ - # Parse URL to get host for backend registration - if not target_url.startswith(("http://", "https://")): - raise ValueError("Dynamic backend requires full URL with scheme") - - # Extract scheme and host - scheme = "https" if target_url.startswith("https://") else "http" - url_without_scheme = target_url[len(scheme + "://") :] - host_and_path = url_without_scheme.split("/", 1) - host = host_and_path[0] - path = "/" + (host_and_path[1] if len(host_and_path) > 1 else "") + # Parse URL using stdlib urllib.parse + parsed = urlparse(target_url) + + if not parsed.scheme or not parsed.netloc: + raise ValueError("Dynamic backend requires full URL with scheme and host") + + scheme = parsed.scheme + host = parsed.netloc + path = parsed.path or "/" # Create backend name (replace dots and colons for valid backend names) backend_name = f"{backend_prefix}_{host.replace('.', '_').replace(':', '_')}" From ddb05915dea338ddfbbb0083796350b8aae917b1 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 18 Nov 2025 19:20:00 -0600 Subject: [PATCH 07/13] Add documentation and type improvements - Add type hint set[str] for _dynamic_backends field - Improve docstring for _resolve_static_backend method with clearer explanation - Add better docstring for __call__ method explaining WSGI callability requirements --- fastly_compute/requests/backend.py | 7 +++++-- fastly_compute/wsgi.py | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index 0c62f11..766077c 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -15,7 +15,7 @@ class BackendResolver: def __init__(self): """Initialize the backend resolver.""" - self._dynamic_backends = set() # Track registered dynamic backends + self._dynamic_backends: set[str] = set() # Track registered dynamic backends def resolve(self, url: str, backend: str | None = None) -> tuple[str, str]: """Resolve backend name and final URL for a request. @@ -47,12 +47,15 @@ def resolve(self, url: str, backend: str | None = None) -> tuple[str, str]: def _resolve_static_backend(self, url: str, backend_name: str) -> tuple[str, str]: """Resolve a static backend request. + Given a backend name, ensure that backend exists, and turn the URL into a + path-only one if it is not already. + Args: url: URL (can be path-only or full URL) backend_name: Name of the static backend Returns: - Tuple of (final_url, backend_name) + Tuple of (final_url, backend_name) where final_url is always a path-only URL Raises: ValueError: If static backend doesn't exist diff --git a/fastly_compute/wsgi.py b/fastly_compute/wsgi.py index 7dcdc7f..29af805 100644 --- a/fastly_compute/wsgi.py +++ b/fastly_compute/wsgi.py @@ -154,7 +154,12 @@ def __init__( self.reuse_sandboxes_for_ms = reuse_sandboxes_for_ms def __call__(self): - """Return self to make the instance callable.""" + """Return self to make the instance callable. + + This method makes the instance callable, which is required by the WSGI + specification. WSGI expects the application to be a callable that returns + itself when invoked without arguments. + """ return self def handle(self, request: Any, body: Any) -> None: From 3326f714f80a2e3b229dbf79577118a933a517ca Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 18 Nov 2025 19:38:56 -0600 Subject: [PATCH 08/13] Implement test infrastructure improvements - Remove LocalTestServerConfig class and use direct parameters in LocalTestServer - Update LocalTestServer constructor to take host, port, and responses directly - Replace _running flag with server None checks for cleaner state management - Update set_up_backends method name for PEP8 compliance - Update all test files to use new LocalTestServer interface - Add comprehensive documentation for LocalTestServer parameters These changes improve the test server API by removing unnecessary abstraction and making the interface more direct and intuitive. --- fastly_compute/test_server.py | 91 ++++++++++++++--------------------- fastly_compute/testing.py | 3 +- tests/test_backend_simple.py | 19 +++----- tests/test_requests_simple.py | 10 ++-- 4 files changed, 48 insertions(+), 75 deletions(-) diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py index 47d84fd..4685b54 100644 --- a/fastly_compute/test_server.py +++ b/fastly_compute/test_server.py @@ -6,26 +6,11 @@ import json import threading import time -from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any from urllib.parse import parse_qs, urlparse -@dataclass -class LocalTestServerConfig: - """Configuration for test server.""" - - host: str = "127.0.0.1" - port: int = 0 # 0 = auto-assign port - responses: dict[str, dict[str, Any]] = None - - def __post_init__(self): - """Initialize responses to empty dict if not provided.""" - if self.responses is None: - self.responses = {} - - class TestRequestHandler(BaseHTTPRequestHandler): """HTTP request handler for test server.""" @@ -118,14 +103,35 @@ def log_message(self, format, *args): class LocalTestServer: - """Local HTTP server for backend testing.""" + """Local HTTP server for backend testing. - def __init__(self, config: LocalTestServerConfig | None = None): - """Construct a new test server.""" - self.config = config or LocalTestServerConfig() + This server can be used to mock external backends during testing. + It supports both httpbin-style behavior and custom response patterns. + """ + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 0, + responses: dict[str, dict[str, Any]] | None = None, + ): + """Initialize the test server. + + Args: + host: The host interface to bind to (default: "127.0.0.1") + port: The port to bind to (default: 0 for auto-assignment) + responses: Optional dict mapping paths to response configs. + Each response config can contain: + - "status": HTTP status code (default: 200) + - "headers": Dict of HTTP headers + - "body": Response body (dict will be JSON-encoded) + Example: {"/api/test": {"status": 200, "body": {"success": True}}} + """ + self.host = host + self.port = port + self.responses = responses or {} self.server: HTTPServer | None = None self.thread: threading.Thread | None = None - self._running = False def start(self) -> str: """Start the test server. @@ -133,25 +139,22 @@ def start(self) -> str: Returns: The base URL of the started server (e.g., "http://127.0.0.1:12345") """ - if self._running: + if self.server is not None: raise RuntimeError("Server is already running") # Create server - self.server = HTTPServer( - (self.config.host, self.config.port), TestRequestHandler - ) + self.server = HTTPServer((self.host, self.port), TestRequestHandler) # Set responses on server for handler access - self.server.responses = self.config.responses + self.server.responses = self.responses # Get actual port (important when port=0 for auto-assignment) actual_port = self.server.server_address[1] - base_url = f"http://{self.config.host}:{actual_port}" + base_url = f"http://{self.host}:{actual_port}" # Start server in background thread self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() - self._running = True # Wait a bit for server to be ready time.sleep(0.1) @@ -160,17 +163,16 @@ def start(self) -> str: def stop(self): """Stop the test server.""" - if not self._running: + if self.server is None: return - if self.server: - self.server.shutdown() - self.server.server_close() + self.server.shutdown() + self.server.server_close() + self.server = None if self.thread and self.thread.is_alive(): self.thread.join(timeout=1.0) - - self._running = False + self.thread = None def __enter__(self): """Context manager entry.""" @@ -183,29 +185,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def base_url(self) -> str: """Get the base URL of the running server.""" - if not self._running or not self.server: + if self.server is None: raise RuntimeError("Server is not running") host, port = self.server.server_address return f"http://{host}:{port}" - - -# Convenience functions for common test server patterns -def create_httpbin_server(host: str = "127.0.0.1", port: int = 0) -> LocalTestServer: - """Create a server that mimics httpbin.org behavior.""" - config = LocalTestServerConfig(host=host, port=port) - return LocalTestServer(config) - - -def create_mock_server( - responses: dict[str, dict[str, Any]], host: str = "127.0.0.1", port: int = 0 -) -> LocalTestServer: - """Create a server with predefined responses. - - Args: - responses: Dict mapping paths to response configs. - host: The host to bind to. - port: The port to bind to. - """ - config = LocalTestServerConfig(host=host, port=port, responses=responses) - return LocalTestServer(config) diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index 5f0e26f..73b6375 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -15,7 +15,6 @@ import threading import time from dataclasses import dataclass -from os import environ from pathlib import Path import pytest @@ -118,7 +117,7 @@ def _create_viceroy_config(cls, backends: dict[str, str] | None = None) -> str: return temp_path @classmethod - def setup_backends(cls, backends: dict[str, str]): + def set_up_backends(cls, backends: dict[str, str]): """Set up backends for testing. Call this in setUpClass or as a class-level setup. diff --git a/tests/test_backend_simple.py b/tests/test_backend_simple.py index e28e721..5485543 100644 --- a/tests/test_backend_simple.py +++ b/tests/test_backend_simple.py @@ -1,6 +1,6 @@ """Tests for backend-simple.py example with local server backends.""" -from fastly_compute.test_server import LocalTestServer, LocalTestServerConfig +from fastly_compute.test_server import LocalTestServer from fastly_compute.testing import ViceroyTestBase @@ -13,19 +13,16 @@ class TestBackendSimple(ViceroyTestBase): def setup_class(cls): """Set up local test servers and configure backends.""" # Create a local test server that mimics httpbin - cls.test_server = LocalTestServer( - LocalTestServerConfig(host="127.0.0.1", port=0) - ) + cls.test_server = LocalTestServer(host="127.0.0.1", port=0) cls.test_server_url = cls.test_server.start() # Configure the backend for viceroy - cls.setup_backends({"test-be": cls.test_server_url}) + cls.set_up_backends({"test-be": cls.test_server_url}) @classmethod def teardown_class(cls): """Clean up test servers.""" - if hasattr(cls, "test_server"): - cls.test_server.stop() + cls.test_server.stop() def test_static_backend_request(self): """Test static backend request to local test server.""" @@ -128,20 +125,18 @@ def setup_class(cls): } # Start mock server - config = LocalTestServerConfig( + cls.mock_server = LocalTestServer( host="127.0.0.1", port=0, responses=mock_responses ) - cls.mock_server = LocalTestServer(config) cls.mock_server_url = cls.mock_server.start() # Configure backend - cls.setup_backends({"test-be": cls.mock_server_url}) + cls.set_up_backends({"test-be": cls.mock_server_url}) @classmethod def teardown_class(cls): """Clean up mock server.""" - if hasattr(cls, "mock_server"): - cls.mock_server.stop() + cls.mock_server.stop() def test_static_backend_with_mock_response(self): """Test static backend with controlled mock response.""" diff --git a/tests/test_requests_simple.py b/tests/test_requests_simple.py index 17c9a65..eebaad0 100644 --- a/tests/test_requests_simple.py +++ b/tests/test_requests_simple.py @@ -1,6 +1,6 @@ """Tests for the requests-simple example application.""" -from fastly_compute.test_server import LocalTestServer, LocalTestServerConfig +from fastly_compute.test_server import LocalTestServer from fastly_compute.testing import ViceroyTestBase @@ -49,20 +49,18 @@ def setup_class(cls): } # Set up mock server - config = LocalTestServerConfig( + cls.test_server = LocalTestServer( host="127.0.0.1", port=0, responses=mock_responses ) - cls.test_server = LocalTestServer(config) cls.test_server_url = cls.test_server.start() # Configure test-be backend for static backend tests - cls.setup_backends({"test-be": cls.test_server_url}) + cls.set_up_backends({"test-be": cls.test_server_url}) @classmethod def teardown_class(cls): """Clean up test server.""" - if hasattr(cls, "test_server"): - cls.test_server.stop() + cls.test_server.stop() def test_static_get_request(self): """Test static backend GET request.""" From be4ea7e5c76665d6d8dbcc5b605d5ae235aa909b Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 19 Nov 2025 11:03:24 -0600 Subject: [PATCH 09/13] Remove backend-simple example This was mostly a building block towards getting the requests support. Remove for now to reduce maint. burden, duplication, confusion. --- Makefile | 2 +- examples/backend-simple.py | 322 ----------------------------------- tests/test_backend_simple.py | 155 ----------------- 3 files changed, 1 insertion(+), 478 deletions(-) delete mode 100644 examples/backend-simple.py delete mode 100644 tests/test_backend_simple.py diff --git a/Makefile b/Makefile index 7826abd..a341b28 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ BUILD_DIR := build EXAMPLES_DIR := examples # Define all available examples (add new ones here) -EXAMPLES := bottle-app flask-app backend-simple requests-simple game-of-life +EXAMPLES := bottle-app flask-app requests-simple game-of-life # Default example for serve target EXAMPLE ?= bottle-app diff --git a/examples/backend-simple.py b/examples/backend-simple.py deleted file mode 100644 index 443326d..0000000 --- a/examples/backend-simple.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Simple backend example. - -This example demonstrates using Fastly backends to make HTTP requests to external services -using the raw WIT bindings. It shows both static and dynamic backend patterns. - -Static backends are pre-configured in the viceroy configuration file. -Dynamic backends are created programmatically at runtime. -""" - -import json -from dataclasses import dataclass -from urllib.parse import urlparse - -from bottle import Bottle -from wit_world.imports import backend, compute_runtime, http_body, http_req - -from fastly_compute.utils import read_response_body -from fastly_compute.wsgi import WsgiHttpIncoming - - -@dataclass -class SimpleResponse: - """Simple response container for backend requests.""" - - status: int - body: bytes - - -app = Bottle() - - -def _register_dynamic_backend( - target_url: str, backend_prefix: str = "dynamic" -) -> tuple[str, str, str, str]: - """ - Parse URL and register dynamic backend if needed. - - Args: - target_url: Full URL to parse and register backend for - backend_prefix: Prefix for the backend name - - Returns: - Tuple of (scheme, host, path, backend_name) - - Raises: - ValueError: If URL is invalid - Exception: If backend registration fails - """ - # Parse URL using stdlib urllib.parse - parsed = urlparse(target_url) - - if not parsed.scheme or not parsed.netloc: - raise ValueError("Dynamic backend requires full URL with scheme and host") - - scheme = parsed.scheme - host = parsed.netloc - path = parsed.path or "/" - - # Create backend name (replace dots and colons for valid backend names) - backend_name = f"{backend_prefix}_{host.replace('.', '_').replace(':', '_')}" - - # Register dynamic backend if it doesn't exist - if not backend.exists(backend_name): - # Create backend options - options = http_req.DynamicBackendOptions() - - # Configure TLS if HTTPS - if scheme == "https": - options.use_tls(True) - - # Set reasonable timeouts (in milliseconds) - options.connect_timeout(30000) # 30 seconds - options.first_byte_timeout(60000) # 60 seconds - options.between_bytes_timeout(10000) # 10 seconds - - # Register the backend - http_req.register_dynamic_backend( - prefix=backend_name, target=f"{scheme}://{host}", options=options - ) - - return scheme, host, path, backend_name - - -def make_static_backend_request(backend_name: str, path: str) -> SimpleResponse: - """Make a request using a static backend (pre-configured in viceroy.toml). - - Args: - backend_name: Name of the static backend from configuration - path: Path to request (e.g., "/get", "/post") - - Returns: - SimpleResponse with status and raw body bytes - - Raises: - Exception if backend doesn't exist or request fails - """ - # Check if backend exists - if not backend.exists(backend_name): - raise ValueError(f"Static backend '{backend_name}' does not exist") - - # Create a new request - request = http_req.Request.new() - - # Set request details - request.set_method("GET") - request.set_uri(path) - request.insert_header("User-Agent", b"FastlyCompute-BackendExample/1.0") - - # TODO: this shouldn't be required and is a bug in viceroy for component - # model interactions, most likely. - request.insert_header("Host", b"localhost") - - # Create empty body for GET request - body = http_body.new() - - # Send request to static backend - response, response_body = http_req.send(request, body, backend_name) - - # Read response - status = response.get_status() - - # Read response body - response_data = read_response_body(response_body) - - return SimpleResponse(status=status, body=response_data) - - -def make_dynamic_backend_request( - target_url: str, backend_prefix: str = "dynamic" -) -> SimpleResponse: - """Make a request using a dynamic backend (created at runtime). - - Args: - target_url: Full URL to request (e.g., "https://httpbin.org/get") - backend_prefix: Prefix for the dynamic backend name - - Returns: - SimpleResponse with status and raw body bytes - - Raises: - ValueError: If URL is invalid - Exception: If backend registration or request fails - """ - # Parse URL and register backend - scheme, host, path, backend_name = _register_dynamic_backend( - target_url, backend_prefix - ) - - # Create request - request = http_req.Request.new() - request.set_method("GET") - request.set_uri(path) - request.insert_header("User-Agent", b"FastlyCompute-BackendExample/1.0") - request.insert_header("Host", host.encode("utf-8")) - - # Create empty body - body = http_body.new() - - # Send request - response, response_body = http_req.send(request, body, backend_name) - - # Read response - status = response.get_status() - - # Read response body - response_data = read_response_body(response_body) - - return SimpleResponse(status=status, body=response_data) - - -def make_dynamic_post_request(target_url: str, post_data: dict) -> SimpleResponse: - """Make a POST request using a dynamic backend with JSON data. - - Args: - target_url: Full URL to POST to - post_data: Data to send as JSON - - Returns: - SimpleResponse with status and raw body bytes - - Raises: - ValueError: If URL is invalid - Exception: If backend registration or request fails - """ - # Parse URL and register backend - scheme, host, path, backend_name = _register_dynamic_backend(target_url) - - # Create POST request - request = http_req.Request.new() - request.set_method("POST") - request.set_uri(path) - request.insert_header("User-Agent", b"FastlyCompute-BackendExample/1.0") - request.insert_header("Host", host.encode("utf-8")) - request.insert_header("Content-Type", b"application/json") - - # Create body with JSON data - json_str = json.dumps(post_data) - json_bytes = json_str.encode("utf-8") - - body = http_body.new() - http_body.write(body, json_bytes, http_body.WriteEnd.BACK) - - # Send request - response, response_body = http_req.send(request, body, backend_name) - - # Read response - status = response.get_status() - - # Read response body - response_data = read_response_body(response_body) - - return SimpleResponse(status=status, body=response_data) - - -@app.route("/static") -def test_static_backend(): - """Test static backend (requires backend named 'test-be' in viceroy.toml).""" - try: - response = make_static_backend_request("test-be", "/get") - - # Try to parse response as JSON - try: - response_data = json.loads(response.body.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - response_data = {"text": response.body.decode("utf-8", errors="replace")} - - return { - "backend_type": "static", - "backend_name": "test-be", - "status": response.status, - "data": response_data, - } - except Exception as e: - return {"backend_type": "static", "backend_name": "test-be", "error": str(e)} - - -@app.route("/dynamic") -def test_dynamic_backend(): - """Test dynamic backend to a public API.""" - from bottle import request - - # Get target from query parameter (required) - target = request.query.get("target") - if not target: - return { - "backend_type": "dynamic", - "error": "target query parameter is required (e.g., ?target=https://httpbin.org/get)", - } - - try: - response = make_dynamic_backend_request(target) - - # Try to parse response as JSON - try: - response_data = json.loads(response.body.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - response_data = {"text": response.body.decode("utf-8", errors="replace")} - - return { - "backend_type": "dynamic", - "target": target, - "status": response.status, - "data": response_data, - } - except Exception as e: - return { - "backend_type": "dynamic", - "target": target, - "error": str(e), - } - - -@app.route("/dynamic-post") -def test_dynamic_post(): - """Test dynamic backend POST.""" - from bottle import request - - # Get target from query parameter (required) - target = request.query.get("target") - if not target: - return { - "backend_type": "dynamic", - "method": "POST", - "error": "target query parameter is required (e.g., ?target=https://httpbin.org/post)", - } - - vcpu_time = compute_runtime.get_vcpu_ms() - test_data = { - "message": "Hello from Fastly Compute!", - "timestamp": vcpu_time, - "test": True, - } - - try: - response = make_dynamic_post_request(target, test_data) - - # Try to parse response as JSON - try: - response_data = json.loads(response.body.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - response_data = {"text": response.body.decode("utf-8", errors="replace")} - - return { - "backend_type": "dynamic", - "method": "POST", - "target": target, - "status": response.status, - "sent_data": test_data, - "data": response_data, - } - except Exception as e: - return { - "backend_type": "dynamic", - "method": "POST", - "target": target, - "error": str(e), - } - - -# Create the HTTP handler using the shared WSGI infrastructure -# Use basic environ for Bottle (doesn't need enhanced WSGI variables like Flask) -HttpIncoming = WsgiHttpIncoming(app) diff --git a/tests/test_backend_simple.py b/tests/test_backend_simple.py deleted file mode 100644 index 5485543..0000000 --- a/tests/test_backend_simple.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Tests for backend-simple.py example with local server backends.""" - -from fastly_compute.test_server import LocalTestServer -from fastly_compute.testing import ViceroyTestBase - - -class TestBackendSimple(ViceroyTestBase): - """Integration tests for backend-simple.py with local backends.""" - - WASM_FILE = "build/backend-simple.composed.wasm" - - @classmethod - def setup_class(cls): - """Set up local test servers and configure backends.""" - # Create a local test server that mimics httpbin - cls.test_server = LocalTestServer(host="127.0.0.1", port=0) - cls.test_server_url = cls.test_server.start() - - # Configure the backend for viceroy - cls.set_up_backends({"test-be": cls.test_server_url}) - - @classmethod - def teardown_class(cls): - """Clean up test servers.""" - cls.test_server.stop() - - def test_static_backend_request(self): - """Test static backend request to local test server.""" - response = self.get("/static") - assert response.status_code == 200 - - data = response.json() - assert data["backend_type"] == "static" - assert data["backend_name"] == "test-be" - assert data["status"] == 200 - - # Check that we got httpbin-like response data - response_data = data["data"] - assert "url" in response_data - assert "headers" in response_data - assert "method" in response_data - assert response_data["method"] == "GET" - - def test_dynamic_backend_request(self): - """Test dynamic backend request (should work without static backend config).""" - response = self.get("/dynamic?target=https://http-me.fastly.dev/get") - assert response.status_code == 200 - - data = response.json() - assert data["backend_type"] == "dynamic" - assert data["target"] == "https://http-me.fastly.dev/get" - - # This might fail if external http-me.fastly.dev is not accessible - # But the test should at least show our code handling the dynamic backend - if "error" in data: - # External request failed - verify error handling - assert "error" in data - else: - # External request succeeded - assert data["status"] == 200 - - def test_dynamic_backend_no_target(self): - """Test dynamic backend request without target parameter.""" - response = self.get("/dynamic") - assert response.status_code == 200 - - data = response.json() - assert data["backend_type"] == "dynamic" - assert "error" in data - assert "target query parameter is required" in data["error"] - - def test_dynamic_post_request(self): - """Test dynamic backend POST request.""" - response = self.get("/dynamic-post?target=https://http-me.fastly.dev/post") - assert response.status_code == 200 - - data = response.json() - assert data["backend_type"] == "dynamic" - assert data["method"] == "POST" - assert data["target"] == "https://http-me.fastly.dev/post" - - # Similar to GET test - external dependency - if "error" in data: - # External request failed - verify error handling - assert "error" in data - else: - # External request succeeded - assert "sent_data" in data - assert data["sent_data"]["test"] is True - - def test_dynamic_post_no_target(self): - """Test dynamic backend POST request without target parameter.""" - response = self.get("/dynamic-post") - assert response.status_code == 200 - - data = response.json() - assert data["backend_type"] == "dynamic" - assert data["method"] == "POST" - assert "error" in data - assert "target query parameter is required" in data["error"] - - -class TestBackendSimpleWithMockResponses(ViceroyTestBase): - """Test backend-simple.py with controlled mock responses.""" - - WASM_FILE = "build/backend-simple.composed.wasm" - - @classmethod - def setup_class(cls): - """Set up mock server with predefined responses.""" - # Create mock responses that match what http-me.fastly.dev would return - mock_responses = { - "/get": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": { - "args": {}, - "headers": {"User-Agent": "FastlyCompute-BackendExample/1.0"}, - "origin": "127.0.0.1", - "url": "http://localhost/get", - "method": "GET", - "path": "/get", - }, - } - } - - # Start mock server - cls.mock_server = LocalTestServer( - host="127.0.0.1", port=0, responses=mock_responses - ) - cls.mock_server_url = cls.mock_server.start() - - # Configure backend - cls.set_up_backends({"test-be": cls.mock_server_url}) - - @classmethod - def teardown_class(cls): - """Clean up mock server.""" - cls.mock_server.stop() - - def test_static_backend_with_mock_response(self): - """Test static backend with controlled mock response.""" - response = self.get("/static") - assert response.status_code == 200 - - data = response.json() - assert data["backend_type"] == "static" - assert data["backend_name"] == "test-be" - assert data["status"] == 200 - - # Check the mock response data - response_data = data["data"] - assert response_data["method"] == "GET" - assert response_data["path"] == "/get" - assert response_data["url"] == "http://localhost/get" From 50287b7ef58b5f5b8f29c986cf15f1625f2d533c Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 19 Nov 2025 11:06:38 -0600 Subject: [PATCH 10/13] Rename requests-simple to backend-requests The simple bit qualifier doesn't add any real meaning but mentioning backends does. --- Makefile | 2 +- examples/{requests-simple.py => backend-requests.py} | 0 tests/{test_requests_simple.py => test_backend_requests.py} | 6 +++--- 3 files changed, 4 insertions(+), 4 deletions(-) rename examples/{requests-simple.py => backend-requests.py} (100%) rename tests/{test_requests_simple.py => test_backend_requests.py} (97%) diff --git a/Makefile b/Makefile index a341b28..6d97666 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ BUILD_DIR := build EXAMPLES_DIR := examples # Define all available examples (add new ones here) -EXAMPLES := bottle-app flask-app requests-simple game-of-life +EXAMPLES := bottle-app flask-app backend-requests game-of-life # Default example for serve target EXAMPLE ?= bottle-app diff --git a/examples/requests-simple.py b/examples/backend-requests.py similarity index 100% rename from examples/requests-simple.py rename to examples/backend-requests.py diff --git a/tests/test_requests_simple.py b/tests/test_backend_requests.py similarity index 97% rename from tests/test_requests_simple.py rename to tests/test_backend_requests.py index eebaad0..5ca84e8 100644 --- a/tests/test_requests_simple.py +++ b/tests/test_backend_requests.py @@ -1,13 +1,13 @@ -"""Tests for the requests-simple example application.""" +"""Tests for the backend-requests example application.""" from fastly_compute.test_server import LocalTestServer from fastly_compute.testing import ViceroyTestBase class TestRequestsSimple(ViceroyTestBase): - """Integration tests for the requests-simple example.""" + """Integration tests for the backend-requests example.""" - WASM_FILE = "build/requests-simple.composed.wasm" + WASM_FILE = "build/backend-requests.composed.wasm" @classmethod def setup_class(cls): From b3ca60aa064c9080a67f7a571217b7109f6f7ebc Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 19 Nov 2025 11:43:33 -0600 Subject: [PATCH 11/13] Daylight advanced timeout configuration Previuosly, we were just using hardcoded values when setting up the hostcalls. Here we try to map the requests `timeout` kwarg where possible and allow for full configuration via `timeout_config` on requests. --- fastly_compute/requests/__init__.py | 91 ++++++++++++++++++++++++++--- fastly_compute/requests/backend.py | 38 +++++++++--- fastly_compute/requests/timeout.py | 86 +++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 fastly_compute/requests/timeout.py diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index 4347cc9..e1b0790 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -3,7 +3,7 @@ This module provides a familiar requests-like API while leveraging Fastly's backend architecture and WIT bindings for optimal performance. -Usage: +Basic Usage: import fastly_compute.requests as requests # Static backend (pre-configured) @@ -15,6 +15,32 @@ # POST with JSON response = requests.post("https://http-me.fastly.dev/post", json={"key": "value"}) + +Fastly-Specific Features: + from fastly_compute.requests import TimeoutConfig + + # Granular timeout control (not available in standard requests) + timeout_config = TimeoutConfig( + connect=5.0, # 5s to establish connection + first_byte=30.0, # 30s to receive first byte + between_bytes=2.0 # 2s max between bytes + ) + response = requests.get( + "https://api.example.com/data", + timeout_config=timeout_config + ) + + # Backend-specific features + response = requests.get( + "/api/endpoint", + backend="my-backend" # Use specific static backend + ) + +Compatibility Notes: + Most parameters are compatible with the standard requests library. + Fastly-specific parameters (timeout_config, backend) will cause TypeErrors + if used with the standard requests library. Use the standard timeout + parameter for cross-platform compatibility. """ import json as json_module @@ -26,6 +52,28 @@ from .backend import BackendResolver from .exceptions import ConnectionError, HTTPError, RequestException, Timeout from .response import FastlyResponse +from .timeout import TimeoutConfig + +# Export main components for public API +__all__ = [ + # Core request functions + "get", + "post", + "put", + "delete", + "head", + "options", + "request", + # Response class + "FastlyResponse", + # Timeout configuration + "TimeoutConfig", + # Exceptions + "RequestException", + "ConnectionError", + "HTTPError", + "Timeout", +] def get( @@ -33,7 +81,8 @@ def get( params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, backend: str | None = None, - timeout: int | None = None, + timeout: None | float | tuple = None, + timeout_config: TimeoutConfig | None = None, **kwargs, ) -> FastlyResponse: """Send a GET request. @@ -43,13 +92,22 @@ def get( params: Query parameters to append to the URL headers: HTTP headers to send with the request backend: Static backend name (optional, will use dynamic backend if not provided) - timeout: Request timeout in seconds + timeout: Request timeout in seconds (requests-compatible). Can be: + - float: Single timeout for all phases + - (connect, read): Tuple for connect and read timeouts + timeout_config: **Fastly-only** Advanced timeout configuration with granular control + over connect_timeout, first_byte_timeout, and between_bytes_timeout **kwargs: Additional arguments (for requests compatibility, ignored) + Note: + The timeout_config parameter is Fastly-specific and will cause a TypeError + if used with the standard requests library. Use timeout for cross-platform compatibility. + Raises: RequestException: For general request errors ConnectionError: For connection-related errors Timeout: For timeout errors + ValueError: If both timeout and timeout_config are specified """ return request( "GET", @@ -58,6 +116,7 @@ def get( headers=headers, backend=backend, timeout=timeout, + timeout_config=timeout_config, **kwargs, ) @@ -69,7 +128,8 @@ def post( params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, backend: str | None = None, - timeout: int | None = None, + timeout: None | float | tuple = None, + timeout_config: TimeoutConfig | None = None, **kwargs, ) -> FastlyResponse: """Send a POST request. @@ -81,7 +141,8 @@ def post( params: Query parameters to append to the URL headers: HTTP headers to send with the request backend: Static backend name (optional) - timeout: Request timeout in seconds + timeout: Request timeout in seconds (requests-compatible) + timeout_config: **Fastly-only** Advanced timeout configuration **kwargs: Additional arguments (for requests compatibility, ignored) """ return request( @@ -93,6 +154,7 @@ def post( headers=headers, backend=backend, timeout=timeout, + timeout_config=timeout_config, **kwargs, ) @@ -140,7 +202,8 @@ def request( json: dict[str, Any] | None = None, headers: dict[str, str] | None = None, backend: str | None = None, - timeout: int | None = None, + timeout: None | float | tuple = None, + timeout_config: TimeoutConfig | None = None, **kwargs, ) -> FastlyResponse: """Send an HTTP request. @@ -153,7 +216,8 @@ def request( json: JSON data for the request body (mutually exclusive with data) headers: HTTP headers backend: Static backend name (if not provided, will use dynamic backend) - timeout: Request timeout in seconds + timeout: Request timeout in seconds (requests-compatible) + timeout_config: **Fastly-only** Advanced timeout configuration **kwargs: Additional arguments (for requests compatibility, ignored) Raises: @@ -164,12 +228,23 @@ def request( if data is not None and json is not None: raise ValueError("Cannot specify both 'data' and 'json' parameters") + if timeout is not None and timeout_config is not None: + raise ValueError( + "Cannot specify both 'timeout' and 'timeout_config' parameters" + ) + + # Resolve timeout configuration + if timeout_config is not None: + resolved_timeout = timeout_config + else: + resolved_timeout = TimeoutConfig.from_requests_timeout(timeout) + # Initialize resolver resolver = BackendResolver() try: # Resolve backend and final URL - final_url, backend_name = resolver.resolve(url, backend) + final_url, backend_name = resolver.resolve(url, backend, resolved_timeout) # Add query parameters if provided if params: diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index 766077c..57e8052 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -5,10 +5,14 @@ """ import urllib.parse +from typing import TYPE_CHECKING from wit_world.imports import backend as wit_backend from wit_world.imports import http_req +if TYPE_CHECKING: + from .timeout import TimeoutConfig + class BackendResolver: """Resolves backend names and URLs for requests.""" @@ -17,12 +21,18 @@ def __init__(self): """Initialize the backend resolver.""" self._dynamic_backends: set[str] = set() # Track registered dynamic backends - def resolve(self, url: str, backend: str | None = None) -> tuple[str, str]: + def resolve( + self, + url: str, + backend: str | None = None, + timeout_config: "TimeoutConfig" | None = None, + ) -> tuple[str, str]: """Resolve backend name and final URL for a request. Args: url: The URL to request (can be path-only or full URL) backend: Optional static backend name + timeout_config: Optional timeout configuration for dynamic backends Returns: Tuple of (final_url, backend_name) @@ -36,7 +46,11 @@ def resolve(self, url: str, backend: str | None = None) -> tuple[str, str]: # If URL looks like a full URL, use dynamic backend pattern if self._is_full_url(url): - return self._resolve_dynamic_backend(url) + if timeout_config is None: + from .timeout import TimeoutConfig + + timeout_config = TimeoutConfig() + return self._resolve_dynamic_backend(url, timeout_config) # Path-only URL without explicit backend - this is an error raise ValueError( @@ -79,11 +93,14 @@ def _resolve_static_backend(self, url: str, backend_name: str) -> tuple[str, str return final_url, backend_name - def _resolve_dynamic_backend(self, url: str) -> tuple[str, str]: + def _resolve_dynamic_backend( + self, url: str, timeout_config: "TimeoutConfig" + ) -> tuple[str, str]: """Resolve a dynamic backend request. Args: url: Full URL (must include scheme and host) + timeout_config: Timeout configuration for the backend Returns: Tuple of (final_url, backend_name) @@ -105,7 +122,9 @@ def _resolve_dynamic_backend(self, url: str) -> tuple[str, str]: # Register dynamic backend if not already registered if backend_name not in self._dynamic_backends: - self._register_dynamic_backend(backend_name, parsed.scheme, host) + self._register_dynamic_backend( + backend_name, parsed.scheme, host, timeout_config + ) self._dynamic_backends.add(backend_name) # For dynamic backends, we use the path portion as the URL @@ -118,7 +137,7 @@ def _resolve_dynamic_backend(self, url: str) -> tuple[str, str]: return final_url, backend_name def _register_dynamic_backend( - self, backend_name: str, scheme: str, host: str + self, backend_name: str, scheme: str, host: str, timeout_config: "TimeoutConfig" ) -> None: """Register a new dynamic backend. @@ -126,6 +145,7 @@ def _register_dynamic_backend( backend_name: Name for the dynamic backend scheme: URL scheme (http or https) host: Target host + timeout_config: Timeout configuration for the backend Raises: Exception: If backend registration fails @@ -137,10 +157,10 @@ def _register_dynamic_backend( if scheme == "https": options.use_tls(True) - # Set reasonable timeouts (in milliseconds) - options.connect_timeout(30000) # 30 seconds - options.first_byte_timeout(60000) # 60 seconds - options.between_bytes_timeout(10000) # 10 seconds + # Set timeouts from configuration (convert to milliseconds) + options.connect_timeout(timeout_config.connect_ms) + options.first_byte_timeout(timeout_config.first_byte_ms) + options.between_bytes_timeout(timeout_config.between_bytes_ms) # Register the backend target = f"{scheme}://{host}" diff --git a/fastly_compute/requests/timeout.py b/fastly_compute/requests/timeout.py new file mode 100644 index 0000000..7d183b4 --- /dev/null +++ b/fastly_compute/requests/timeout.py @@ -0,0 +1,86 @@ +"""Timeout configuration for Fastly Compute requests. + +This module provides timeout configuration classes that support both standard +requests-compatible timeouts and Fastly-specific granular timeout controls. +""" + + +class TimeoutConfig: + """Timeout configuration for Fastly backend requests. + + Fastly supports three distinct timeout phases: + - connect_timeout: Time to establish the initial TCP connection + - first_byte_timeout: Time between sending request and receiving first response byte + - between_bytes_timeout: Maximum time between any two consecutive bytes in response + + This provides much more granular control than the standard requests library, + which only supports a single timeout or (connect, read) tuple. + """ + + def __init__( + self, + connect: float = 30.0, + first_byte: float = 60.0, + between_bytes: float = 10.0, + ): + """Initialize timeout configuration. + + Args: + connect: Connection timeout in seconds (default: 30.0) + first_byte: First byte timeout in seconds (default: 60.0) + between_bytes: Between bytes timeout in seconds (default: 10.0) + """ + self.connect = connect + self.first_byte = first_byte + self.between_bytes = between_bytes + + @property + def connect_ms(self) -> int: + """Connection timeout in milliseconds (for WIT API).""" + return int(self.connect * 1000) + + @property + def first_byte_ms(self) -> int: + """First byte timeout in milliseconds (for WIT API).""" + return int(self.first_byte * 1000) + + @property + def between_bytes_ms(self) -> int: + """Between bytes timeout in milliseconds (for WIT API).""" + return int(self.between_bytes * 1000) + + @classmethod + def from_requests_timeout(cls, timeout: None | float | tuple) -> "TimeoutConfig": + """Create TimeoutConfig from requests-compatible timeout parameter. + + Args: + timeout: Timeout specification in requests-compatible formats: + - None: Use default timeouts + - float: Single timeout applied to all phases + - (connect, read): Tuple with separate connect and read timeouts + + Returns: + TimeoutConfig object with appropriate timeout values + + Raises: + ValueError: If timeout format is invalid + """ + if timeout is None: + return cls() + elif isinstance(timeout, int | float): + # Single timeout - use for all phases + return cls(connect=timeout, first_byte=timeout, between_bytes=timeout) + elif isinstance(timeout, tuple) and len(timeout) == 2: + # (connect, read) - requests-compatible format + connect, read = timeout + # Split read timeout between first_byte and between_bytes + # Use read timeout for first_byte, and half for between_bytes + return cls(connect=connect, first_byte=read, between_bytes=read / 2) + else: + raise ValueError( + f"Invalid timeout format: {timeout}. Expected None, float, or 2-tuple." + ) + + def __repr__(self) -> str: + """Return string representation of TimeoutConfig.""" + return f"TimeoutConfig(connect={self.connect}, first_byte={self.first_byte}, between_bytes={self.between_bytes})" From 3ef02b911401413d0078a839690b3e9a661971be Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 19 Nov 2025 14:58:44 -0600 Subject: [PATCH 12/13] Attempt to better map errors in backend code This is challenging to test at present and there aren't test cases added as Viceroy currently maps everything to a generic error so this is a best effort to try to put in place a framework that could be closer to correct and more fine-grained than what was present previously. --- fastly_compute/requests/__init__.py | 167 ++++++++++++++++++++++------ 1 file changed, 136 insertions(+), 31 deletions(-) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index e1b0790..1b3a8f7 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -48,12 +48,106 @@ from typing import Any from wit_world.imports import http_body, http_req +from wit_world.imports import types as wit_types +from wit_world.types import Err as WitErr from .backend import BackendResolver from .exceptions import ConnectionError, HTTPError, RequestException, Timeout from .response import FastlyResponse from .timeout import TimeoutConfig +# WIT error type mappings for detailed errors; the keys here are derived +# from send-error-detail +_WIT_ERROR_CODE_TO_REQUESTS_EXC = { + # Timeout errors + http_req.SendErrorDetail_DnsTimeout: Timeout, + http_req.SendErrorDetail_DnsTimeout: Timeout, + http_req.SendErrorDetail_ConnectionTimeout: Timeout, + http_req.SendErrorDetail_HttpResponseTimeout: Timeout, + # Connection errors + http_req.SendErrorDetail_ConnectionRefused: ConnectionError, + http_req.SendErrorDetail_ConnectionTerminated: ConnectionError, + http_req.SendErrorDetail_DestinationNotFound: ConnectionError, + http_req.SendErrorDetail_DestinationUnavailable: ConnectionError, + http_req.SendErrorDetail_DestinationIpUnroutable: ConnectionError, + http_req.SendErrorDetail_DnsError: ConnectionError, + http_req.SendErrorDetail_TlsCertificateError: ConnectionError, + http_req.SendErrorDetail_TlsConfigurationError: ConnectionError, + http_req.SendErrorDetail_TlsAlertReceived: ConnectionError, + http_req.SendErrorDetail_TlsProtocolError: ConnectionError, + http_req.SendErrorDetail_ConnectionLimitReached: ConnectionError, + # HTTP protocol errors + http_req.SendErrorDetail_HttpIncompleteResponse: HTTPError, + http_req.SendErrorDetail_HttpResponseHeaderSectionTooLarge: HTTPError, + http_req.SendErrorDetail_HttpResponseBodyTooLarge: HTTPError, + http_req.SendErrorDetail_HttpUpgradeFailed: HTTPError, + http_req.SendErrorDetail_HttpProtocolError: HTTPError, + http_req.SendErrorDetail_HttpResponseStatusInvalid: HTTPError, + # Request/backend errors (default to RequestException) + http_req.SendErrorDetail_HttpRequestCacheKeyInvalid: RequestException, + http_req.SendErrorDetail_HttpRequestUriInvalid: RequestException, + http_req.SendErrorDetail_InternalError: RequestException, +} + +# WIT base error type mappings for generic errors (current viceroy) +_BASE_ERROR_MAPPINGS = { + wit_types.Error_HttpInvalid: HTTPError, + wit_types.Error_HttpUser: HTTPError, + wit_types.Error_HttpIncomplete: HTTPError, + wit_types.Error_HttpHeadTooLarge: HTTPError, + wit_types.Error_HttpInvalidStatus: HTTPError, + wit_types.Error_CannotRead: ConnectionError, + # All others (Error_GenericError, Error_InvalidArgument, etc.) default to RequestException +} + + +def _map_wit_error(err: WitErr, operation: str) -> None: + """Map a WIT error to a requests exception. + + Args: + err: WIT Err exception containing ErrorWithDetail + operation: Description of what operation failed + + Raises: + Appropriate exception (Timeout, ConnectionError, HTTPError, RequestException) + with full exception chain preserved via 'from err' + """ + # TODO: many of the requests exceptions allow for storage of the request/response + # that lead to the error; plumb those through in the future depending on the type. + + # Create base error message + message = f"{operation}: " + + # sanity check -- this isn't expected but map the base case to a + # generic exception + if not hasattr(err.value, "detail") and not hasattr(err.value, "error"): + message += f"unexpected error structure: {err}" + return RequestException(message) + + error_with_detail = err.value + + # Try detailed error classification first (future production case) + if error_with_detail.detail is not None: + send_error_type = type(error_with_detail.detail) + message += send_error_type.__name__ + + # Look up exception type from detailed error mapping + # TODO - there's some additional info on some of these types that could + # be extracted. It may be enough that we just keep the underlying exception + # but that is TBD. + exception_class = _WIT_ERROR_CODE_TO_REQUESTS_EXC.get( + send_error_type, RequestException + ) + return exception_class(message) + + # No detailed error - classify based on base error type + base_error_type = type(error_with_detail.error) + message += base_error_type.__name__ + exception_class = _BASE_ERROR_MAPPINGS.get(base_error_type, RequestException) + + return exception_class(message) + + # Export main components for public API __all__ = [ # Core request functions @@ -242,12 +336,16 @@ def request( # Initialize resolver resolver = BackendResolver() + # Resolve backend and final URL try: - # Resolve backend and final URL final_url, backend_name = resolver.resolve(url, backend, resolved_timeout) + except ValueError as e: + # Backend resolution errors (invalid URLs, missing backends, etc.) + raise RequestException(f"Backend resolution failed: {e}") from e - # Add query parameters if provided - if params: + # Add query parameters if provided + if params: + try: # Parse existing query parameters parsed_url = urllib.parse.urlparse(final_url) query_params = urllib.parse.parse_qs(parsed_url.query) @@ -271,33 +369,39 @@ def request( parsed_url.fragment, ) ) + except (ValueError, TypeError) as e: + raise RequestException(f"Invalid query parameters: {e}") from e - # Create WIT request + # Create WIT request + try: wit_request = http_req.Request.new() wit_request.set_method(method.upper()) wit_request.set_uri(final_url) + except Exception as e: + raise RequestException(f"Failed to create WIT request: {e}") from e - # Set Host header (may be required by viceroy, but let's test without it) - # TODO: Investigate if Host header is actually required by the WIT spec - # or if this is a viceroy-specific requirement + # Set headers + try: + # TODO: See https://github.com/fastly/Viceroy/pull/549; what is + # present here is a temporary workaround for viceroy differing + # in its handling than XQD. if backend is not None: - # Static backend - use localhost as host - wit_request.insert_header("Host", b"localhost") - else: - # Dynamic backend - extract host from original URL - parsed_url = urllib.parse.urlparse(url) - host = parsed_url.netloc.encode("utf-8") - wit_request.insert_header("Host", host) - - # Set default headers + wit_request.insert_header("Host", b"dummy") + + # TODO: verify against other Compute SDKs wit_request.insert_header("User-Agent", b"FastlyCompute-Requests/1.0") # Add custom headers if headers: for name, value in headers.items(): wit_request.insert_header(name, value.encode("utf-8")) + except (ValueError, UnicodeError) as e: + raise RequestException(f"Invalid headers: {e}") from e + except Exception as e: + raise RequestException(f"Failed to set request headers: {e}") from e - # Prepare request body + # Prepare request body + try: request_body = http_body.new() if json is not None: @@ -321,27 +425,28 @@ def request( http_body.write(request_body, data_bytes, http_body.WriteEnd.BACK) else: raise ValueError(f"Unsupported data type: {type(data)}") + except (TypeError, ValueError, UnicodeError) as e: + raise RequestException(f"Invalid request body: {e}") from e + except Exception as e: + raise RequestException(f"Failed to prepare request body: {e}") from e - # Send request + # Send the request + try: wit_response, response_body = http_req.send( wit_request, request_body, backend_name ) + except WitErr as e: + # WIT-level errors during request execution - use proper error classification + raise _map_wit_error(e, "Request execution failed") from e + except Exception as e: + # Unexpected non-WIT exception (should be rare) + raise RequestException(f"Unexpected error during request execution: {e}") from e - # Wrap in FastlyResponse + # Wrap in FastlyResponse + try: return FastlyResponse(wit_response, response_body, final_url) - except Exception as e: - # TODO: revisit finer-grained exception handling and top-level - # WIT exception mapping. - - # Map WIT errors to requests-compatible exceptions - error_msg = str(e).lower() - if "timeout" in error_msg: - raise Timeout(str(e)) from e - elif "connection" in error_msg or "network" in error_msg: - raise ConnectionError(str(e)) from e - else: - raise RequestException(str(e)) from e + raise RequestException(f"Failed to create response object: {e}") from e # Export main API From 1787eebf26261715dfa42b8cb9d6e89fefcd4123 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 19 Nov 2025 15:19:21 -0600 Subject: [PATCH 13/13] Introduce snapshot testing and convert backend tests I'm familiar with snapshot testing from the rust world using the insta library. Here we introduce syrupy to do snapshot testing to assert that we get back the responses we want. For now, this is only applied to the backend tests which needed some updates as it is. The general flow is to add tests and then to run `make test-update-snapshots` (or run the syrupy command directly) in order to write out new snapshots (which should be reviewed). --- Makefile | 7 +- pyproject.toml | 1 + .../__snapshots__/test_backend_requests.ambr | 77 +++++++++++++ tests/test_backend_requests.py | 108 +++--------------- uv.lock | 14 +++ 5 files changed, 112 insertions(+), 95 deletions(-) create mode 100644 tests/__snapshots__/test_backend_requests.ambr diff --git a/Makefile b/Makefile index 6d97666..7c5a775 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,10 @@ serve: $(WASM_FILE) test: $(COMPOSED_WASMS) VICEROY=$(VICEROY) uv run --extra test pytest +# Update snapshots for snapshot tests +test-update-snapshots: $(COMPOSED_WASMS) + VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update + # List available examples list-examples: @echo "Available examples:" @@ -91,6 +95,7 @@ help: @echo " all Build all examples" @echo " serve [EXAMPLE=name] Serve example (default: $(EXAMPLE))" @echo " test Run integration tests (builds all examples)" + @echo " test-update-snapshots Update snapshot test baselines" @echo " build-all Build all examples (alias for 'all')" @echo " list-examples List available examples" @echo " clean Clean build artifacts" @@ -107,4 +112,4 @@ help: @echo "" @echo "Available examples: $(EXAMPLES)" -.PHONY: all serve test list-examples build-all clean lint lint-fix format format-check help $(WASILESS_WASM) +.PHONY: all serve test test-update-snapshots list-examples build-all clean lint lint-fix format format-check help $(WASILESS_WASM) diff --git a/pyproject.toml b/pyproject.toml index d0002d4..7f2c6c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ test = [ "pytest (>=8.4.0,<9.0.0)", "requests (>=2.32.5,<3.0.0)", "tomli-w (>=1.0.0,<2.0.0)", + "syrupy (==5.0.0)", ] dev = [ "ruff (>=0.12.11,<0.13.0)", diff --git a/tests/__snapshots__/test_backend_requests.ambr b/tests/__snapshots__/test_backend_requests.ambr new file mode 100644 index 0000000..2bde137 --- /dev/null +++ b/tests/__snapshots__/test_backend_requests.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: TestRequestsSimple.test_dynamic_get_no_target + dict({ + 'demo': 'dynamic-get', + 'error': 'target query parameter is required (e.g., ?target=https://http-me.fastly.dev/get)', + }) +# --- +# name: TestRequestsSimple.test_dynamic_get_request + dict({ + 'demo': 'dynamic-get', + 'error': "module 'wit_world.imports.http_req' has no attribute 'DynamicBackendOptions'", + 'error_type': 'AttributeError', + }) +# --- +# name: TestRequestsSimple.test_dynamic_post_no_target + dict({ + 'demo': 'dynamic-post', + 'error': 'target query parameter is required (e.g., ?target=https://http-me.fastly.dev/post)', + }) +# --- +# name: TestRequestsSimple.test_dynamic_post_request + dict({ + 'demo': 'dynamic-post', + 'error': "module 'wit_world.imports.http_req' has no attribute 'DynamicBackendOptions'", + 'error_type': 'AttributeError', + }) +# --- +# name: TestRequestsSimple.test_error_handling + dict({ + 'demo': 'error-demo', + 'test_results': list([ + dict({ + 'error': "Backend resolution failed: Static backend 'nonexistent-backend' does not exist", + 'error_type': 'RequestException', + 'status': 'expected_error', + 'test': 'invalid-static-backend', + }), + dict({ + 'error': "Backend resolution failed: Path-only URL requires explicit 'backend' parameter. Either provide backend='backend-name' or use full URL like 'https://example.comnot-a-url'", + 'error_type': 'RequestException', + 'status': 'expected_error', + 'test': 'invalid-url-format', + }), + ]), + }) +# --- +# name: TestRequestsSimple.test_static_get_request + dict({ + 'backend_name': 'test-be', + 'backend_type': 'static', + 'content_length': 185, + 'demo': 'static-get', + 'headers_count': 3, + 'response_preview': ''' + { + "args": {}, + "headers": { + "User-Agent": "FastlyCompute-Requests/1.0", + "Host": "localhost" + }, + "method": "GET", + "origin": "127.0.0.1", + "url": "http://localhost/get" + } + ''', + 'status_code': 200, + 'success': True, + 'url': '/get', + }) +# --- +# name: TestRequestsSimple.test_static_post_request + dict({ + 'demo': 'static-post', + 'error': "Failed to prepare request body: module 'wit_world.imports.http_body' has no attribute 'WriteEnd'", + 'error_type': 'RequestException', + }) +# --- diff --git a/tests/test_backend_requests.py b/tests/test_backend_requests.py index 5ca84e8..64ff3da 100644 --- a/tests/test_backend_requests.py +++ b/tests/test_backend_requests.py @@ -62,124 +62,44 @@ def teardown_class(cls): """Clean up test server.""" cls.test_server.stop() - def test_static_get_request(self): + def test_static_get_request(self, snapshot): """Test static backend GET request.""" response = self.get("/static-get") assert response.status_code == 200 + assert response.json() == snapshot - data = response.json() - assert data["demo"] == "static-get" - assert data["backend_type"] == "static" - assert data["backend_name"] == "test-be" - assert data["status_code"] == 200 - assert data["success"] is True - assert "url" in data - assert "content_length" in data - - def test_static_post_request(self): + def test_static_post_request(self, snapshot): """Test static backend POST request.""" response = self.get("/static-post") assert response.status_code == 200 + assert response.json() == snapshot - data = response.json() - - if "error" in data: - # Request failed - check error handling - assert data["demo"] == "static-post" - assert "error_type" in data - else: - # Request succeeded - assert data["demo"] == "static-post" - assert data["backend_type"] == "static" - assert data["backend_name"] == "test-be" - assert data["status_code"] == 200 - assert data["success"] is True - - # Check that post data was sent - assert "sent_data" in data - sent_data = data["sent_data"] - assert sent_data["message"] == "Hello from Fastly Compute!" - assert sent_data["demo"] == "static-post" - - def test_dynamic_get_request(self): + def test_dynamic_get_request(self, snapshot): """Test dynamic backend GET request.""" response = self.get("/dynamic-get?target=https://http-me.fastly.dev/get") assert response.status_code == 200 + assert response.json() == snapshot - data = response.json() - assert data["demo"] == "dynamic-get" - - # This test might fail if external http-me.fastly.dev is not accessible - if "error" in data: - # External request failed - verify error handling - assert "error" in data - assert "error_type" in data - else: - # External request succeeded - assert data["backend_type"] == "dynamic" - assert data["target_url"] == "https://http-me.fastly.dev/get" - assert data["status_code"] == 200 - assert data["success"] is True - assert "url" in data - assert "headers" in data - - def test_dynamic_get_no_target(self): + def test_dynamic_get_no_target(self, snapshot): """Test dynamic backend GET request without target parameter.""" response = self.get("/dynamic-get") assert response.status_code == 200 + assert response.json() == snapshot - data = response.json() - assert data["demo"] == "dynamic-get" - assert "error" in data - assert "target query parameter is required" in data["error"] - - def test_dynamic_post_request(self): + def test_dynamic_post_request(self, snapshot): """Test dynamic backend POST request.""" response = self.get("/dynamic-post?target=https://http-me.fastly.dev/post") assert response.status_code == 200 + assert response.json() == snapshot - data = response.json() - assert data["demo"] == "dynamic-post" - - # External dependency - should handle gracefully - if "error" in data: - assert "error" in data - assert "error_type" in data - else: - assert data["backend_type"] == "dynamic" - assert data["target_url"] == "https://http-me.fastly.dev/post" - assert "sent_data" in data - sent_data = data["sent_data"] - assert sent_data["service"] == "fastly-compute" - assert sent_data["demo"] == "dynamic-post" - - def test_dynamic_post_no_target(self): + def test_dynamic_post_no_target(self, snapshot): """Test dynamic backend POST request without target parameter.""" response = self.get("/dynamic-post") assert response.status_code == 200 + assert response.json() == snapshot - data = response.json() - assert data["demo"] == "dynamic-post" - assert "error" in data - assert "target query parameter is required" in data["error"] - - def test_error_handling(self): + def test_error_handling(self, snapshot): """Test error handling scenarios.""" response = self.get("/error-demo") assert response.status_code == 200 - - data = response.json() - assert data["demo"] == "error-demo" - assert "test_results" in data - - # Should have at least 2 test cases - test_results = data["test_results"] - assert len(test_results) >= 2 - - # Check that errors are properly caught and reported - for result in test_results: - assert "test" in result - assert "status" in result - if result["status"] == "expected_error": - assert "error" in result - assert "error_type" in result + assert response.json() == snapshot diff --git a/uv.lock b/uv.lock index 836f59e..3e5b4f0 100644 --- a/uv.lock +++ b/uv.lock @@ -137,6 +137,7 @@ dev = [ test = [ { name = "pytest" }, { name = "requests" }, + { name = "syrupy" }, { name = "tomli-w" }, ] @@ -148,6 +149,7 @@ requires-dist = [ { 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"] @@ -355,6 +357,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] +[[package]] +name = "syrupy" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, +] + [[package]] name = "tomli-w" version = "1.2.0"