fix: resolve CI/CD test, lint, and type-check failures
This commit is contained in:
242
app/api_snapshot/server/server.py
Normal file
242
app/api_snapshot/server/server.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Flask-based mock server module."""
|
||||
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from flask import Flask, Response, jsonify
|
||||
from flask import request as flask_request
|
||||
|
||||
from api_snapshot.snapshot.manager import Snapshot, SnapshotManager
|
||||
|
||||
|
||||
def parse_path_parameters(path: str, recorded_path: str) -> Dict[str, str]:
|
||||
"""Extract path parameters from a matched path."""
|
||||
params = {}
|
||||
|
||||
path_parts = path.strip("/").split("/")
|
||||
recorded_parts = recorded_path.strip("/").split("/")
|
||||
|
||||
for path_part, recorded_part in zip(path_parts, recorded_parts):
|
||||
if recorded_part.startswith(":") and path_part:
|
||||
param_name = recorded_part[1:]
|
||||
params[param_name] = path_part
|
||||
elif recorded_part.startswith("{") and recorded_part.endswith("}") and path_part:
|
||||
param_name = recorded_part[1:-1]
|
||||
params[param_name] = path_part
|
||||
|
||||
return params
|
||||
|
||||
|
||||
class MockServer:
|
||||
"""Flask-based mock server for replaying snapshots."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: Snapshot,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
latency_mode: str = "original",
|
||||
fixed_latency_ms: Optional[int] = None,
|
||||
random_latency_range: Optional[Tuple[int, int]] = None
|
||||
):
|
||||
"""Initialize the mock server.
|
||||
|
||||
Args:
|
||||
snapshot: The Snapshot to replay
|
||||
host: Host to bind to
|
||||
port: Port to listen on
|
||||
latency_mode: "original", "fixed", "random", or "none"
|
||||
fixed_latency_ms: Fixed latency in milliseconds (for "fixed" mode)
|
||||
random_latency_range: Tuple of (min, max) ms for random latency
|
||||
"""
|
||||
self.snapshot = snapshot
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.latency_mode = latency_mode
|
||||
self.fixed_latency_ms = fixed_latency_ms
|
||||
self.random_latency_range = random_latency_range
|
||||
|
||||
self.app = Flask(__name__)
|
||||
self._setup_routes()
|
||||
|
||||
def _get_latency_ms(self, original_latency: int = 0) -> int:
|
||||
"""Get the latency to apply based on configuration."""
|
||||
if self.latency_mode == "none":
|
||||
return 0
|
||||
elif self.latency_mode == "fixed" and self.fixed_latency_ms is not None:
|
||||
return self.fixed_latency_ms
|
||||
elif self.latency_mode == "random" and self.random_latency_range:
|
||||
return random.randint(*self.random_latency_range)
|
||||
else:
|
||||
return original_latency
|
||||
|
||||
def _parse_query_params(self, url: str) -> Dict[str, List[str]]:
|
||||
"""Parse query parameters from URL."""
|
||||
parsed = urlparse(url)
|
||||
return parse_qs(parsed.query)
|
||||
|
||||
def _get_request_path(self, url: str) -> str:
|
||||
"""Extract path from URL for matching."""
|
||||
if url.startswith("http"):
|
||||
parsed = urlparse(url)
|
||||
return parsed.path
|
||||
return url
|
||||
|
||||
def _match_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
query_params: Dict[str, List[str]]
|
||||
) -> Optional[Any]:
|
||||
"""Match an incoming request to a recorded request."""
|
||||
request_path = path
|
||||
|
||||
for pair in self.snapshot.requests:
|
||||
recorded_method = pair.request.method.upper()
|
||||
recorded_url = pair.request.url
|
||||
|
||||
recorded_path = self._get_request_path(recorded_url)
|
||||
|
||||
if recorded_method != method:
|
||||
continue
|
||||
|
||||
if recorded_path == request_path:
|
||||
query_match = True
|
||||
recorded_query = self._parse_query_params(recorded_url)
|
||||
for key, values in query_params.items():
|
||||
if key not in recorded_query:
|
||||
query_match = False
|
||||
break
|
||||
recorded_values = recorded_query.get(key, [])
|
||||
if set(values) != set(recorded_values):
|
||||
query_match = False
|
||||
break
|
||||
|
||||
if query_match:
|
||||
return pair
|
||||
|
||||
elif ":" in recorded_path or "{" in recorded_path:
|
||||
regex = "^" + re.sub(r":(\w+)", r"(?P<\1>[^/]+)", recorded_path) + "$"
|
||||
regex = regex.replace("{", "").replace("}", "")
|
||||
try:
|
||||
match = re.match(regex, request_path)
|
||||
if match:
|
||||
return pair
|
||||
except re.error:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _setup_routes(self) -> None:
|
||||
"""Set up Flask routes for the mock server."""
|
||||
|
||||
@self.app.route("/__snapshot-info", methods=["GET"])
|
||||
def snapshot_info():
|
||||
info = {
|
||||
"name": "mock-server",
|
||||
"description": self.snapshot.metadata.description,
|
||||
"endpoints": len(self.snapshot.requests),
|
||||
"latency_mode": self.latency_mode
|
||||
}
|
||||
return jsonify(info)
|
||||
|
||||
@self.app.route(
|
||||
"/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
||||
)
|
||||
def handle_request(subpath):
|
||||
method = flask_request.method
|
||||
path = "/" + subpath
|
||||
query_params = {k: v for k, v in parse_qs(flask_request.query_string).items()}
|
||||
|
||||
pair = self._match_request(method, path, query_params)
|
||||
|
||||
if pair is None:
|
||||
available_routes = []
|
||||
for p in self.snapshot.requests:
|
||||
url = p.request.url
|
||||
if url.startswith("http"):
|
||||
parsed = urlparse(url)
|
||||
path_only = parsed.path
|
||||
else:
|
||||
path_only = url
|
||||
available_routes.append(f"{p.request.method} {path_only}")
|
||||
|
||||
unique_routes = sorted(set(available_routes))
|
||||
|
||||
error_response = {
|
||||
"error": "Route not found",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"available_routes": unique_routes
|
||||
}
|
||||
|
||||
return jsonify(error_response), 404
|
||||
|
||||
latency = self._get_latency_ms(pair.response.latency_ms)
|
||||
|
||||
if latency > 0:
|
||||
time.sleep(latency / 1000)
|
||||
|
||||
headers = {}
|
||||
for key, value in pair.response.headers.items():
|
||||
if key.lower() not in ("transfer-encoding", "content-encoding"):
|
||||
headers[key] = value
|
||||
|
||||
content_type = headers.get("Content-Type", "application/json")
|
||||
|
||||
body = pair.response.body or ""
|
||||
|
||||
response = Response(body, status=pair.response.status_code, headers=headers)
|
||||
response.headers["Content-Type"] = content_type
|
||||
|
||||
return response
|
||||
|
||||
def run(self, debug: bool = False) -> None:
|
||||
"""Run the mock server."""
|
||||
print(f"Starting mock server on {self.host}:{self.port}")
|
||||
print(f"Latency mode: {self.latency_mode}")
|
||||
print(f"Endpoints: {len(self.snapshot.requests)}")
|
||||
print("Press Ctrl+C to stop")
|
||||
print("-" * 40)
|
||||
|
||||
self.app.run(host=self.host, port=self.port, debug=debug, threaded=True)
|
||||
|
||||
|
||||
def create_app_from_snapshot(
|
||||
snapshot_path: str,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
latency_mode: str = "original",
|
||||
fixed_latency_ms: Optional[int] = None,
|
||||
random_latency_range: Optional[Tuple[int, int]] = None
|
||||
) -> Tuple[Flask, "MockServer"]:
|
||||
"""Create a Flask app from a snapshot file.
|
||||
|
||||
Args:
|
||||
snapshot_path: Path to the snapshot file
|
||||
host: Host to bind to
|
||||
port: Port to listen on
|
||||
latency_mode: Latency mode for replay
|
||||
fixed_latency_ms: Fixed latency in milliseconds
|
||||
random_latency_range: Random latency range (min, max)
|
||||
|
||||
Returns:
|
||||
Tuple of (Flask app, MockServer instance)
|
||||
"""
|
||||
manager = SnapshotManager()
|
||||
snapshot = manager.load_snapshot(snapshot_path)
|
||||
|
||||
server = MockServer(
|
||||
snapshot=snapshot,
|
||||
host=host,
|
||||
port=port,
|
||||
latency_mode=latency_mode,
|
||||
fixed_latency_ms=fixed_latency_ms,
|
||||
random_latency_range=random_latency_range
|
||||
)
|
||||
|
||||
return server.app, server
|
||||
Reference in New Issue
Block a user