mockstack 0.0.3__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.
- mockstack/__init__.py +0 -0
- mockstack/config.py +91 -0
- mockstack/display.py +35 -0
- mockstack/identifiers.py +84 -0
- mockstack/lifespan.py +29 -0
- mockstack/main.py +31 -0
- mockstack/middleware.py +19 -0
- mockstack/opentelemetry.py +45 -0
- mockstack/routers/__init__.py +0 -0
- mockstack/routers/catchall.py +33 -0
- mockstack/routers/homepage.py +18 -0
- mockstack/strategies/__init__.py +0 -0
- mockstack/strategies/base.py +14 -0
- mockstack/strategies/factory.py +21 -0
- mockstack/strategies/filefixtures.py +230 -0
- mockstack/templating.py +114 -0
- mockstack/tests/__init__.py +0 -0
- mockstack/tests/conftest.py +29 -0
- mockstack/tests/fixtures/templates/__init__.py +0 -0
- mockstack/tests/routers/__init__.py +0 -0
- mockstack/tests/routers/test_catchall.py +46 -0
- mockstack/tests/routers/test_homepage.py +27 -0
- mockstack/tests/strategies/test_filefixtures.py +312 -0
- mockstack/tests/test_display.py +33 -0
- mockstack/tests/test_identifiers.py +94 -0
- mockstack/tests/test_middleware.py +37 -0
- mockstack/tests/test_templating.py +220 -0
- mockstack-0.0.3.dist-info/METADATA +13 -0
- mockstack-0.0.3.dist-info/RECORD +32 -0
- mockstack-0.0.3.dist-info/WHEEL +5 -0
- mockstack-0.0.3.dist-info/licenses/LICENSE +21 -0
- mockstack-0.0.3.dist-info/top_level.txt +1 -0
mockstack/__init__.py
ADDED
|
File without changes
|
mockstack/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from typing import Any, Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import DirectoryPath
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenTelemetrySettings(BaseSettings):
|
|
9
|
+
"""Settings for OpenTelemetry."""
|
|
10
|
+
|
|
11
|
+
enabled: bool = False
|
|
12
|
+
|
|
13
|
+
endpoint: str = "http://localhost:4317/"
|
|
14
|
+
|
|
15
|
+
# whether to capture the response body.
|
|
16
|
+
# this can be heavy, sensitive (PII) and/or not needed depending on the use case.
|
|
17
|
+
capture_response_body: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Settings(BaseSettings):
|
|
21
|
+
"""Settings for mockstack.
|
|
22
|
+
|
|
23
|
+
Default values are defined below and can be overwritten using an .env file
|
|
24
|
+
or with environment variables.
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = SettingsConfigDict(
|
|
29
|
+
env_prefix="mockstack__",
|
|
30
|
+
env_file=".env",
|
|
31
|
+
env_nested_delimiter="__",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# OpenTelemetry configuration
|
|
35
|
+
opentelemetry: OpenTelemetrySettings = OpenTelemetrySettings()
|
|
36
|
+
|
|
37
|
+
# strategy to use for handling requests
|
|
38
|
+
strategy: Literal["filefixtures", "proxyrules"] = "filefixtures"
|
|
39
|
+
|
|
40
|
+
# base directory for templates used by strategies
|
|
41
|
+
templates_dir: DirectoryPath = "./templates" # type: ignore[assignment]
|
|
42
|
+
|
|
43
|
+
# metadata fields to inject into created resources.
|
|
44
|
+
# A few template fields are available. See documentation for more details.
|
|
45
|
+
created_resource_metadata: dict[str, Any] = {
|
|
46
|
+
"id": "{{ uuid4() }}",
|
|
47
|
+
"createdAt": "{{ utcnow().isoformat() }}",
|
|
48
|
+
"updatedAt": "{{ utcnow().isoformat() }}",
|
|
49
|
+
"createdBy": "{{ request.headers.get('X-User-Id', uuid4()) }}",
|
|
50
|
+
"status": dict(code="OK", error_code=None),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# fields to inject into missing resources response json.
|
|
54
|
+
# some services may require such additional fields to be present in the response.
|
|
55
|
+
missing_resource_fields: dict[str, Any] = dict(
|
|
56
|
+
code=404,
|
|
57
|
+
message="mockstack: resource not found",
|
|
58
|
+
retryable=False,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# logging configuration. schema is based on the logging configuration schema:
|
|
62
|
+
# https://docs.python.org/3/library/logging.config.html#logging-config-dictschema
|
|
63
|
+
logging: dict[str, Any] = {
|
|
64
|
+
"version": 1,
|
|
65
|
+
"disable_existing_loggers": False,
|
|
66
|
+
"formatters": {
|
|
67
|
+
"standard": {
|
|
68
|
+
"format": " %(levelname)s [%(name)s] %(message)s",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
"handlers": {
|
|
72
|
+
"console": {
|
|
73
|
+
"class": "logging.StreamHandler",
|
|
74
|
+
"level": "DEBUG",
|
|
75
|
+
"formatter": "standard",
|
|
76
|
+
"stream": "ext://sys.stdout",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"loggers": {
|
|
80
|
+
"FileFixturesStrategy": {
|
|
81
|
+
"handlers": ["console"],
|
|
82
|
+
"level": "INFO",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@lru_cache
|
|
89
|
+
def settings_provider() -> Settings:
|
|
90
|
+
"""Provide the settings for the application."""
|
|
91
|
+
return Settings()
|
mockstack/display.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Display and logging functionality."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from mockstack.config import Settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ANSIColors:
|
|
9
|
+
HEADER = "\033[95m"
|
|
10
|
+
OKBLUE = "\033[94m"
|
|
11
|
+
OKCYAN = "\033[96m"
|
|
12
|
+
OKGREEN = "\033[92m"
|
|
13
|
+
WARNING = "\033[93m"
|
|
14
|
+
FAIL = "\033[91m"
|
|
15
|
+
ENDC = "\033[0m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
UNDERLINE = "\033[4m"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def announce(settings: Settings):
|
|
21
|
+
"""Log the startup message with the active settings."""
|
|
22
|
+
HIGHLIGHT = ANSIColors.HEADER
|
|
23
|
+
ENDC = ANSIColors.ENDC
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("uvicorn")
|
|
26
|
+
logger.info(
|
|
27
|
+
f"{HIGHLIGHT}mockstack{ENDC} ready to roll. "
|
|
28
|
+
f"Using strategy: {HIGHLIGHT}{settings.strategy}{ENDC}, "
|
|
29
|
+
f"templates_dir: {HIGHLIGHT}{settings.templates_dir}{ENDC}. "
|
|
30
|
+
)
|
|
31
|
+
logger.info(
|
|
32
|
+
f"OpenTelemetry enabled: {HIGHLIGHT}{settings.opentelemetry.enabled}{ENDC}, "
|
|
33
|
+
f"endpoint: {HIGHLIGHT}{settings.opentelemetry.endpoint}{ENDC}, "
|
|
34
|
+
f"capture_response_body: {HIGHLIGHT}{settings.opentelemetry.capture_response_body}{ENDC}"
|
|
35
|
+
)
|
mockstack/identifiers.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Identifiers helpers."""
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def prefixes(iterable, reverse=False):
|
|
7
|
+
"""Return an iterator of the prefixes of the iterable.
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
---------
|
|
11
|
+
>>> list(prefixes([1, 2, 3]))
|
|
12
|
+
[(1,), (1, 2), (1, 2, 3)]
|
|
13
|
+
|
|
14
|
+
>>> list(prefixes([1, 2, 3], reverse=True))
|
|
15
|
+
[(1, 2, 3), (1, 2), (1,)]
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
iterator = itertools.accumulate(map(lambda x: (x,), iterable))
|
|
19
|
+
if reverse:
|
|
20
|
+
return reversed(list(iterator))
|
|
21
|
+
return iterator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def looks_like_id(segment: str) -> bool:
|
|
25
|
+
"""Check if a URL path segment looks like an ID.
|
|
26
|
+
|
|
27
|
+
Identifiers are typically numeric or hexadecimal (e.g. UUIDs) and are used to identify a resource.
|
|
28
|
+
|
|
29
|
+
We apply a few simple heuristics to try and provide a good balance between false positives and false negatives.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
---------
|
|
33
|
+
>>> looks_like_id("123")
|
|
34
|
+
False # Odd length numeric
|
|
35
|
+
|
|
36
|
+
>>> looks_like_id("1234567890")
|
|
37
|
+
True # Even length numeric
|
|
38
|
+
|
|
39
|
+
>>> looks_like_id("1234567890abcdef")
|
|
40
|
+
True # Even length hex
|
|
41
|
+
|
|
42
|
+
>>> looks_like_id("3a4e5ad9-17ee-41af-972f-864dfccd4856")
|
|
43
|
+
True # UUID with dashes
|
|
44
|
+
|
|
45
|
+
>>> looks_like_id("3a4e5ad917ee41af972f864dfccd4856")
|
|
46
|
+
True # UUID without dashes
|
|
47
|
+
|
|
48
|
+
>>> looks_like_id("project")
|
|
49
|
+
False # Not a valid ID format
|
|
50
|
+
|
|
51
|
+
>>> looks_like_id("api")
|
|
52
|
+
False # Not a valid ID format
|
|
53
|
+
|
|
54
|
+
>>> looks_like_id("v1")
|
|
55
|
+
False # Not a valid ID format
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
if not segment or segment.isspace():
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
# Check for special characters that aren't allowed in IDs
|
|
62
|
+
if any(c in segment for c in "_./+@"):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
N = len(segment)
|
|
66
|
+
|
|
67
|
+
# Check for UUID format (with or without dashes)
|
|
68
|
+
if N == 36:
|
|
69
|
+
# UUID with dashes
|
|
70
|
+
parts = segment.lower().split("-")
|
|
71
|
+
if len(parts) == 5 and all(
|
|
72
|
+
all(c in "0123456789abcdef" for c in p) for p in parts
|
|
73
|
+
):
|
|
74
|
+
lengths = [len(p) for p in parts]
|
|
75
|
+
if lengths == [8, 4, 4, 4, 12]:
|
|
76
|
+
return True
|
|
77
|
+
elif N == 32:
|
|
78
|
+
# UUID without dashes
|
|
79
|
+
return all(c in "0123456789abcdefABCDEF" for c in segment)
|
|
80
|
+
|
|
81
|
+
# Check for even length numeric or hex
|
|
82
|
+
return (N % 2 == 0 and segment.isdigit()) or (
|
|
83
|
+
N % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in segment)
|
|
84
|
+
)
|
mockstack/lifespan.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""FastAPI application lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from logging import config
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
from mockstack.config import Settings
|
|
10
|
+
from mockstack.display import announce
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lifespan_provider(
|
|
14
|
+
settings: Settings,
|
|
15
|
+
) -> Callable:
|
|
16
|
+
"""Provide the lifespan context manager."""
|
|
17
|
+
|
|
18
|
+
@asynccontextmanager
|
|
19
|
+
async def lifespan(app: FastAPI):
|
|
20
|
+
"""FastAPI application lifespan management.
|
|
21
|
+
|
|
22
|
+
This is the context manager that FastAPI will use to manage the lifecycle of the application.
|
|
23
|
+
"""
|
|
24
|
+
config.dictConfig(settings.logging)
|
|
25
|
+
announce(settings)
|
|
26
|
+
|
|
27
|
+
yield
|
|
28
|
+
|
|
29
|
+
return lifespan
|
mockstack/main.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Application entrypoints."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from mockstack.config import settings_provider
|
|
6
|
+
from mockstack.lifespan import lifespan_provider
|
|
7
|
+
from mockstack.middleware import middleware_provider
|
|
8
|
+
from mockstack.opentelemetry import opentelemetry_provider
|
|
9
|
+
from mockstack.routers.catchall import catchall_router_provider
|
|
10
|
+
from mockstack.routers.homepage import homepage_router_provider
|
|
11
|
+
from mockstack.strategies.factory import strategy_provider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_app() -> FastAPI:
|
|
15
|
+
"""Create the FastAPI app."""
|
|
16
|
+
settings = settings_provider()
|
|
17
|
+
|
|
18
|
+
app = FastAPI(lifespan=lifespan_provider(settings))
|
|
19
|
+
|
|
20
|
+
strategy_provider(app, settings)
|
|
21
|
+
middleware_provider(app, settings)
|
|
22
|
+
opentelemetry_provider(app, settings)
|
|
23
|
+
|
|
24
|
+
homepage_router_provider(app, settings)
|
|
25
|
+
catchall_router_provider(app, settings)
|
|
26
|
+
|
|
27
|
+
return app
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# expose top-level app object for fastapi cli
|
|
31
|
+
app = create_app()
|
mockstack/middleware.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Middleware definitionsfor the mockstack app."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
|
|
7
|
+
from mockstack.config import Settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def middleware_provider(app: FastAPI, settings: Settings) -> None:
|
|
11
|
+
"""Instrument the middlewares to the mockstack app."""
|
|
12
|
+
|
|
13
|
+
@app.middleware("http")
|
|
14
|
+
async def add_process_time_header(request: Request, call_next):
|
|
15
|
+
start_time = time.time()
|
|
16
|
+
response = await call_next(request)
|
|
17
|
+
process_time = time.time() - start_time
|
|
18
|
+
response.headers["X-Process-Time"] = str(process_time)
|
|
19
|
+
return response
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""OpenTelemetry integration."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from opentelemetry import trace
|
|
7
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
8
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
9
|
+
from opentelemetry.sdk.resources import Resource
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
12
|
+
|
|
13
|
+
from mockstack.config import Settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def span_name_for(request: Request) -> str:
|
|
17
|
+
"""Get the span name for a request."""
|
|
18
|
+
return f"{request.method.upper()} {request.url.path}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def opentelemetry_provider(app: FastAPI, settings: Settings) -> None:
|
|
22
|
+
"""Initialize OpenTelemetry for the mockstack app."""
|
|
23
|
+
if not settings.opentelemetry.enabled:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
# Initialize OpenTelemetry
|
|
27
|
+
distribution = metadata.distribution("mockstack")
|
|
28
|
+
resource = Resource(
|
|
29
|
+
attributes={
|
|
30
|
+
"service.name": distribution.name,
|
|
31
|
+
"service.version": distribution.version,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
tracer_provider = TracerProvider(resource=resource)
|
|
36
|
+
trace.set_tracer_provider(tracer_provider)
|
|
37
|
+
|
|
38
|
+
# Set up OTLP exporter
|
|
39
|
+
otlp_exporter = OTLPSpanExporter(endpoint=settings.opentelemetry.endpoint)
|
|
40
|
+
span_processor = BatchSpanProcessor(otlp_exporter)
|
|
41
|
+
tracer_provider.add_span_processor(span_processor)
|
|
42
|
+
|
|
43
|
+
# Nb. we do not actually use the default FastAPIInstrumentor here
|
|
44
|
+
# because we use custom tracing in various places.
|
|
45
|
+
# FastAPIInstrumentor.instrument_app(app)
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Routes for the mockstack app."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from opentelemetry import trace
|
|
5
|
+
from opentelemetry.propagate import extract
|
|
6
|
+
|
|
7
|
+
from mockstack.config import Settings
|
|
8
|
+
from mockstack.opentelemetry import span_name_for
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def catchall_router_provider(app: FastAPI, settings: Settings) -> None:
|
|
12
|
+
"""Create the catch-all routes for the mockstack app."""
|
|
13
|
+
|
|
14
|
+
@app.route("/{full_path:path}", methods=["GET", "PATCH", "POST", "PUT", "DELETE"])
|
|
15
|
+
async def catch_all(request: Request):
|
|
16
|
+
"""Catch all requests and delegate to the strategy."""
|
|
17
|
+
tracer = trace.get_tracer(__name__)
|
|
18
|
+
ctx = extract(request.headers)
|
|
19
|
+
with tracer.start_as_current_span(span_name_for(request), context=ctx) as span:
|
|
20
|
+
span.set_attribute("http.method", request.method)
|
|
21
|
+
span.set_attribute("http.url", str(request.url))
|
|
22
|
+
|
|
23
|
+
response = await app.state.strategy.apply(request)
|
|
24
|
+
|
|
25
|
+
span.set_attribute("http.status_code", response.status_code)
|
|
26
|
+
|
|
27
|
+
# Nb. persisting response body can hamper performance,
|
|
28
|
+
# expose sensitive / PII data, and / or may not be needed.
|
|
29
|
+
# it is therefore an opt-in setting.
|
|
30
|
+
if settings.opentelemetry.capture_response_body:
|
|
31
|
+
span.set_attribute("response", response.body)
|
|
32
|
+
|
|
33
|
+
return response
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Routes for the homepage."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, FastAPI
|
|
4
|
+
|
|
5
|
+
from mockstack.config import Settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def homepage_router_provider(app: FastAPI, settings: Settings) -> APIRouter:
|
|
9
|
+
"""Provide the homepage routes."""
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
@router.get("/")
|
|
14
|
+
async def homepage():
|
|
15
|
+
"""Root endpoint."""
|
|
16
|
+
return {"Hello": "World"}
|
|
17
|
+
|
|
18
|
+
return router
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Base strategy for MockStack."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from fastapi import Request, Response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseStrategy(ABC):
|
|
9
|
+
"""Base strategy for MockStack."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def apply(self, request: Request) -> Response:
|
|
13
|
+
"""Apply the strategy to the request and response."""
|
|
14
|
+
pass
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Factory for creating strategies."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from mockstack.config import Settings
|
|
6
|
+
from mockstack.strategies.base import BaseStrategy
|
|
7
|
+
from mockstack.strategies.filefixtures import FileFixturesStrategy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def strategy_provider(app: FastAPI, settings: Settings) -> BaseStrategy:
|
|
11
|
+
"""Factory for creating strategies."""
|
|
12
|
+
|
|
13
|
+
if settings.strategy == "filefixtures":
|
|
14
|
+
strategy = FileFixturesStrategy(settings)
|
|
15
|
+
else:
|
|
16
|
+
raise ValueError(f"Unknown strategy: {settings.strategy}")
|
|
17
|
+
|
|
18
|
+
# add strategy to app state for dependency injection
|
|
19
|
+
app.state.strategy = strategy
|
|
20
|
+
|
|
21
|
+
return strategy
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""MockStack strategy for using file-based fixtures."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Request, Response, status
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
11
|
+
from jinja2 import Environment, FileSystemLoader
|
|
12
|
+
|
|
13
|
+
from mockstack.config import Settings
|
|
14
|
+
from mockstack.strategies.base import BaseStrategy
|
|
15
|
+
from mockstack.templating import (
|
|
16
|
+
iter_possible_template_arguments,
|
|
17
|
+
missing_template_detail,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_json_media_type(media_type: str) -> bool:
|
|
22
|
+
"""Check if the media type is JSON."""
|
|
23
|
+
return media_type in ("application/json", "text/json")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def looks_like_a_search(request: Request) -> bool:
|
|
27
|
+
"""Check if the request looks like a search.
|
|
28
|
+
|
|
29
|
+
This is a heuristic to try and identify cases where a POST
|
|
30
|
+
request is used for issuing a search rather than for creating
|
|
31
|
+
a new resource.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
return any(
|
|
35
|
+
(
|
|
36
|
+
request.url.path.endswith("_search"),
|
|
37
|
+
request.url.path.endswith("/search"),
|
|
38
|
+
request.url.path.endswith("_query"),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def looks_like_a_command(request: Request) -> bool:
|
|
44
|
+
"""Check if the request looks like a command.
|
|
45
|
+
|
|
46
|
+
This is a heuristic to try and identify cases where a POST
|
|
47
|
+
request is used for issuing a command rather than for creating
|
|
48
|
+
a new resource.
|
|
49
|
+
"""
|
|
50
|
+
return any(
|
|
51
|
+
(
|
|
52
|
+
request.url.path.endswith("_command"),
|
|
53
|
+
request.url.path.endswith("/command"),
|
|
54
|
+
request.url.path.endswith("_request"),
|
|
55
|
+
request.url.path.endswith("/request"),
|
|
56
|
+
request.url.path.endswith("_run"),
|
|
57
|
+
request.url.path.endswith("/run"),
|
|
58
|
+
request.url.path.endswith("_execute"),
|
|
59
|
+
request.url.path.endswith("/execute"),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FileFixturesStrategy(BaseStrategy):
|
|
65
|
+
"""Strategy for using file-based fixtures."""
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger("FileFixturesStrategy")
|
|
68
|
+
|
|
69
|
+
def __init__(self, settings: Settings, *args, **kwargs):
|
|
70
|
+
super().__init__(*args, **kwargs)
|
|
71
|
+
self.templates_dir = Path(settings.templates_dir)
|
|
72
|
+
self.created_resource_metadata = settings.created_resource_metadata
|
|
73
|
+
self.missing_resource_fields = settings.missing_resource_fields
|
|
74
|
+
|
|
75
|
+
self.env = Environment(loader=FileSystemLoader(settings.templates_dir))
|
|
76
|
+
|
|
77
|
+
async def apply(self, request: Request) -> Response:
|
|
78
|
+
match request.method:
|
|
79
|
+
case "GET":
|
|
80
|
+
return await self._get(request)
|
|
81
|
+
case "POST":
|
|
82
|
+
return await self._post(request)
|
|
83
|
+
case "PATCH":
|
|
84
|
+
return await self._patch(request)
|
|
85
|
+
case "PUT":
|
|
86
|
+
return await self._put(request)
|
|
87
|
+
case "DELETE":
|
|
88
|
+
return await self._delete(request)
|
|
89
|
+
case _:
|
|
90
|
+
raise HTTPException(status_code=405, detail="Method not allowed")
|
|
91
|
+
|
|
92
|
+
async def _post(self, request: Request) -> Response:
|
|
93
|
+
"""Apply the strategy for POST requests.
|
|
94
|
+
|
|
95
|
+
POST requests are typically used for a few different purposes:
|
|
96
|
+
|
|
97
|
+
- Creating a new resource
|
|
98
|
+
- Searching for resources with a complex query that cannot be expressed in a URI
|
|
99
|
+
- Executing a 'command' of some sort, like a workflow or a batch job
|
|
100
|
+
|
|
101
|
+
We try to infer the intent from the request URI and body.
|
|
102
|
+
We also allow a configuration to specify a default intent.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
if looks_like_a_search(request):
|
|
106
|
+
# Searching for resources with a complex query that cannot be expressed in a URI.
|
|
107
|
+
return self._response_from_template(request)
|
|
108
|
+
elif looks_like_a_command(request):
|
|
109
|
+
# Executing a 'command' of some sort, like a workflow or a batch job.
|
|
110
|
+
# We return a 201 CREATED status code with response from template.
|
|
111
|
+
return self._response_from_template(
|
|
112
|
+
request, status_code=status.HTTP_201_CREATED
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
# Creating a new resource.
|
|
116
|
+
media_type = request.headers.get("Content-Type", "application/json")
|
|
117
|
+
|
|
118
|
+
if is_json_media_type(media_type):
|
|
119
|
+
# We return a 201 CREATED response with the resource as the body,
|
|
120
|
+
# potentially injecting the resource ID into the response.
|
|
121
|
+
resource = await request.json()
|
|
122
|
+
|
|
123
|
+
return JSONResponse(
|
|
124
|
+
status_code=status.HTTP_201_CREATED,
|
|
125
|
+
content=self._created(resource, request=request),
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
# We return a 201 CREATEDresponse with an empty body.
|
|
129
|
+
return Response(
|
|
130
|
+
status_code=status.HTTP_201_CREATED,
|
|
131
|
+
content=None,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def _get(self, request: Request) -> Response:
|
|
135
|
+
"""Apply the strategy for GET requests.
|
|
136
|
+
|
|
137
|
+
We try to find a template that matches the request.
|
|
138
|
+
|
|
139
|
+
for a URI path like `/api/v1/projects/1234`, we try the following templates:
|
|
140
|
+
|
|
141
|
+
- api-v1-projects.1234.j2
|
|
142
|
+
- api-v1-projects.j2
|
|
143
|
+
- index.j2
|
|
144
|
+
|
|
145
|
+
where `1234` is the identifier of the project.
|
|
146
|
+
|
|
147
|
+
If we find one, we render it and return the response.
|
|
148
|
+
If we don't find one, we raise a 404 error.
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
return self._response_from_template(request)
|
|
152
|
+
|
|
153
|
+
async def _delete(self, request: Request) -> Response:
|
|
154
|
+
"""Apply the strategy for DELETE requests."""
|
|
155
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
156
|
+
|
|
157
|
+
async def _patch(self, request: Request) -> Response:
|
|
158
|
+
"""Apply the strategy for PATCH requests."""
|
|
159
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
160
|
+
|
|
161
|
+
async def _put(self, request: Request) -> Response:
|
|
162
|
+
"""Apply the strategy for PUT requests."""
|
|
163
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
164
|
+
|
|
165
|
+
def _created(self, resource: dict, *, request: Request) -> dict:
|
|
166
|
+
"""Create a new resource given a request resource.
|
|
167
|
+
|
|
168
|
+
We use the request resource as the basis for the new resource.
|
|
169
|
+
We then inject an identifier into the resource if it doesn't already have one,
|
|
170
|
+
as well as any other metadata fields that are configured for the strategy.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def with_metadata(resource: dict, copy=True) -> dict:
|
|
175
|
+
"""Inject metadata fields into the resource."""
|
|
176
|
+
_resource = resource.copy() if copy else resource
|
|
177
|
+
for key, value in self.created_resource_metadata.items():
|
|
178
|
+
if isinstance(value, str):
|
|
179
|
+
_resource[key] = self.env.from_string(value).render(
|
|
180
|
+
self._metadata_context(request)
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
_resource[key] = value
|
|
184
|
+
return _resource
|
|
185
|
+
|
|
186
|
+
return with_metadata(resource)
|
|
187
|
+
|
|
188
|
+
def _metadata_context(self, request: Request) -> dict:
|
|
189
|
+
"""Context for injecting metadata fields into resources.
|
|
190
|
+
|
|
191
|
+
Some care is needed to ensure that we only expose the minimum amount
|
|
192
|
+
of information here since templates are user-defined.
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
return {
|
|
196
|
+
"utcnow": lambda: datetime.now(timezone.utc),
|
|
197
|
+
"uuid4": uuid4,
|
|
198
|
+
"request": request,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def _response_from_template(
|
|
202
|
+
self, request: Request, status_code: int = status.HTTP_200_OK
|
|
203
|
+
) -> Response:
|
|
204
|
+
for template_args in iter_possible_template_arguments(request):
|
|
205
|
+
filename = self.templates_dir / template_args["name"]
|
|
206
|
+
self.logger.debug("Looking for template filename: %s", filename)
|
|
207
|
+
if not os.path.exists(filename):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
self.logger.debug("Found template filename: %s", filename)
|
|
211
|
+
template = self.env.get_template(template_args["name"])
|
|
212
|
+
|
|
213
|
+
return Response(
|
|
214
|
+
template.render(**template_args["context"]),
|
|
215
|
+
media_type=template_args["media_type"],
|
|
216
|
+
status_code=status_code,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# if we get here, we have no template to render.
|
|
220
|
+
raise HTTPException(
|
|
221
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
222
|
+
detail=missing_template_detail(request, templates_dir=self.templates_dir),
|
|
223
|
+
)
|
|
224
|
+
"""
|
|
225
|
+
# TODO: return custom fields from settings
|
|
226
|
+
return JSONResponse(
|
|
227
|
+
content=self.missing_resource_fields,
|
|
228
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
229
|
+
)
|
|
230
|
+
"""
|