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 ADDED
File without changes
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
@@ -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
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
31
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.100+-009688.svg?logo=fastapi)](https://fastapi.tiangolo.com)
32
+ [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?logo=docker&logoColor=white)](https://www.docker.com/)
33
+ [![Build Status](https://github.com/Artem-SPb/fastmock/actions/workflows/ci.yml/badge.svg)](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
+ ![FastMock Preview](docs/assets/preview.png) *(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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fastmock = fastmock.cli:app