| """ |
| mock_api.py β Dynamic mock API for AdaptiveWorld environment. |
| |
| All business endpoints read their field names, endpoint paths, status values, |
| and behaviour from DYNAMIC_CONFIG. The /_admin/mutate endpoint lets the |
| environment silently mutate DYNAMIC_CONFIG mid-episode to simulate API drift. |
| |
| Domains covered: |
| e-commerce β /orders, /products, /cart, /discount |
| hotel β /rooms/book, /v2/rooms/book |
| flight β /flights/search |
| insurance β /claims, /claims/v2 |
| """ |
|
|
| import time |
| import uuid |
| from fastapi import APIRouter, Header, HTTPException, Request, Query |
| from pydantic import BaseModel |
| from typing import Optional |
|
|
| router = APIRouter(prefix="/mock_api", tags=["mock"]) |
|
|
| |
| _issued_tokens: set = set() |
| _request_log: dict = {} |
| _items_db: dict = {} |
| _orders_db: dict = {} |
| _bookings_db: dict = {} |
| _claims_db: dict = {} |
|
|
| |
| DYNAMIC_CONFIG: dict = { |
| |
| "order_field": "qty", |
| "required_extra": None, |
| "order_status": "confirmed", |
| "noisy_errors": False, |
|
|
| |
| "rooms_endpoint": "/mock_api/rooms/book", |
|
|
| |
| "auth_scheme": "Bearer", |
|
|
| |
| "claims_endpoint": "/mock_api/claims", |
| "claims_id_field": "policy_id", |
| "claims_amount_field": "amount", |
| "claims_require_date": False, |
|
|
| |
| "discount_requires_membership": False, |
|
|
| |
| "product_key": "products", |
| "product_id_field": "id", |
| "product_price_field": "price", |
|
|
| |
| "product_category": "electronics", |
|
|
| |
| "max_rooms_per_request": 0, |
| "bulk_booking_allowed": True, |
|
|
| |
| "demo_token": "demo_token_123", |
| "client_id": "abc", |
| "client_secret": "xyz", |
| } |
|
|
| LOG_PAGES = { |
| None: {"items": list(range(10)), "next_cursor": "cur_abc", "has_more": True}, |
| "cur_abc": {"items": list(range(10, 20)), "next_cursor": "cur_def", "has_more": True}, |
| "cur_def": {"items": list(range(20, 25)), "next_cursor": None, "has_more": False}, |
| } |
|
|
| |
|
|
| @router.post("/_admin/mutate") |
| async def admin_mutate(config: Optional[dict] = None): |
| """ |
| Called by AdaptiveWorldEnvironment to inject drift mid-episode. |
| Silently updates DYNAMIC_CONFIG β no notification to the agent. |
| """ |
| _issued_tokens.clear() |
| _request_log.clear() |
| _items_db.clear() |
| _orders_db.clear() |
| _bookings_db.clear() |
| _claims_db.clear() |
| if config: |
| DYNAMIC_CONFIG.update(config) |
| return {"status": "mutated", "applied": DYNAMIC_CONFIG} |
|
|
|
|
| @router.post("/_admin/reset") |
| async def admin_reset(config: Optional[dict] = None): |
| """Alias for mutate β also used for episode resets.""" |
| return await admin_mutate(config) |
|
|
| |
|
|
| class TokenRequest(BaseModel): |
| client_id: str |
| client_secret: str |
|
|
|
|
| @router.post("/auth/token") |
| async def get_token(body: TokenRequest): |
| if (body.client_id == DYNAMIC_CONFIG["client_id"] and |
| body.client_secret == DYNAMIC_CONFIG["client_secret"]): |
| token = f"tok_{int(time.time())}" |
| _issued_tokens.add(token) |
| return {"access_token": token, "expires_in": 3600} |
| raise HTTPException(status_code=401, detail="Invalid client credentials") |
|
|
|
|
| def _check_auth(authorization: Optional[str]) -> bool: |
| scheme = DYNAMIC_CONFIG.get("auth_scheme", "Bearer") |
| dt = DYNAMIC_CONFIG["demo_token"] |
| if not authorization: |
| return False |
| if not authorization.startswith(f"{scheme} "): |
| return False |
| token = authorization.split(" ", 1)[1] |
| return token in _issued_tokens or token == dt |
|
|
| |
|
|
| @router.get("/users") |
| async def get_users(authorization: Optional[str] = Header(default=None)): |
| scheme = DYNAMIC_CONFIG.get("auth_scheme", "Bearer") |
| dt = DYNAMIC_CONFIG["demo_token"] |
| if not _check_auth(authorization): |
| raise HTTPException( |
| status_code=401, |
| detail=f"Authorization header missing or malformed. " |
| f"Expected format: {scheme} <token>. Use '{dt}' for testing." |
| ) |
| return {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} |
|
|
| |
|
|
| @router.post("/orders") |
| async def create_order(request: Request): |
| ct = request.headers.get("content-type", "") |
| if "application/json" not in ct: |
| raise HTTPException(status_code=415, detail="Content-Type must be application/json") |
|
|
| body = await request.json() |
|
|
| |
| required_field = DYNAMIC_CONFIG.get("order_field", "qty") |
| extra_field = DYNAMIC_CONFIG.get("required_extra") |
|
|
| if required_field not in body: |
| detail = f"Missing required field: '{required_field}'" |
| if DYNAMIC_CONFIG.get("noisy_errors"): |
| detail = "Validation error in request body" |
| raise HTTPException(status_code=422, detail=detail) |
|
|
| if extra_field and extra_field not in body: |
| detail = f"Missing required field: '{extra_field}'" |
| if DYNAMIC_CONFIG.get("noisy_errors"): |
| detail = "Validation error in request body" |
| raise HTTPException(status_code=422, detail=detail) |
|
|
| order_id = str(uuid.uuid4()) |
| qty_value = body.get(required_field, body.get("qty", body.get("quantity", 1))) |
| |
| status_value = DYNAMIC_CONFIG.get("order_status", "confirmed") |
|
|
| order = { |
| "order_id": order_id, |
| "status": status_value, |
| "product_id": body.get("product_id"), |
| required_field: qty_value, |
| } |
| _orders_db[order_id] = order |
| return order |
|
|
|
|
| @router.get("/orders/{order_id}") |
| async def get_order(order_id: str): |
| if order_id not in _orders_db: |
| raise HTTPException(status_code=404, detail="Order not found") |
| return _orders_db[order_id] |
|
|
| |
|
|
| @router.get("/products") |
| async def search_products(q: Optional[str] = Query(default=None)): |
| """ |
| Returns product list. response_structure drift changes the key and field names. |
| """ |
| product_key = DYNAMIC_CONFIG.get("product_key", "products") |
| id_field = DYNAMIC_CONFIG.get("product_id_field", "id") |
| price_field = DYNAMIC_CONFIG.get("product_price_field", "price") |
| category = DYNAMIC_CONFIG.get("product_category", "electronics") |
|
|
| products = [ |
| {id_field: 1, price_field: 100, "name": "Widget A", "category": category}, |
| {id_field: 2, price_field: 250, "name": "Widget B", "category": category}, |
| {id_field: 5, price_field: 75, "name": "Widget C", "category": category}, |
| ] |
| if q: |
| products = [p for p in products if q.lower() in p.get("name", "").lower()] |
| return {product_key: products, "total": len(products)} |
|
|
| |
|
|
| @router.post("/cart/add") |
| async def add_to_cart(request: Request): |
| ct = request.headers.get("content-type", "") |
| if "application/json" not in ct: |
| raise HTTPException(status_code=415, detail="Content-Type must be application/json") |
| body = await request.json() |
| id_field = DYNAMIC_CONFIG.get("product_id_field", "id") |
| pid = body.get("product_id") or body.get(id_field) |
| if not pid: |
| raise HTTPException(status_code=422, detail="Missing product_id in body") |
| return {"cart_id": str(uuid.uuid4()), "product_id": pid, "added": True} |
|
|
| |
|
|
| @router.post("/discount/apply") |
| async def apply_discount(request: Request): |
| ct = request.headers.get("content-type", "") |
| if "application/json" not in ct: |
| raise HTTPException(status_code=415, detail="Content-Type must be application/json") |
| body = await request.json() |
|
|
| code = body.get("code") or body.get("discount_code") |
| if not code: |
| raise HTTPException(status_code=422, detail="Missing discount code") |
|
|
| |
| if DYNAMIC_CONFIG.get("discount_requires_membership"): |
| tier = body.get("membership_tier") |
| if tier != "gold": |
| raise HTTPException( |
| status_code=403, |
| detail={ |
| "error": "Discount ineligible", |
| "reason": "Discount codes now require membership_tier: 'gold' in the request body.", |
| "policy": "membership_required_for_discounts" |
| } |
| ) |
| return {"discount_applied": True, "code": code, "amount": 20} |
|
|
| |
|
|
| @router.post("/rooms/book") |
| async def book_room_v1(request: Request): |
| """ |
| V1 booking endpoint. After endpoint_version drift, this returns 404 |
| because the environment will call /v2/rooms/book which is the new path. |
| The environment checks rooms_endpoint to decide which is active. |
| """ |
| current_endpoint = DYNAMIC_CONFIG.get("rooms_endpoint", "/mock_api/rooms/book") |
| if current_endpoint != "/mock_api/rooms/book": |
| raise HTTPException( |
| status_code=404, |
| detail="This endpoint has moved. Please check the API documentation." |
| ) |
| return await _do_room_booking(request) |
|
|
|
|
| @router.post("/v2/rooms/book") |
| async def book_room_v2(request: Request): |
| """V2 booking endpoint β active after endpoint_version drift.""" |
| current_endpoint = DYNAMIC_CONFIG.get("rooms_endpoint", "/mock_api/rooms/book") |
| if current_endpoint != "/mock_api/v2/rooms/book": |
| raise HTTPException( |
| status_code=404, |
| detail="Endpoint not found." |
| ) |
| return await _do_room_booking(request) |
|
|
|
|
| async def _do_room_booking(request: Request): |
| ct = request.headers.get("content-type", "") |
| if "application/json" not in ct: |
| raise HTTPException(status_code=415, detail="Content-Type must be application/json") |
| body = await request.json() |
|
|
| nights = body.get("nights") |
| room_type = body.get("room_type", "standard") |
|
|
| |
| qty = body.get("qty", body.get("quantity", 1)) |
| max_rooms = DYNAMIC_CONFIG.get("max_rooms_per_request", 0) |
| if max_rooms and int(qty) > max_rooms: |
| raise HTTPException( |
| status_code=429, |
| detail={ |
| "error": "Booking limit exceeded", |
| "reason": f"Maximum {max_rooms} room(s) per request. Bulk booking has been removed.", |
| "max_per_request": max_rooms, |
| } |
| ) |
|
|
| if not nights: |
| raise HTTPException(status_code=422, detail="Missing required field: 'nights'") |
|
|
| booking_id = str(uuid.uuid4()) |
| booking = {"booking_id": booking_id, "nights": nights, "room_type": room_type, |
| "confirmation": f"CONF-{booking_id[:8].upper()}"} |
| _bookings_db[booking_id] = booking |
| return booking |
|
|
| |
|
|
| @router.get("/flights/search") |
| async def search_flights( |
| origin: Optional[str] = Query(default=None), |
| destination: Optional[str] = Query(default=None), |
| depart: Optional[str] = Query(default=None), |
| authorization: Optional[str] = Header(default=None), |
| ): |
| """Requires auth. Auth scheme changes on auth drift.""" |
| if not _check_auth(authorization): |
| scheme = DYNAMIC_CONFIG.get("auth_scheme", "Bearer") |
| raise HTTPException( |
| status_code=401, |
| detail=f"Unauthorized. Expected Authorization: {scheme} <token>. " |
| f"Auth scheme may have changed β check /openapi.json." |
| ) |
| if not origin or not destination: |
| raise HTTPException(status_code=422, detail="Missing required params: origin, destination") |
| return { |
| "flights": [ |
| {"flight_id": "FL001", "origin": origin, "destination": destination, |
| "depart": depart or "tomorrow", "price": 4500, "seats": 12}, |
| {"flight_id": "FL002", "origin": origin, "destination": destination, |
| "depart": depart or "tomorrow", "price": 5200, "seats": 4}, |
| ] |
| } |
|
|
| |
|
|
| @router.post("/claims") |
| async def file_claim_v1(request: Request): |
| """V1 claims endpoint. Moves to /claims/v2 after endpoint_version drift.""" |
| current_endpoint = DYNAMIC_CONFIG.get("claims_endpoint", "/mock_api/claims") |
| if current_endpoint != "/mock_api/claims": |
| raise HTTPException(status_code=404, detail="Claims endpoint has moved. Check API docs.") |
| return await _do_file_claim(request) |
|
|
|
|
| @router.post("/claims/v2") |
| async def file_claim_v2(request: Request): |
| """V2 claims endpoint β active after drift.""" |
| current_endpoint = DYNAMIC_CONFIG.get("claims_endpoint", "/mock_api/claims") |
| if current_endpoint != "/mock_api/claims/v2": |
| raise HTTPException(status_code=404, detail="Endpoint not found.") |
| return await _do_file_claim(request) |
|
|
|
|
| async def _do_file_claim(request: Request): |
| ct = request.headers.get("content-type", "") |
| if "application/json" not in ct: |
| raise HTTPException(status_code=415, detail="Content-Type must be application/json") |
| body = await request.json() |
|
|
| id_field = DYNAMIC_CONFIG.get("claims_id_field", "policy_id") |
| amount_field = DYNAMIC_CONFIG.get("claims_amount_field", "amount") |
| require_date = DYNAMIC_CONFIG.get("claims_require_date", False) |
|
|
| if id_field not in body: |
| raise HTTPException(status_code=422, |
| detail=f"Missing required field: '{id_field}'") |
| if amount_field not in body: |
| raise HTTPException(status_code=422, |
| detail=f"Missing required field: '{amount_field}'") |
| if require_date and "incident_date" not in body: |
| raise HTTPException(status_code=422, |
| detail="Missing required field: 'incident_date'") |
|
|
| claim_id = str(uuid.uuid4()) |
| claim = { |
| "claim_id": claim_id, |
| id_field: body[id_field], |
| amount_field: body[amount_field], |
| "status": "pending", |
| } |
| _claims_db[claim_id] = claim |
| return claim |
|
|
|
|
| @router.get("/claims/{claim_id}") |
| async def get_claim(claim_id: str): |
| if claim_id not in _claims_db: |
| raise HTTPException(status_code=404, detail="Claim not found") |
| return _claims_db[claim_id] |
|
|
| |
|
|
| @router.get("/search") |
| async def search(q: Optional[str] = Query(default=None)): |
| if not q: |
| raise HTTPException(status_code=422, |
| detail=[{"loc": ["query", "q"], "msg": "field required"}]) |
| return {"results": [{"title": f"Result for {q}", "score": 0.95}], "total": 1} |
|
|
|
|
| @router.get("/rate_limited") |
| async def rate_limited(request: Request, |
| x_retry_after: Optional[str] = Header(default=None)): |
| client_id = request.client.host if request.client else "default" |
| now = time.time() |
| window = [t for t in _request_log.get(client_id, []) if now - t < 5] |
| if len(window) >= 3 and not x_retry_after: |
| _request_log[client_id] = window |
| raise HTTPException( |
| status_code=429, |
| detail={"error": "Rate limit exceeded", |
| "hint": "Add X-Retry-After header with value 2 to override."}, |
| headers={"Retry-After": "2"} |
| ) |
| window.append(now) |
| _request_log[client_id] = window |
| return {"data": "rate_limited_resource", "requests_in_window": len(window)} |
|
|
|
|
| @router.get("/logs") |
| async def get_logs(cursor: Optional[str] = Query(default=None)): |
| if cursor not in LOG_PAGES: |
| raise HTTPException(status_code=400, detail=f"Invalid cursor: {cursor}") |
| return LOG_PAGES[cursor] |
|
|
|
|
| @router.get("/openapi-schema") |
| async def get_schema_summary(): |
| """Returns a simplified summary of the current API contract.""" |
| return { |
| "order_field": DYNAMIC_CONFIG.get("order_field", "qty"), |
| "required_extra": DYNAMIC_CONFIG.get("required_extra"), |
| "order_status": DYNAMIC_CONFIG.get("order_status", "confirmed"), |
| "rooms_endpoint": DYNAMIC_CONFIG.get("rooms_endpoint", "/mock_api/rooms/book"), |
| "auth_scheme": DYNAMIC_CONFIG.get("auth_scheme", "Bearer"), |
| "claims_endpoint": DYNAMIC_CONFIG.get("claims_endpoint", "/mock_api/claims"), |
| "claims_id_field": DYNAMIC_CONFIG.get("claims_id_field", "policy_id"), |
| "claims_amount_field": DYNAMIC_CONFIG.get("claims_amount_field", "amount"), |
| "product_key": DYNAMIC_CONFIG.get("product_key", "products"), |
| "product_id_field": DYNAMIC_CONFIG.get("product_id_field", "id"), |
| "product_price_field": DYNAMIC_CONFIG.get("product_price_field", "price"), |
| "max_rooms_per_request": DYNAMIC_CONFIG.get("max_rooms_per_request", 0), |
| } |
|
|