fastmock-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmock/__init__.py +0 -0
- fastmock/api/__init__.py +0 -0
- fastmock/api/admin_routes.py +100 -0
- fastmock/api/dynamic_router.py +87 -0
- fastmock/api/middlewares.py +46 -0
- fastmock/api/ws_router.py +74 -0
- fastmock/cli.py +37 -0
- fastmock/core/__init__.py +0 -0
- fastmock/core/config.py +12 -0
- fastmock/core/state.py +42 -0
- fastmock/main.py +94 -0
- fastmock/services/__init__.py +0 -0
- fastmock/services/crud_manager.py +57 -0
- fastmock/services/data_generator.py +80 -0
- fastmock/services/openapi_parser.py +51 -0
- fastmock_api-0.1.0.dist-info/METADATA +71 -0
- fastmock_api-0.1.0.dist-info/RECORD +19 -0
- fastmock_api-0.1.0.dist-info/WHEEL +4 -0
- fastmock_api-0.1.0.dist-info/entry_points.txt +2 -0
fastmock/__init__.py
ADDED
|
File without changes
|
fastmock/api/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import jsonref
|
|
2
|
+
import yaml
|
|
3
|
+
from fastapi import APIRouter, File, UploadFile
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from fastmock.core.state import app_state
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/_admin", tags=["Admin API | Управление сервером"])
|
|
9
|
+
|
|
10
|
+
class ChaosConfig(BaseModel):
|
|
11
|
+
chaos_delay_ms: int = Field(
|
|
12
|
+
0,
|
|
13
|
+
description="**EN:** Global delay in milliseconds for all requests.\n\n**RU:** Глобальная задержка в миллисекундах для всех запросов.",
|
|
14
|
+
json_schema_extra={"example": 500}
|
|
15
|
+
)
|
|
16
|
+
chaos_error_rate: float = Field(
|
|
17
|
+
0.0,
|
|
18
|
+
description="**EN:** Probability of random errors (from 0.0 to 1.0).\n\n**RU:** Вероятность случайных ошибок (от 0.0 до 1.0).",
|
|
19
|
+
json_schema_extra={"example": 0.2}
|
|
20
|
+
)
|
|
21
|
+
chaos_allowed_errors: list[int] = Field(
|
|
22
|
+
[500, 502, 503],
|
|
23
|
+
description="**EN:** List of allowed HTTP status codes for chaos errors.\n\n**RU:** Список разрешенных HTTP-статусов для случайных ошибок.",
|
|
24
|
+
json_schema_extra={"example": [400, 403, 500, 503]}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@router.get(
|
|
28
|
+
"/config",
|
|
29
|
+
summary="Get Chaos Config | Получить настройки хаоса",
|
|
30
|
+
description="**EN:** Returns the current global network throttling and chaos engineering settings.\n\n**RU:** Возвращает текущие глобальные настройки задержек сети и случайных ошибок."
|
|
31
|
+
)
|
|
32
|
+
async def get_config() -> ChaosConfig:
|
|
33
|
+
return ChaosConfig(
|
|
34
|
+
chaos_delay_ms=app_state.chaos_delay_ms,
|
|
35
|
+
chaos_error_rate=app_state.chaos_error_rate,
|
|
36
|
+
chaos_allowed_errors=app_state.chaos_allowed_errors
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@router.put(
|
|
40
|
+
"/config",
|
|
41
|
+
summary="Update Chaos Config | Обновить настройки хаоса",
|
|
42
|
+
description="**EN:** Updates global delays and error rates. These settings apply to all mock routes.\n\n**RU:** Обновляет глобальные задержки и частоту ошибок. Эти настройки применяются ко всем мок-эндпоинтам."
|
|
43
|
+
)
|
|
44
|
+
async def set_config(config: ChaosConfig):
|
|
45
|
+
app_state.chaos_delay_ms = config.chaos_delay_ms
|
|
46
|
+
app_state.chaos_error_rate = config.chaos_error_rate
|
|
47
|
+
app_state.chaos_allowed_errors = config.chaos_allowed_errors
|
|
48
|
+
return {"message": "Chaos configuration updated", "config": config.model_dump()}
|
|
49
|
+
|
|
50
|
+
@router.delete(
|
|
51
|
+
"/state",
|
|
52
|
+
summary="Clear In-Memory State | Очистить базу данных",
|
|
53
|
+
description="**EN:** Clears the in-memory database used for CRUD operations (POST/PUT/GET).\n\n**RU:** Очищает In-Memory базу данных, используемую для CRUD операций (сохраненные мок-сущности)."
|
|
54
|
+
)
|
|
55
|
+
async def clear_state():
|
|
56
|
+
app_state.clear_db()
|
|
57
|
+
return {"message": "In-Memory database cleared."}
|
|
58
|
+
|
|
59
|
+
@router.post(
|
|
60
|
+
"/specs",
|
|
61
|
+
summary="Upload OpenAPI Spec | Загрузить спецификацию",
|
|
62
|
+
description="**EN:** Upload an OpenAPI 3.0/3.1 specification file (YAML or JSON) to dynamically generate mock routes.\n\n**RU:** Загрузите файл спецификации OpenAPI 3.0/3.1 (YAML или JSON) для динамической генерации мок-роутов."
|
|
63
|
+
)
|
|
64
|
+
async def upload_spec(file: UploadFile = File(..., description="**EN:** OpenAPI spec file (.yaml or .json)\n\n**RU:** Файл спецификации (.yaml или .json)")):
|
|
65
|
+
content = await file.read()
|
|
66
|
+
try:
|
|
67
|
+
raw_spec = yaml.safe_load(content.decode("utf-8"))
|
|
68
|
+
resolved_spec = jsonref.replace_refs(raw_spec)
|
|
69
|
+
app_state.spec = resolved_spec # type: ignore
|
|
70
|
+
title = app_state.spec.get("info", {}).get("title", "Unknown") if isinstance(app_state.spec, dict) else "Unknown"
|
|
71
|
+
return {"message": f"Spec '{title}' loaded successfully."}
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return {"error": f"Failed to parse spec: {e!s}"}
|
|
74
|
+
|
|
75
|
+
@router.delete(
|
|
76
|
+
"/specs",
|
|
77
|
+
summary="Clear OpenAPI Spec | Удалить спецификацию",
|
|
78
|
+
description="**EN:** Removes the currently loaded OpenAPI specification and disables all mock routes.\n\n**RU:** Удаляет текущую загруженную спецификацию OpenAPI и отключает все мок-роуты."
|
|
79
|
+
)
|
|
80
|
+
async def clear_spec():
|
|
81
|
+
app_state.spec = None
|
|
82
|
+
return {"message": "OpenAPI specification cleared. No mock routes are currently active."}
|
|
83
|
+
|
|
84
|
+
@router.get(
|
|
85
|
+
"/routes",
|
|
86
|
+
summary="List Mocked Routes | Список мок-роутов",
|
|
87
|
+
description="**EN:** Returns a list of all dynamically generated mock endpoints currently active.\n\n**RU:** Возвращает список всех динамически сгенерированных мок-эндпоинтов, которые сейчас готовы к работе."
|
|
88
|
+
)
|
|
89
|
+
async def list_routes():
|
|
90
|
+
if not app_state.spec or "paths" not in app_state.spec:
|
|
91
|
+
return {"routes": []}
|
|
92
|
+
|
|
93
|
+
routes = []
|
|
94
|
+
for path, path_item in app_state.spec["paths"].items():
|
|
95
|
+
if isinstance(path_item, dict):
|
|
96
|
+
for method in path_item:
|
|
97
|
+
if method.lower() in ["get", "post", "put", "delete", "patch"]:
|
|
98
|
+
routes.append(f"{method.upper()} {path}")
|
|
99
|
+
|
|
100
|
+
return {"routes": routes}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
|
|
4
|
+
from fastmock.core.state import app_state
|
|
5
|
+
from fastmock.services import crud_manager, data_generator, openapi_parser
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
@router.api_route(
|
|
10
|
+
"/{path:path}",
|
|
11
|
+
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
12
|
+
include_in_schema=False,
|
|
13
|
+
summary="Dynamic Mock Router | Динамический генератор ответов",
|
|
14
|
+
description="**EN:** Catch-all endpoint that intercepts requests and responds with generated mock data based on the uploaded OpenAPI spec.\n\n**RU:** Эндпоинт, который перехватывает все запросы и отвечает сгенерированными мок-данными на основе загруженной OpenAPI спецификации."
|
|
15
|
+
)
|
|
16
|
+
async def catch_all(request: Request, path: str):
|
|
17
|
+
# Добавляем слэш в начале
|
|
18
|
+
full_path = f"/{path}"
|
|
19
|
+
|
|
20
|
+
# 1. Если спеки нет, отдаем ошибку
|
|
21
|
+
if not app_state.spec:
|
|
22
|
+
raise HTTPException(
|
|
23
|
+
status_code=400,
|
|
24
|
+
detail="OpenAPI spec not loaded. Use POST /_admin/specs to upload or provide openapi.yaml."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# 2. Ищем операцию в спеке
|
|
28
|
+
_spec_path, operation = openapi_parser.find_operation(request.method, full_path)
|
|
29
|
+
|
|
30
|
+
if not operation:
|
|
31
|
+
raise HTTPException(
|
|
32
|
+
status_code=404,
|
|
33
|
+
detail=f"Path {full_path} not found in OpenAPI spec for method {request.method}."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# 3. Обработка сохранения (CRUD) для POST/PUT/PATCH
|
|
37
|
+
if request.method in ["POST", "PUT", "PATCH"]:
|
|
38
|
+
try:
|
|
39
|
+
body = await request.json()
|
|
40
|
+
if isinstance(body, dict):
|
|
41
|
+
saved_data = crud_manager.save_data(full_path, body)
|
|
42
|
+
# Возвращаем сохраненные данные
|
|
43
|
+
return JSONResponse(
|
|
44
|
+
content=saved_data,
|
|
45
|
+
status_code=201 if request.method == "POST" else 200
|
|
46
|
+
)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
# 3.5. Обработка удаления (CRUD) для DELETE
|
|
51
|
+
if request.method == "DELETE":
|
|
52
|
+
deleted = crud_manager.delete_data(full_path)
|
|
53
|
+
if deleted:
|
|
54
|
+
return JSONResponse(content={"message": "Deleted successfully"}, status_code=200)
|
|
55
|
+
# Если не нашли что удалить, продолжаем генерировать мок из схемы
|
|
56
|
+
|
|
57
|
+
# 4. Поиск сохраненных данных для GET
|
|
58
|
+
if request.method == "GET":
|
|
59
|
+
saved_data = crud_manager.get_data(full_path)
|
|
60
|
+
if saved_data:
|
|
61
|
+
# Отдаем сохраненные данные только если они есть (если список пуст - сгенерируем фикстуры)
|
|
62
|
+
if not isinstance(saved_data, list) or len(saved_data) > 0:
|
|
63
|
+
return JSONResponse(content=saved_data, status_code=200)
|
|
64
|
+
|
|
65
|
+
# 5. Если данных нет - генерируем фиктивные данные по схеме ответа
|
|
66
|
+
schema = openapi_parser.get_response_schema(operation, status_code="200")
|
|
67
|
+
status_code = 200
|
|
68
|
+
|
|
69
|
+
if not schema:
|
|
70
|
+
# Если схемы для 200 нет, пробуем 201 (для POST)
|
|
71
|
+
schema = openapi_parser.get_response_schema(operation, status_code="201")
|
|
72
|
+
status_code = 201 if schema else 200
|
|
73
|
+
|
|
74
|
+
if schema:
|
|
75
|
+
mock_data = data_generator.generate_mock_data(schema)
|
|
76
|
+
# Опционально: можно сохранить сгенерированные списки, чтобы они не менялись каждый раз
|
|
77
|
+
if request.method == "GET" and isinstance(mock_data, list):
|
|
78
|
+
for item in mock_data:
|
|
79
|
+
if isinstance(item, dict):
|
|
80
|
+
crud_manager.save_data(full_path, item)
|
|
81
|
+
|
|
82
|
+
return JSONResponse(content=mock_data, status_code=status_code)
|
|
83
|
+
|
|
84
|
+
return JSONResponse(
|
|
85
|
+
content={"message": "Mock response generated, but no JSON schema found in OpenAPI."},
|
|
86
|
+
status_code=status_code
|
|
87
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
from fastapi import Request
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
|
|
8
|
+
from fastmock.core.state import app_state
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChaosMiddleware(BaseHTTPMiddleware):
|
|
12
|
+
async def dispatch(self, request: Request, call_next):
|
|
13
|
+
# Пропускаем админку и корневой роут
|
|
14
|
+
if request.url.path.startswith("/_admin") or request.url.path == "/":
|
|
15
|
+
return await call_next(request)
|
|
16
|
+
|
|
17
|
+
# 1. Задержка (Network Throttling)
|
|
18
|
+
# Проверяем заголовок X-Mock-Delay, если нет - берем из глобального стейта
|
|
19
|
+
delay_ms_str = request.headers.get("X-Mock-Delay")
|
|
20
|
+
delay_ms = int(delay_ms_str) if delay_ms_str and delay_ms_str.isdigit() else app_state.chaos_delay_ms
|
|
21
|
+
|
|
22
|
+
if delay_ms > 0:
|
|
23
|
+
await asyncio.sleep(delay_ms / 1000.0)
|
|
24
|
+
|
|
25
|
+
# 2. Chaos Injection (Случайные или принудительные ошибки)
|
|
26
|
+
# Проверяем заголовок X-Mock-Status, если он есть - принудительно отдаем ошибку
|
|
27
|
+
forced_status = request.headers.get("X-Mock-Status")
|
|
28
|
+
if forced_status and forced_status.isdigit():
|
|
29
|
+
status_code = int(forced_status)
|
|
30
|
+
return JSONResponse(
|
|
31
|
+
content={"error": "Chaos Monkey injected forced error", "status": status_code},
|
|
32
|
+
status_code=status_code
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Либо генерируем ошибку случайно по рейту
|
|
36
|
+
if app_state.chaos_error_rate > 0.0:
|
|
37
|
+
if random.random() < app_state.chaos_error_rate:
|
|
38
|
+
status_code = random.choice(app_state.chaos_allowed_errors)
|
|
39
|
+
return JSONResponse(
|
|
40
|
+
content={"error": "Chaos Monkey injected random error", "status": status_code},
|
|
41
|
+
status_code=status_code
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# 3. Если хаос не сработал - пропускаем запрос к роутеру
|
|
45
|
+
response = await call_next(request)
|
|
46
|
+
return response
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from faker import Faker
|
|
5
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
fake = Faker()
|
|
9
|
+
|
|
10
|
+
@router.websocket("/ws/{path:path}")
|
|
11
|
+
async def websocket_endpoint(websocket: WebSocket, path: str):
|
|
12
|
+
"""
|
|
13
|
+
Универсальный WebSocket роутер для мокирования.
|
|
14
|
+
Поддерживает:
|
|
15
|
+
1. Эхо-сообщения
|
|
16
|
+
2. Потоковую отдачу фейковых данных ({"action": "stream", "interval": 1})
|
|
17
|
+
"""
|
|
18
|
+
await websocket.accept()
|
|
19
|
+
|
|
20
|
+
streaming_task = None
|
|
21
|
+
|
|
22
|
+
async def stream_data(interval: float):
|
|
23
|
+
while True:
|
|
24
|
+
try:
|
|
25
|
+
# Генерируем случайные данные для потока
|
|
26
|
+
mock_payload = {
|
|
27
|
+
"event": "update",
|
|
28
|
+
"path": f"/{path}",
|
|
29
|
+
"data": {
|
|
30
|
+
"id": fake.uuid4(),
|
|
31
|
+
"value": fake.pyfloat(min_value=10.0, max_value=1000.0, right_digits=2),
|
|
32
|
+
"timestamp": fake.iso8601()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
await websocket.send_json(mock_payload)
|
|
36
|
+
await asyncio.sleep(interval)
|
|
37
|
+
except asyncio.CancelledError:
|
|
38
|
+
break
|
|
39
|
+
except Exception:
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
while True:
|
|
44
|
+
data = await websocket.receive_text()
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Пытаемся распарсить как JSON для поиска команд
|
|
48
|
+
json_data = json.loads(data)
|
|
49
|
+
action = json_data.get("action")
|
|
50
|
+
|
|
51
|
+
if action == "stream":
|
|
52
|
+
interval = float(json_data.get("interval", 1.0))
|
|
53
|
+
if streaming_task is None or streaming_task.done():
|
|
54
|
+
streaming_task = asyncio.create_task(stream_data(interval))
|
|
55
|
+
await websocket.send_json({"status": "streaming_started", "interval": interval})
|
|
56
|
+
else:
|
|
57
|
+
await websocket.send_json({"status": "already_streaming"})
|
|
58
|
+
|
|
59
|
+
elif action == "stop":
|
|
60
|
+
if streaming_task and not streaming_task.done():
|
|
61
|
+
streaming_task.cancel()
|
|
62
|
+
streaming_task = None
|
|
63
|
+
await websocket.send_json({"status": "streaming_stopped"})
|
|
64
|
+
else:
|
|
65
|
+
# Если это просто JSON, но не команда - делаем эхо
|
|
66
|
+
await websocket.send_json({"echo": json_data})
|
|
67
|
+
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
# Если пришел обычный текст - просто возвращаем его (эхо)
|
|
70
|
+
await websocket.send_text(f"Echo: {data}")
|
|
71
|
+
|
|
72
|
+
except WebSocketDisconnect:
|
|
73
|
+
if streaming_task and not streaming_task.done():
|
|
74
|
+
streaming_task.cancel()
|
fastmock/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import uvicorn
|
|
3
|
+
|
|
4
|
+
from fastmock.core.config import settings
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="FastMock API Engine CLI")
|
|
7
|
+
|
|
8
|
+
@app.command()
|
|
9
|
+
def start(
|
|
10
|
+
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Bind socket to this host."),
|
|
11
|
+
port: int = typer.Option(8000, "--port", "-p", help="Bind socket to this port."),
|
|
12
|
+
spec: str = typer.Option("openapi.yaml", "--spec", "-s", help="Path to the OpenAPI specification file."),
|
|
13
|
+
persist: str = typer.Option(None, "--persist", help="Path to the JSON file for saving the database state (e.g. db.json)."),
|
|
14
|
+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload for development.")
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Start the FastMock API Engine server.
|
|
18
|
+
"""
|
|
19
|
+
# Update global settings based on CLI args
|
|
20
|
+
settings.default_spec_path = spec
|
|
21
|
+
settings.debug = reload
|
|
22
|
+
if persist:
|
|
23
|
+
settings.persist_path = persist
|
|
24
|
+
|
|
25
|
+
typer.echo(f"Starting FastMock API Engine on http://{host}:{port}")
|
|
26
|
+
if persist:
|
|
27
|
+
typer.echo(f"Persistence enabled: {persist}")
|
|
28
|
+
|
|
29
|
+
uvicorn.run(
|
|
30
|
+
"fastmock.main:app",
|
|
31
|
+
host=host,
|
|
32
|
+
port=port,
|
|
33
|
+
reload=reload
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
app()
|
|
File without changes
|
fastmock/core/config.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Settings(BaseSettings):
|
|
5
|
+
app_name: str = "FastMock API Engine"
|
|
6
|
+
host: str = "0.0.0.0"
|
|
7
|
+
port: int = 8000
|
|
8
|
+
default_spec_path: str = "openapi.yaml"
|
|
9
|
+
persist_path: str | None = None # Путь к файлу для сохранения БД (например, db.json)
|
|
10
|
+
debug: bool = True
|
|
11
|
+
|
|
12
|
+
settings = Settings()
|
fastmock/core/state.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmock.core.config import settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AppState:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.spec: dict[str, Any] | None = None
|
|
11
|
+
self.db: dict[str, dict[str, Any]] = {}
|
|
12
|
+
|
|
13
|
+
self.chaos_delay_ms: int = 0
|
|
14
|
+
self.chaos_error_rate: float = 0.0
|
|
15
|
+
self.chaos_allowed_errors: list[int] = [500, 502, 503]
|
|
16
|
+
|
|
17
|
+
def clear_db(self) -> None:
|
|
18
|
+
self.db = {}
|
|
19
|
+
self.save_db()
|
|
20
|
+
|
|
21
|
+
def reset_chaos(self) -> None:
|
|
22
|
+
self.chaos_delay_ms = 0
|
|
23
|
+
self.chaos_error_rate = 0.0
|
|
24
|
+
|
|
25
|
+
def load_db(self):
|
|
26
|
+
if settings.persist_path and os.path.exists(settings.persist_path):
|
|
27
|
+
try:
|
|
28
|
+
with open(settings.persist_path, "r", encoding="utf-8") as f:
|
|
29
|
+
self.db = json.load(f)
|
|
30
|
+
print(f"[*] Loaded database state from {settings.persist_path}")
|
|
31
|
+
except Exception as e:
|
|
32
|
+
print(f"[!] Failed to load database state: {e}")
|
|
33
|
+
|
|
34
|
+
def save_db(self):
|
|
35
|
+
if settings.persist_path:
|
|
36
|
+
try:
|
|
37
|
+
with open(settings.persist_path, "w", encoding="utf-8") as f:
|
|
38
|
+
json.dump(self.db, f, indent=2, ensure_ascii=False)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"[!] Failed to save database state: {e}")
|
|
41
|
+
|
|
42
|
+
app_state = AppState()
|
fastmock/main.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
import jsonref
|
|
6
|
+
import uvicorn
|
|
7
|
+
import yaml
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from fastmock.core.config import settings
|
|
11
|
+
from fastmock.core.state import app_state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
16
|
+
"""Lifespan события для запуска и остановки сервера."""
|
|
17
|
+
|
|
18
|
+
# Загружаем сохраненную базу данных, если она есть
|
|
19
|
+
app_state.load_db()
|
|
20
|
+
|
|
21
|
+
# Пытаемся загрузить дефолтную спеку при старте
|
|
22
|
+
if os.path.exists(settings.default_spec_path):
|
|
23
|
+
print(f"[*] Found default spec at {settings.default_spec_path}. Loading...")
|
|
24
|
+
try:
|
|
25
|
+
with open(settings.default_spec_path, "r", encoding="utf-8") as f:
|
|
26
|
+
raw_spec = yaml.safe_load(f)
|
|
27
|
+
|
|
28
|
+
# jsonref автоматически заменяет все $ref на реальные объекты
|
|
29
|
+
# Это избавляет нас от необходимости писать свой сложный резолвер
|
|
30
|
+
resolved_spec = jsonref.replace_refs(raw_spec)
|
|
31
|
+
app_state.spec = resolved_spec # type: ignore
|
|
32
|
+
|
|
33
|
+
print(f"[*] Spec '{app_state.spec.get('info', {}).get('title', 'Unknown')}' loaded successfully.")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"[!] Error loading spec: {e}")
|
|
36
|
+
else:
|
|
37
|
+
print(f"[*] No default spec found at {settings.default_spec_path}. Please upload via Admin API.")
|
|
38
|
+
|
|
39
|
+
yield
|
|
40
|
+
|
|
41
|
+
# Очистка при выключении
|
|
42
|
+
print("[*] Shutting down FastMock...")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
app = FastAPI(
|
|
46
|
+
title=settings.app_name,
|
|
47
|
+
description="""
|
|
48
|
+
**EN:** A lightweight, local Mock server on FastAPI for frontend and mobile teams.
|
|
49
|
+
Upload your OpenAPI spec via `/_admin/specs` and get a working mock API instantly.
|
|
50
|
+
|
|
51
|
+
**RU:** Легковесный, локальный Mock-сервер на FastAPI для фронтенд- и мобильных команд.
|
|
52
|
+
Загрузите вашу OpenAPI спецификацию через `/_admin/specs` и мгновенно получите работающий мок-API.
|
|
53
|
+
""",
|
|
54
|
+
version="0.1.0",
|
|
55
|
+
lifespan=lifespan
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
from fastmock.api.admin_routes import router as admin_router
|
|
59
|
+
from fastmock.api.dynamic_router import router as mock_router
|
|
60
|
+
from fastmock.api.middlewares import ChaosMiddleware
|
|
61
|
+
from fastmock.api.ws_router import router as ws_router
|
|
62
|
+
|
|
63
|
+
# Добавляем Middleware (порядок важен)
|
|
64
|
+
app.add_middleware(ChaosMiddleware)
|
|
65
|
+
|
|
66
|
+
# Подключаем админку
|
|
67
|
+
app.include_router(admin_router)
|
|
68
|
+
|
|
69
|
+
# Подключаем WebSockets
|
|
70
|
+
app.include_router(ws_router)
|
|
71
|
+
|
|
72
|
+
@app.get(
|
|
73
|
+
"/",
|
|
74
|
+
tags=["System"],
|
|
75
|
+
summary="Root | Проверка статуса",
|
|
76
|
+
description="**EN:** Root endpoint to check if the FastMock server is running.\n\n**RU:** Корневой эндпоинт для проверки работоспособности сервера FastMock."
|
|
77
|
+
)
|
|
78
|
+
async def root() -> dict[str, str]:
|
|
79
|
+
return {"message": f"Welcome to {settings.app_name}. Use /_admin to manage the server."}
|
|
80
|
+
|
|
81
|
+
# Подключаем catch-all роутер последним!
|
|
82
|
+
app.include_router(mock_router)
|
|
83
|
+
|
|
84
|
+
def start() -> None:
|
|
85
|
+
"""Точка входа для запуска из терминала (команда fastmock)."""
|
|
86
|
+
uvicorn.run(
|
|
87
|
+
"fastmock.main:app",
|
|
88
|
+
host=settings.host,
|
|
89
|
+
port=settings.port,
|
|
90
|
+
reload=settings.debug
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
start()
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastmock.core.state import app_state
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_collection_and_id(path: str) -> tuple[str, str | None]:
|
|
8
|
+
"""Эвристика: разбиваем путь на коллекцию и ID (например, /users/123 -> users, 123)."""
|
|
9
|
+
parts = [p for p in path.split("/") if p]
|
|
10
|
+
if not parts:
|
|
11
|
+
return "root", None
|
|
12
|
+
|
|
13
|
+
if len(parts) % 2 == 0:
|
|
14
|
+
# Четное количество частей: /users/123 -> collection="users", id="123"
|
|
15
|
+
return parts[-2], parts[-1]
|
|
16
|
+
else:
|
|
17
|
+
# Нечетное количество: /users -> collection="users", id=None
|
|
18
|
+
return parts[-1], None
|
|
19
|
+
|
|
20
|
+
def save_data(path: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
"""Сохраняет данные из POST/PUT в In-Memory БД."""
|
|
22
|
+
collection, item_id = _get_collection_and_id(path)
|
|
23
|
+
|
|
24
|
+
if collection not in app_state.db:
|
|
25
|
+
app_state.db[collection] = {}
|
|
26
|
+
|
|
27
|
+
# Генерируем ID если его нет и это добавление в коллекцию
|
|
28
|
+
if not item_id:
|
|
29
|
+
item_id = str(data.get("id", uuid.uuid4()))
|
|
30
|
+
data["id"] = item_id
|
|
31
|
+
|
|
32
|
+
app_state.db[collection][item_id] = data
|
|
33
|
+
app_state.save_db()
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
def delete_data(path: str) -> bool:
|
|
37
|
+
"""Удаляет данные из In-Memory БД (для DELETE запросов)."""
|
|
38
|
+
collection, item_id = _get_collection_and_id(path)
|
|
39
|
+
if collection in app_state.db and item_id in app_state.db[collection]:
|
|
40
|
+
del app_state.db[collection][item_id]
|
|
41
|
+
app_state.save_db()
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
def get_data(path: str) -> Any | None:
|
|
46
|
+
"""Ищет данные в In-Memory БД (для GET запросов)."""
|
|
47
|
+
collection, item_id = _get_collection_and_id(path)
|
|
48
|
+
|
|
49
|
+
if collection not in app_state.db:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if item_id:
|
|
53
|
+
# Запрос конкретного элемента
|
|
54
|
+
return app_state.db[collection].get(item_id)
|
|
55
|
+
else:
|
|
56
|
+
# Запрос списка
|
|
57
|
+
return list(app_state.db[collection].values())
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from faker import Faker
|
|
4
|
+
|
|
5
|
+
fake = Faker()
|
|
6
|
+
|
|
7
|
+
def generate_mock_data(schema: dict[str, Any]) -> Any:
|
|
8
|
+
"""Генерирует фиктивные данные на основе JSON Schema."""
|
|
9
|
+
if not schema:
|
|
10
|
+
return None
|
|
11
|
+
|
|
12
|
+
# Если есть example или default - отдаем его
|
|
13
|
+
if "example" in schema:
|
|
14
|
+
return schema["example"]
|
|
15
|
+
if "default" in schema:
|
|
16
|
+
return schema["default"]
|
|
17
|
+
|
|
18
|
+
schema_type = schema.get("type", "object")
|
|
19
|
+
|
|
20
|
+
if schema_type == "object":
|
|
21
|
+
properties = schema.get("properties", {})
|
|
22
|
+
result = {}
|
|
23
|
+
for prop_name, prop_schema in properties.items():
|
|
24
|
+
result[prop_name] = generate_field(prop_name, prop_schema)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
elif schema_type == "array":
|
|
28
|
+
items_schema = schema.get("items", {})
|
|
29
|
+
# Генерируем от 1 до 3 элементов
|
|
30
|
+
return [generate_mock_data(items_schema) for _ in range(fake.random_int(min=1, max=3))]
|
|
31
|
+
|
|
32
|
+
else:
|
|
33
|
+
return generate_field("unknown", schema)
|
|
34
|
+
|
|
35
|
+
def generate_field(name: str, schema: dict[str, Any]) -> Any:
|
|
36
|
+
"""Генерирует значение для конкретного поля, учитывая его имя и формат."""
|
|
37
|
+
|
|
38
|
+
if "example" in schema:
|
|
39
|
+
return schema["example"]
|
|
40
|
+
|
|
41
|
+
# Сначала проверяем формат (uuid, date-time, email)
|
|
42
|
+
fmt = schema.get("format")
|
|
43
|
+
if fmt == "uuid":
|
|
44
|
+
return fake.uuid4()
|
|
45
|
+
elif fmt == "email":
|
|
46
|
+
return fake.email()
|
|
47
|
+
elif fmt == "date-time":
|
|
48
|
+
return fake.iso8601()
|
|
49
|
+
elif fmt == "date":
|
|
50
|
+
return fake.date()
|
|
51
|
+
|
|
52
|
+
# Затем пытаемся угадать по имени поля
|
|
53
|
+
name_lower = name.lower()
|
|
54
|
+
if "name" in name_lower and "user" in name_lower:
|
|
55
|
+
return fake.name()
|
|
56
|
+
elif "first_name" in name_lower:
|
|
57
|
+
return fake.first_name()
|
|
58
|
+
elif "last_name" in name_lower:
|
|
59
|
+
return fake.last_name()
|
|
60
|
+
elif "phone" in name_lower:
|
|
61
|
+
return fake.phone_number()
|
|
62
|
+
elif "url" in name_lower or "link" in name_lower:
|
|
63
|
+
return fake.url()
|
|
64
|
+
elif "address" in name_lower:
|
|
65
|
+
return fake.address()
|
|
66
|
+
elif "id" in name_lower:
|
|
67
|
+
return fake.uuid4() if schema.get("type") == "string" else fake.random_int(min=1, max=1000)
|
|
68
|
+
|
|
69
|
+
# Если не угадали, генерируем просто по типу
|
|
70
|
+
schema_type = schema.get("type", "string")
|
|
71
|
+
if schema_type == "string":
|
|
72
|
+
return fake.word()
|
|
73
|
+
elif schema_type == "integer":
|
|
74
|
+
return fake.random_int(min=1, max=100)
|
|
75
|
+
elif schema_type == "number":
|
|
76
|
+
return fake.pyfloat(positive=True, max_value=1000.0)
|
|
77
|
+
elif schema_type == "boolean":
|
|
78
|
+
return fake.boolean()
|
|
79
|
+
|
|
80
|
+
return None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from re import Pattern
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmock.core.state import app_state
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def path_to_regex(openapi_path: str) -> Pattern[str]:
|
|
9
|
+
"""Преобразует OpenAPI путь (/users/{id}) в регулярное выражение для матчинга."""
|
|
10
|
+
# Заменяем {param} на захватывающую группу (?P<param>[^/]+)
|
|
11
|
+
pattern = re.sub(r'\{([^}]+)\}', r'(?P<\1>[^/]+)', openapi_path)
|
|
12
|
+
return re.compile(f"^{pattern}$")
|
|
13
|
+
|
|
14
|
+
def find_operation(method: str, path: str) -> tuple[str | None, dict[str, Any] | None]:
|
|
15
|
+
"""
|
|
16
|
+
Ищет операцию в загруженной спецификации по HTTP методу и пути.
|
|
17
|
+
Возвращает (openapi_path_key, operation_dict).
|
|
18
|
+
"""
|
|
19
|
+
if not app_state.spec or "paths" not in app_state.spec:
|
|
20
|
+
return None, None
|
|
21
|
+
|
|
22
|
+
method = method.lower()
|
|
23
|
+
|
|
24
|
+
# Сначала пытаемся найти точное совпадение (без параметров)
|
|
25
|
+
if path in app_state.spec["paths"]:
|
|
26
|
+
op = app_state.spec["paths"][path].get(method)
|
|
27
|
+
if op:
|
|
28
|
+
return path, op
|
|
29
|
+
|
|
30
|
+
# Если точного нет, ищем по шаблонам с {param}
|
|
31
|
+
for spec_path, path_item in app_state.spec["paths"].items():
|
|
32
|
+
if "{" in spec_path:
|
|
33
|
+
regex = path_to_regex(spec_path)
|
|
34
|
+
if regex.match(path):
|
|
35
|
+
op = path_item.get(method)
|
|
36
|
+
if op:
|
|
37
|
+
return spec_path, op
|
|
38
|
+
|
|
39
|
+
return None, None
|
|
40
|
+
|
|
41
|
+
def get_response_schema(operation: dict[str, Any], status_code: str = "200") -> dict[str, Any] | None:
|
|
42
|
+
"""Извлекает JSON Schema для ответа с указанным кодом (по умолчанию 200)."""
|
|
43
|
+
responses = operation.get("responses", {})
|
|
44
|
+
response = responses.get(status_code) or responses.get(int(status_code)) or responses.get("default")
|
|
45
|
+
|
|
46
|
+
if not response:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
content = response.get("content", {})
|
|
50
|
+
json_content = content.get("application/json", {})
|
|
51
|
+
return json_content.get("schema")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastmock-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, local Mock server on FastAPI for frontend and mobile teams.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: faker>=19.0.0
|
|
7
|
+
Requires-Dist: fastapi>=0.100.0
|
|
8
|
+
Requires-Dist: jsonref>=1.1.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
12
|
+
Requires-Dist: pyyaml>=6.0
|
|
13
|
+
Requires-Dist: typer>=0.9.0
|
|
14
|
+
Requires-Dist: uvicorn>=0.23.0
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
18
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
<a href="README_RU.md">🇷🇺 Русский</a> | <b>🇬🇧 English</b>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
# ⚡ FastMock API Engine
|
|
29
|
+
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
[](https://fastapi.tiangolo.com)
|
|
32
|
+
[](https://www.docker.com/)
|
|
33
|
+
[](https://github.com/Artem-SPb/fastmock/actions)
|
|
34
|
+
|
|
35
|
+
**FastMock** is a lightweight, local Mock server built on FastAPI. It's designed for frontend and mobile developers who need a reliable, realistic REST API instantly, without waiting for the backend team.
|
|
36
|
+
|
|
37
|
+
 *(You can place the generated image here)*
|
|
38
|
+
|
|
39
|
+
## 🚀 Features at a glance
|
|
40
|
+
1. **Dynamic OpenAPI Ingestion**: Drop your `openapi.yaml` in the folder or upload via Admin API. The endpoints are generated on the fly.
|
|
41
|
+
2. **Realistic Payload Generation**: Automatically generates realistic data (names, emails, UUIDs, dates) based on JSON Schema types using `Faker`.
|
|
42
|
+
3. **In-Memory CRUD**: Remembers what you `POST` and returns it on `GET`.
|
|
43
|
+
4. **Chaos Engineering**: Simulate slow 3G networks or random server crashes (HTTP 500, 503) globally or per-request using HTTP headers.
|
|
44
|
+
|
|
45
|
+
## 📖 Detailed Documentation
|
|
46
|
+
For deep-dive instructions, check the full usage guide:
|
|
47
|
+
👉 **[Read the Full Documentation (English)](docs/USAGE_EN.md)**
|
|
48
|
+
|
|
49
|
+
## 🛠 Quick Start (Docker)
|
|
50
|
+
|
|
51
|
+
The easiest way to run FastMock is via Docker.
|
|
52
|
+
|
|
53
|
+
1. Clone the repository:
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/Artem-SPb/fastmock.git
|
|
56
|
+
cd fastmock
|
|
57
|
+
```
|
|
58
|
+
2. Place your `openapi.yaml` in the root directory (optional).
|
|
59
|
+
3. Run the container:
|
|
60
|
+
```bash
|
|
61
|
+
docker compose up
|
|
62
|
+
```
|
|
63
|
+
4. Open **http://127.0.0.1:8000/docs** in your browser!
|
|
64
|
+
|
|
65
|
+
## 👨💻 Author
|
|
66
|
+
**Artem-SPb**
|
|
67
|
+
- GitHub: [@Artem-SPb](https://github.com/Artem-SPb)
|
|
68
|
+
|
|
69
|
+
Created as a portfolio project showcasing modern Python architecture, FastAPI ecosystem, and Developer Experience (DX) best practices.
|
|
70
|
+
|
|
71
|
+
*Feel free to star ⭐ this repository if you found it helpful!*
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
fastmock/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
fastmock/cli.py,sha256=qklJuB-H3DT1bhYafkL_mx4QgEHu861ptqZ-Jf92nwA,1191
|
|
3
|
+
fastmock/main.py,sha256=1UuWl0tosi89BZHyFunxaO90jLLwQmZslkDfA-8SDas,3659
|
|
4
|
+
fastmock/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
fastmock/api/admin_routes.py,sha256=KfJLJq7lzT3iNecPXgSjVRxOB0A5HLBvIBdIl5nzmwY,5386
|
|
6
|
+
fastmock/api/dynamic_router.py,sha256=WtubrHAo65eCqIK76bBByn1xPfDbXiXPlN11QKlrLWY,4260
|
|
7
|
+
fastmock/api/middlewares.py,sha256=R5-gt2ddb2WqJs_zaWk-iy_NKu14br2COKAqGASA3yg,2220
|
|
8
|
+
fastmock/api/ws_router.py,sha256=lHYi1ll6E7bzfw0xbQjBqhpr_Bd5QxWP0DBbEly0rKk,3030
|
|
9
|
+
fastmock/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
fastmock/core/config.py,sha256=wihMQ7xmrTtRk94jhHUwQrWOLHjs6rlkcI8BiNELw-Q,377
|
|
11
|
+
fastmock/core/state.py,sha256=bEI2ODob8YJvfle29OPr71ndt76Dp4WceNN8rlNyP4s,1335
|
|
12
|
+
fastmock/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
fastmock/services/crud_manager.py,sha256=3bv1bpv4IXZCnFqltUEK5xSwQ466_GC_xDauzlgDakc,2114
|
|
14
|
+
fastmock/services/data_generator.py,sha256=KPLSlAQ2zy-DB2asVWhAJlgG8ImYdzozg54igfpQOw0,2818
|
|
15
|
+
fastmock/services/openapi_parser.py,sha256=cpv-A7ruFNWG14dpAUBUQ6Bw7S3sOEjJIW-r5our6P0,2128
|
|
16
|
+
fastmock_api-0.1.0.dist-info/METADATA,sha256=8uHas1W84fl5glCtTE-b2LAFeO_2mqxGjvZeVxMbLAo,2969
|
|
17
|
+
fastmock_api-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
18
|
+
fastmock_api-0.1.0.dist-info/entry_points.txt,sha256=1Edk6Iyjp2-48DMa8pzYE5nbjivGVmPZWz4ufCcrEuc,46
|
|
19
|
+
fastmock_api-0.1.0.dist-info/RECORD,,
|