Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| Smoke-tests an extracted API catalog against live WebArena endpoints. | |
| Run locally pointing at your EC2 instance: | |
| python validate_catalog.py --host <EC2_IP> --app shopping --catalog catalogs/shopping.json | |
| python validate_catalog.py --host <EC2_IP> --all | |
| Run directly on EC2: | |
| python validate_catalog.py --host localhost --all | |
| Requirements: pip install requests | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| import time | |
| from pathlib import Path | |
| from typing import Any | |
| from urllib.parse import urlencode | |
| try: | |
| import requests | |
| requests.packages.urllib3.disable_warnings() | |
| except ImportError: | |
| print("pip install requests") | |
| sys.exit(1) | |
| # ββ Port map ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| APP_PORTS = { | |
| "shopping": 7770, | |
| "shopping_admin": 7780, | |
| "forum": 9999, | |
| "wikipedia": 8888, | |
| "map": 3000, | |
| } | |
| # ββ Test credentials (fill in before running) βββββββββββββββββββββββββββββββββ | |
| # These are the known WebArena default credentials. Update if your instance differs. | |
| CREDENTIALS = { | |
| "shopping": { | |
| "login_endpoint": "POST /rest/V1/integration/customer/token", | |
| "body": {"username": "emma.lopez@gmail.com", "password": "Password.1"}, | |
| "token_path": None, # entire response body is the token string | |
| "header": "Authorization", | |
| "header_prefix": "Bearer ", | |
| }, | |
| "shopping_admin": { | |
| "login_endpoint": "POST /rest/V1/integration/admin/token", | |
| "body": {"username": "admin", "password": "admin1234"}, | |
| "token_path": None, | |
| "header": "Authorization", | |
| "header_prefix": "Bearer ", | |
| }, | |
| "forum": { | |
| "login_endpoint": "POST /login_check", | |
| "form": {"_username": "MarvelsGrantMan136", "_password": "test1234"}, | |
| "uses_session": True, | |
| }, | |
| } | |
| # ββ Minimal test values for path/query params βββββββββββββββββββββββββββββββββ | |
| # When an endpoint needs a param that comes from a prior call (PREV_CALL) or task, | |
| # we substitute a safe dummy value just to see if the endpoint exists and responds. | |
| DUMMY_VALUES = { | |
| "string": "test", | |
| "integer": "1", | |
| "number": "1", | |
| "boolean": "true", | |
| "int": "1", | |
| "Int": "1", | |
| "String": "test", | |
| } | |
| # ββ Colour helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| GREEN = "\033[92m" | |
| YELLOW = "\033[93m" | |
| RED = "\033[91m" | |
| RESET = "\033[0m" | |
| GREY = "\033[90m" | |
| def col(text, colour): return f"{colour}{text}{RESET}" | |
| # ββ Validator βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Validator: | |
| def __init__(self, host: str, app: str, catalog: list[dict], timeout: int = 10): | |
| self.host = host | |
| self.app = app | |
| self.port = APP_PORTS[app] | |
| self.base = f"http://{host}:{self.port}" | |
| self.catalog = catalog | |
| self.timeout = timeout | |
| self.session = requests.Session() | |
| self.auth_headers: dict[str, str] = {} | |
| self.results: list[dict] = [] | |
| # ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def authenticate(self): | |
| creds = CREDENTIALS.get(self.app) | |
| if not creds: | |
| return | |
| parts = creds["login_endpoint"].split(" ", 1) | |
| method = parts[0].upper() | |
| path = parts[1] | |
| url = self.base + path | |
| try: | |
| if creds.get("uses_session"): | |
| resp = self.session.post(url, data=creds.get("form", {}), | |
| timeout=self.timeout, allow_redirects=True) | |
| if resp.status_code in (200, 302): | |
| print(col(f" [auth] forum session cookie set", GREEN)) | |
| else: | |
| print(col(f" [auth] forum login returned {resp.status_code}", YELLOW)) | |
| else: | |
| resp = requests.request(method, url, json=creds.get("body"), | |
| timeout=self.timeout) | |
| if resp.status_code == 200: | |
| token = resp.json() if creds["token_path"] else resp.text.strip().strip('"') | |
| prefix = creds.get("header_prefix", "Bearer ") | |
| self.auth_headers[creds["header"]] = prefix + token | |
| print(col(f" [auth] got token for {self.app}", GREEN)) | |
| else: | |
| print(col(f" [auth] login failed ({resp.status_code}) β authenticated endpoints will be skipped", YELLOW)) | |
| except Exception as e: | |
| print(col(f" [auth] {e}", RED)) | |
| # ββ Single endpoint test ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_endpoint(self, entry: dict) -> dict: | |
| api_type = entry.get("api_type", "rest") | |
| endpoint = entry.get("endpoint", "") | |
| # WebSocket β not testable with HTTP, skip | |
| if api_type == "websocket": | |
| return {"endpoint": endpoint, "api_type": api_type, "status": "SKIP", | |
| "notes": "WebSocket β skipped (requires WS client)"} | |
| # GraphQL β send introspection or a minimal operation | |
| if api_type == "graphql": | |
| return self._test_graphql(entry) | |
| # REST or form | |
| parts = endpoint.split(" ", 1) | |
| if len(parts) != 2: | |
| return {"endpoint": endpoint, "api_type": api_type, "status": "SKIP", "notes": "cannot parse endpoint"} | |
| method = parts[0].upper() | |
| path = parts[1] | |
| # Substitute path params with dummies | |
| path = self._fill_path(path, entry.get("path_params", {})) | |
| # Build query string | |
| query = self._build_query(entry.get("query_params", {})) | |
| url = self.base + path + ("?" + urlencode(query) if query else "") | |
| # Auth | |
| auth_type = entry.get("auth", "none") | |
| if "bearer_token" in auth_type or "admin_bearer_token" in auth_type: | |
| if not self.auth_headers: | |
| return {"endpoint": endpoint, "api_type": api_type, "status": "SKIP", | |
| "notes": "auth required but login failed/not configured"} | |
| headers = {"Content-Type": "application/json", **self.auth_headers} | |
| elif "csrf" in auth_type: | |
| if not self.session.cookies: | |
| return {"endpoint": endpoint, "api_type": api_type, "status": "SKIP", | |
| "notes": "session+csrf required but login not configured"} | |
| headers = {} | |
| elif "session_cookie" in auth_type: | |
| headers = {} | |
| else: | |
| headers = {"Content-Type": "application/json"} | |
| # Body | |
| body = None | |
| body_params = entry.get("body_params") or entry.get("form_params") | |
| if method in ("POST", "PUT", "PATCH") and body_params: | |
| if entry.get("content_type", "").startswith("application/x-www"): | |
| body = {k: DUMMY_VALUES.get(v.get("type", "string"), "test") | |
| for k, v in body_params.items() if v.get("source") == "TASK_SPEC"} | |
| else: | |
| body = {k: DUMMY_VALUES.get(v.get("type", "string"), "test") | |
| for k, v in body_params.items() if v.get("source") == "TASK_SPEC"} | |
| # Fire request | |
| try: | |
| start = time.time() | |
| if "csrf" in auth_type or "session_cookie" in auth_type: | |
| resp = self.session.request(method, url, headers=headers, | |
| json=body if api_type == "rest" else None, | |
| data=body if api_type == "form" else None, | |
| timeout=self.timeout, allow_redirects=False) | |
| else: | |
| resp = requests.request(method, url, headers=headers, | |
| json=body if api_type == "rest" else None, | |
| data=body if api_type == "form" else None, | |
| timeout=self.timeout, allow_redirects=False) | |
| elapsed = int((time.time() - start) * 1000) | |
| status_code = resp.status_code | |
| # Decide pass/fail | |
| # 200-299 = clear pass; 400/422 = endpoint exists but our dummy params are wrong (expected) | |
| # 401/403 = auth issue; 404 = endpoint not found; 500 = server error | |
| if 200 <= status_code < 300: | |
| outcome = "PASS" | |
| elif status_code in (400, 405, 422): | |
| outcome = "WARN" # endpoint reached, validation error from dummy params β expected | |
| elif status_code in (401, 403): | |
| outcome = "AUTH" # exists but needs auth we didn't provide | |
| elif status_code == 404: | |
| outcome = "FAIL" # not found | |
| elif status_code in (301, 302): | |
| outcome = "REDIR" # redirect β endpoint exists | |
| else: | |
| outcome = f"HTTP{status_code}" | |
| snippet = resp.text[:120].replace("\n", " ") if resp.text else "" | |
| return {"endpoint": endpoint, "api_type": api_type, "status": outcome, | |
| "http_code": status_code, "ms": elapsed, "snippet": snippet} | |
| except requests.exceptions.ConnectionError: | |
| return {"endpoint": endpoint, "api_type": api_type, "status": "CONN_ERR", | |
| "notes": "could not connect β is the app running?"} | |
| except requests.exceptions.Timeout: | |
| return {"endpoint": endpoint, "api_type": api_type, "status": "TIMEOUT"} | |
| except Exception as e: | |
| return {"endpoint": endpoint, "api_type": api_type, "status": "ERROR", "notes": str(e)} | |
| def _test_graphql(self, entry: dict) -> dict: | |
| url = self.base + "/graphql" | |
| op = entry.get("operation_name", "") | |
| op_type = entry.get("operation_type", "query") | |
| # Send a minimal introspection or operation | |
| if op_type == "query": | |
| payload = {"query": f"{{ __typename }}"} # simplest valid query | |
| else: | |
| payload = {"query": f"mutation {{ __typename }}"} | |
| try: | |
| resp = requests.post(url, json=payload, timeout=self.timeout) | |
| snippet = resp.text[:120].replace("\n", " ") | |
| outcome = "PASS" if resp.status_code == 200 else f"HTTP{resp.status_code}" | |
| return {"endpoint": f"POST /graphql ({op})", "api_type": "graphql", | |
| "status": outcome, "http_code": resp.status_code, "snippet": snippet} | |
| except Exception as e: | |
| return {"endpoint": f"POST /graphql ({op})", "api_type": "graphql", | |
| "status": "ERROR", "notes": str(e)} | |
| def _fill_path(self, path: str, path_params: dict) -> str: | |
| for name in path_params: | |
| param_type = path_params[name].get("type", "string") | |
| dummy = DUMMY_VALUES.get(param_type, "test") | |
| path = path.replace("{" + name + "}", dummy) | |
| return path | |
| def _build_query(self, query_params: dict) -> dict: | |
| out = {} | |
| for name, meta in query_params.items(): | |
| if meta.get("source") in ("TASK_SPEC", "STATIC"): | |
| val = meta.get("value") or DUMMY_VALUES.get(meta.get("type", "string"), "test") | |
| out[name] = val | |
| return out | |
| # ββ Run all βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run(self): | |
| print(f"\n{'β'*60}") | |
| print(f" {self.app.upper()} β {self.base} ({len(self.catalog)} endpoints)") | |
| print(f"{'β'*60}") | |
| self.authenticate() | |
| for entry in self.catalog: | |
| result = self.test_endpoint(entry) | |
| self.results.append(result) | |
| self._print_results() | |
| return self.results | |
| def _print_results(self): | |
| print() | |
| w = max(len(r["endpoint"]) for r in self.results) + 2 | |
| header = f" {'ENDPOINT':<{w}} {'TYPE':<10} {'STATUS':<10} {'CODE':<6} {'ms':<6} SNIPPET" | |
| print(header) | |
| print(" " + "β" * (len(header) - 2)) | |
| counts = {"PASS": 0, "WARN": 0, "FAIL": 0, "SKIP": 0, "other": 0} | |
| for r in self.results: | |
| s = r["status"] | |
| code = str(r.get("http_code", "")) | |
| ms = str(r.get("ms", "")) | |
| snip = r.get("snippet", r.get("notes", ""))[:60] | |
| if s == "PASS": | |
| colour, counts["PASS"] = GREEN, counts["PASS"] + 1 | |
| elif s in ("WARN", "REDIR", "AUTH"): | |
| colour, counts["WARN"] = YELLOW, counts["WARN"] + 1 | |
| elif s in ("FAIL", "CONN_ERR", "TIMEOUT", "ERROR") or s.startswith("HTTP"): | |
| colour, counts["FAIL"] = RED, counts["FAIL"] + 1 | |
| else: | |
| colour, counts["SKIP"] = GREY, counts["SKIP"] + 1 | |
| line = f" {r['endpoint']:<{w}} {r['api_type']:<10} {col(s, colour):<20} {code:<6} {ms:<6} {col(snip, GREY)}" | |
| print(line) | |
| total = len(self.results) | |
| print() | |
| print(f" Results: {col(str(counts['PASS']), GREEN)} pass " | |
| f"{col(str(counts['WARN']), YELLOW)} warn " | |
| f"{col(str(counts['FAIL']), RED)} fail " | |
| f"{col(str(counts['SKIP']), GREY)} skip " | |
| f"/ {total} total") | |
| print() | |
| # ββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_catalog(path: str) -> list[dict]: | |
| p = Path(path) | |
| if not p.exists(): | |
| print(f"catalog not found: {path}") | |
| sys.exit(1) | |
| data = json.loads(p.read_text()) | |
| if isinstance(data, list): | |
| return data | |
| # some catalogs are wrapped in an object | |
| for v in data.values(): | |
| if isinstance(v, list): | |
| return v | |
| return [] | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Smoke-test an API catalog against live WebArena apps") | |
| parser.add_argument("--host", required=True, help="EC2 IP or hostname") | |
| parser.add_argument("--app", help="single app: shopping|shopping_admin|forum|wikipedia|map") | |
| parser.add_argument("--catalog", help="path to api_catalog.json (required if --app is set)") | |
| parser.add_argument("--all", action="store_true", | |
| help="run all apps; looks for catalogs/shopping.json etc.") | |
| parser.add_argument("--timeout", type=int, default=10) | |
| args = parser.parse_args() | |
| if args.all: | |
| catalog_dir = Path("catalogs") | |
| apps = { | |
| "shopping": catalog_dir / "shopping.json", | |
| "shopping_admin": catalog_dir / "shopping_admin.json", | |
| "forum": catalog_dir / "forum.json", | |
| "wikipedia": catalog_dir / "wikipedia.json", | |
| "map": catalog_dir / "osm.json", | |
| } | |
| all_results = {} | |
| for app, cat_path in apps.items(): | |
| if cat_path.exists(): | |
| catalog = load_catalog(str(cat_path)) | |
| v = Validator(args.host, app, catalog, args.timeout) | |
| all_results[app] = v.run() | |
| else: | |
| print(col(f" {app}: catalog not found at {cat_path} β skipping", YELLOW)) | |
| # Summary | |
| print(f"\n{'β'*60}") | |
| print(" SUMMARY") | |
| print(f"{'β'*60}") | |
| for app, results in all_results.items(): | |
| total = len(results) | |
| passed = sum(1 for r in results if r["status"] == "PASS") | |
| warned = sum(1 for r in results if r["status"] in ("WARN", "REDIR", "AUTH")) | |
| failed = sum(1 for r in results if r["status"] in ("FAIL", "CONN_ERR", "TIMEOUT", "ERROR") or r["status"].startswith("HTTP")) | |
| print(f" {app:<20} {col(str(passed), GREEN)}/{total} pass " | |
| f"{col(str(warned), YELLOW)} warn {col(str(failed), RED)} fail") | |
| print() | |
| elif args.app: | |
| if not args.catalog: | |
| parser.error("--catalog is required when using --app") | |
| catalog = load_catalog(args.catalog) | |
| v = Validator(args.host, args.app, catalog, args.timeout) | |
| v.run() | |
| else: | |
| parser.error("use --app + --catalog, or --all") | |
| if __name__ == "__main__": | |
| main() | |