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 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
+ )
@@ -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()
@@ -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
+ """