diff --git a/Makefile b/Makefile index 88ab02e..7c5a775 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-requests 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,11 @@ 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 + +# Update snapshots for snapshot tests +test-update-snapshots: $(COMPOSED_WASMS) + VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update # List available examples list-examples: @@ -89,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" @@ -105,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/examples/backend-requests.py b/examples/backend-requests.py new file mode 100644 index 0000000..295d970 --- /dev/null +++ b/examples/backend-requests.py @@ -0,0 +1,197 @@ +"""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..1b3a8f7 --- /dev/null +++ b/fastly_compute/requests/__init__.py @@ -0,0 +1,467 @@ +"""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. + +Basic 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"}) + +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 +import urllib.parse +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 + "get", + "post", + "put", + "delete", + "head", + "options", + "request", + # Response class + "FastlyResponse", + # Timeout configuration + "TimeoutConfig", + # Exceptions + "RequestException", + "ConnectionError", + "HTTPError", + "Timeout", +] + + +def get( + url: str, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + backend: str | None = None, + timeout: None | float | tuple = None, + timeout_config: TimeoutConfig | 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 (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", + url, + params=params, + headers=headers, + backend=backend, + timeout=timeout, + timeout_config=timeout_config, + **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: None | float | tuple = None, + timeout_config: TimeoutConfig | 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 (requests-compatible) + timeout_config: **Fastly-only** Advanced timeout configuration + **kwargs: Additional arguments (for requests compatibility, ignored) + """ + return request( + "POST", + url, + data=data, + json=json, + params=params, + headers=headers, + backend=backend, + timeout=timeout, + timeout_config=timeout_config, + **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: None | float | tuple = None, + timeout_config: TimeoutConfig | 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 (requests-compatible) + timeout_config: **Fastly-only** Advanced timeout configuration + **kwargs: Additional arguments (for requests compatibility, ignored) + + Raises: + RequestException: For general request errors + ValueError: For invalid arguments + """ + # Validate arguments + 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() + + # Resolve backend and final URL + try: + 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: + try: + # 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, + ) + ) + except (ValueError, TypeError) as e: + raise RequestException(f"Invalid query parameters: {e}") from e + + # 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 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: + 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 + try: + 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)}") + 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 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 + try: + return FastlyResponse(wit_response, response_body, final_url) + except Exception as e: + raise RequestException(f"Failed to create response object: {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..57e8052 --- /dev/null +++ b/fastly_compute/requests/backend.py @@ -0,0 +1,206 @@ +"""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 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.""" + + 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, + 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) + + 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): + 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( + "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. + + 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) where final_url is always a path-only 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 final_url, backend_name + + 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) + + 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, timeout_config + ) + 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 final_url, backend_name + + def _register_dynamic_backend( + self, backend_name: str, scheme: str, host: str, timeout_config: "TimeoutConfig" + ) -> None: + """Register a new dynamic backend. + + Args: + 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 + """ + # Create backend options + options = http_req.DynamicBackendOptions() + + # Configure TLS for HTTPS + if scheme == "https": + options.use_tls(True) + + # 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}" + 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..cdd08a6 --- /dev/null +++ b/fastly_compute/requests/exceptions.py @@ -0,0 +1,63 @@ +"""Exceptions for fastly_compute.requests - compatible with requests library.""" + + +class RequestException(IOError): + """Base exception for all requests-related errors.""" + + 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): + """Exception for connection-related errors.""" + + +class Timeout(RequestException): + """Exception for timeout errors.""" + + +class HTTPError(RequestException): + """Exception for HTTP error responses (4xx, 5xx status codes).""" + + 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, request) + + +class TooManyRedirects(RequestException): + """Exception for too many redirects.""" + + +class InvalidURL(RequestException, ValueError): + """Exception for invalid URLs.""" + + +class InvalidHeader(RequestException, ValueError): + """Exception for invalid headers.""" + + +class ChunkedEncodingError(RequestException): + """Exception for chunked encoding errors.""" + + +class ContentDecodingError(RequestException): + """Exception for content decoding errors.""" + + +class StreamConsumedError(RequestException, TypeError): + """Exception for attempting to read a consumed stream.""" diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py new file mode 100644 index 0000000..534cdfc --- /dev/null +++ b/fastly_compute/requests/response.py @@ -0,0 +1,202 @@ +"""A requests-compatible response object for Fastly Compute.""" + +import json +from typing import Any + +from ..utils import read_response_body +from .exceptions import HTTPError + + +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 = self._parse_charset() or "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 + """ + 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") + + 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: + return content_type.split("charset=")[1].split(";")[0].strip() + except (IndexError, ValueError): + 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.""" + return read_response_body(self._response_body) + + 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/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})" diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py new file mode 100644 index 0000000..4685b54 --- /dev/null +++ b/fastly_compute/test_server.py @@ -0,0 +1,192 @@ +"""Local test server helper for backend testing. + +Provides a simple HTTP server that can act as a backend for viceroy testing. +""" + +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + + +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.""" + + +class LocalTestServer: + """Local HTTP server for backend testing. + + 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 + + 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.server is not None: + raise RuntimeError("Server is already running") + + # Create server + self.server = HTTPServer((self.host, self.port), TestRequestHandler) + + # Set responses on server for handler access + 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.host}:{actual_port}" + + # Start server in background thread + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + # Wait a bit for server to be ready + time.sleep(0.1) + + return base_url + + def stop(self): + """Stop the test server.""" + if self.server is None: + return + + 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.thread = None + + 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 self.server is None: + raise RuntimeError("Server is not running") + + host, port = self.server.server_address + return f"http://{host}:{port}" diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index d8cd07d..73b6375 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -8,16 +8,18 @@ pytest_plugins = ["fastly_compute.pytest_plugin"] """ +import os import socket import subprocess +import tempfile import threading import time from dataclasses import dataclass -from os import environ from pathlib import Path import pytest import requests +import tomli_w @dataclass @@ -54,6 +56,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 +68,66 @@ 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 set_up_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 +155,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 +250,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/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 diff --git a/fastly_compute/wsgi.py b/fastly_compute/wsgi.py index 13717fd..29af805 100644 --- a/fastly_compute/wsgi.py +++ b/fastly_compute/wsgi.py @@ -154,6 +154,12 @@ def __init__( self.reuse_sandboxes_for_ms = reuse_sandboxes_for_ms def __call__(self): + """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: 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 8617b33..7f2c6c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ 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)", @@ -43,6 +45,7 @@ select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions "UP", # pyupgrade + "D", # docstrings ] ignore = [ "E501", # line too long, handled by formatter @@ -55,6 +58,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/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/__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 new file mode 100644 index 0000000..64ff3da --- /dev/null +++ b/tests/test_backend_requests.py @@ -0,0 +1,105 @@ +"""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 backend-requests example.""" + + WASM_FILE = "build/backend-requests.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 + cls.test_server = LocalTestServer( + host="127.0.0.1", port=0, responses=mock_responses + ) + cls.test_server_url = cls.test_server.start() + + # Configure test-be backend for static backend tests + cls.set_up_backends({"test-be": cls.test_server_url}) + + @classmethod + def teardown_class(cls): + """Clean up test server.""" + cls.test_server.stop() + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 + + def test_error_handling(self, snapshot): + """Test error handling scenarios.""" + response = self.get("/error-demo") + assert response.status_code == 200 + assert response.json() == snapshot diff --git a/uv.lock b/uv.lock index 9eaa403..3e5b4f0 100644 --- a/uv.lock +++ b/uv.lock @@ -137,6 +137,8 @@ dev = [ test = [ { name = "pytest" }, { name = "requests" }, + { name = "syrupy" }, + { name = "tomli-w" }, ] [package.metadata] @@ -147,6 +149,8 @@ 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"] @@ -353,6 +357,27 @@ 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" +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"