mockstack 0.0.3__tar.gz
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-0.0.3/LICENSE +21 -0
- mockstack-0.0.3/PKG-INFO +13 -0
- mockstack-0.0.3/README.md +62 -0
- mockstack-0.0.3/mockstack/__init__.py +0 -0
- mockstack-0.0.3/mockstack/config.py +91 -0
- mockstack-0.0.3/mockstack/display.py +35 -0
- mockstack-0.0.3/mockstack/identifiers.py +84 -0
- mockstack-0.0.3/mockstack/lifespan.py +29 -0
- mockstack-0.0.3/mockstack/main.py +31 -0
- mockstack-0.0.3/mockstack/middleware.py +19 -0
- mockstack-0.0.3/mockstack/opentelemetry.py +45 -0
- mockstack-0.0.3/mockstack/routers/__init__.py +0 -0
- mockstack-0.0.3/mockstack/routers/catchall.py +33 -0
- mockstack-0.0.3/mockstack/routers/homepage.py +18 -0
- mockstack-0.0.3/mockstack/strategies/__init__.py +0 -0
- mockstack-0.0.3/mockstack/strategies/base.py +14 -0
- mockstack-0.0.3/mockstack/strategies/factory.py +21 -0
- mockstack-0.0.3/mockstack/strategies/filefixtures.py +230 -0
- mockstack-0.0.3/mockstack/templating.py +114 -0
- mockstack-0.0.3/mockstack/tests/__init__.py +0 -0
- mockstack-0.0.3/mockstack/tests/conftest.py +29 -0
- mockstack-0.0.3/mockstack/tests/fixtures/templates/__init__.py +0 -0
- mockstack-0.0.3/mockstack/tests/routers/__init__.py +0 -0
- mockstack-0.0.3/mockstack/tests/routers/test_catchall.py +46 -0
- mockstack-0.0.3/mockstack/tests/routers/test_homepage.py +27 -0
- mockstack-0.0.3/mockstack/tests/strategies/test_filefixtures.py +312 -0
- mockstack-0.0.3/mockstack/tests/test_display.py +33 -0
- mockstack-0.0.3/mockstack/tests/test_identifiers.py +94 -0
- mockstack-0.0.3/mockstack/tests/test_middleware.py +37 -0
- mockstack-0.0.3/mockstack/tests/test_templating.py +220 -0
- mockstack-0.0.3/mockstack.egg-info/PKG-INFO +13 -0
- mockstack-0.0.3/mockstack.egg-info/SOURCES.txt +35 -0
- mockstack-0.0.3/mockstack.egg-info/dependency_links.txt +1 -0
- mockstack-0.0.3/mockstack.egg-info/requires.txt +7 -0
- mockstack-0.0.3/mockstack.egg-info/top_level.txt +1 -0
- mockstack-0.0.3/pyproject.toml +30 -0
- mockstack-0.0.3/setup.cfg +4 -0
mockstack-0.0.3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [year] [fullname]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mockstack-0.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mockstack
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Requires-Python: >=3.11
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Dist: colorama>=0.4.6
|
|
7
|
+
Requires-Dist: fastapi[standard]>=0.115.12
|
|
8
|
+
Requires-Dist: jinja2>=3.1.6
|
|
9
|
+
Requires-Dist: opentelemetry-distro[otlp]>=0.53b1
|
|
10
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.53b1
|
|
11
|
+
Requires-Dist: pydantic>=2.11.3
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.9.1
|
|
13
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# mockstack
|
|
2
|
+
|
|
3
|
+
[](https://github.com/adamhadani/mockstack/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
An API mocking workhorse :racehorse:
|
|
7
|
+
|
|
8
|
+
Enabling a sane development lifecycle for microservice-oriented architectures.
|
|
9
|
+
|
|
10
|
+
Highlights include:
|
|
11
|
+
|
|
12
|
+
* Multiple strategies for handling requests such as Jinja2 template files with intelligent URL request-to-template routing, proxy strategy, and mixed strategies. :game_die:
|
|
13
|
+
* Observability via OpenTelemetry integration. Get detailed traces of your sessions instantly reported to backends such as Grafana, Jaeger, Zipkin, etc. :eyes:
|
|
14
|
+
* Configurability via `pydantic-settings` supports customizing behaviour via environment variables and a `.env` file. :flags:
|
|
15
|
+
* Comprehensive unit-tests, linting and formatting coverage as well as vulnerabilities and security scanning with full CI automation to ensure stability and a high-quality codebase for production-grade use. :+1:
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install using [uv](https://docs.astral.sh/uv/). This will create a virtualenv for you and install all dependencies:
|
|
21
|
+
|
|
22
|
+
uv sync
|
|
23
|
+
uv pip install -e .
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Copy the included [.env.example](.env.example) file to `.env` and fill in configuration as needed based on the given examples.
|
|
29
|
+
|
|
30
|
+
Run in development mode (for live-reload of changes when developing):
|
|
31
|
+
|
|
32
|
+
uv run fastapi dev mockstack/main.py
|
|
33
|
+
|
|
34
|
+
Or, run in production mode:
|
|
35
|
+
|
|
36
|
+
uv run fastapi run mockstack/main.py
|
|
37
|
+
|
|
38
|
+
Available configuration options are [here](./mockstack/config.py). Setting individual options can be done with env. variables as in the following example:
|
|
39
|
+
|
|
40
|
+
MOCKSTACK__OPENTELEMETRY__ENABLED=true MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true uv run fastapi run mockstack/main.py
|
|
41
|
+
|
|
42
|
+
Out of the box, you get the following behavior when using the default `filefixtures` strategy:
|
|
43
|
+
|
|
44
|
+
- The HTTP request `GET /someservice/api/v1/user/c27f5b2b-6e81-420d-a4e4-6426e1c32db8` will try to find `<templates_dir>/someservice-api-v1-user.c27f5b2b-6e81-420d-a4e4-6426e1c32db8.j2`,
|
|
45
|
+
and will fallback to `<templates_dir>/someservice-api-v1-user.j2` (and finally to `index.j2` if exists). These are j2 files that have access to request body context variables.
|
|
46
|
+
- The HTTP request `POST /someservice/api/v2/item` with a JSON body will attempt to intelligently simulate the creation of a resource, returning the appropriate status code and will echo back the provided request resource, after injecting additional metadata fields based on strategy configuration. This is useful for services that expect fields such as `id` and `created_at` on returned created resources.
|
|
47
|
+
- HTTP requests for `DELETE` / `PUT` / `PATCH` are a no-op by default, simply returning the appropriate status code.
|
|
48
|
+
- The HTTP request `POST /someservice/api/v2/embedding_search` will be handled as a search request rather than a resource creation, returning an appropriate http status code and mock results based on user-configurable formatting.
|
|
49
|
+
|
|
50
|
+
Overall, the design philosophy is that things "just work". The framework attempts to intelligently deduce the intent of the request as much as possible and act accordingly,
|
|
51
|
+
while leaving room for advanced users to go in and customize behavior using the configuration options.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## Testing
|
|
55
|
+
|
|
56
|
+
Invoke unit-tests with:
|
|
57
|
+
|
|
58
|
+
uv run python -m pytest
|
|
59
|
+
|
|
60
|
+
Linting, formatting, static type checks etc. are all managed via [pre-commit](https://pre-commit.com/) hooks. These will run automatically on every commit. You can invoke these manually on all files with:
|
|
61
|
+
|
|
62
|
+
pre-commit run --all-files
|
|
File without changes
|
|
@@ -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()
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
@@ -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
|