mockstack 0.1.1__tar.gz → 0.3.0__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.3.0/.env.example +20 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/.github/workflows/ci.yml +3 -1
- mockstack-0.3.0/.github/workflows/publish-docs.yml +35 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/.gitignore +6 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/.pre-commit-config.yaml +11 -1
- {mockstack-0.1.1 → mockstack-0.3.0}/PKG-INFO +15 -5
- {mockstack-0.1.1 → mockstack-0.3.0}/README.md +13 -5
- mockstack-0.3.0/docs/assets/favicon.ico +0 -0
- mockstack-0.3.0/docs/assets/logo.png +0 -0
- mockstack-0.3.0/docs/assets/mockstack.png +0 -0
- mockstack-0.3.0/mkdocs.yml +58 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/config.py +54 -4
- mockstack-0.3.0/mockstack/constants.py +19 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/display.py +4 -2
- mockstack-0.3.0/mockstack/intent.py +53 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/lifespan.py +1 -1
- mockstack-0.3.0/mockstack/strategies/__init__.py +5 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/strategies/base.py +5 -0
- mockstack-0.3.0/mockstack/strategies/create_mixin.py +81 -0
- mockstack-0.3.0/mockstack/strategies/factory.py +48 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/strategies/filefixtures.py +27 -103
- mockstack-0.3.0/mockstack/strategies/proxyrules.py +171 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/telemetry.py +0 -1
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/templating.py +17 -10
- mockstack-0.3.0/mockstack/tests/conftest.py +53 -0
- mockstack-0.3.0/mockstack/tests/fixtures/proxyrules.yml +8 -0
- mockstack-0.3.0/mockstack/tests/strategies/test_filefixtures.py +97 -0
- mockstack-0.3.0/mockstack/tests/strategies/test_proxyrules.py +230 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_display.py +2 -2
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_templating.py +59 -25
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/PKG-INFO +15 -5
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/SOURCES.txt +12 -1
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/requires.txt +2 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/pyproject.toml +5 -1
- {mockstack-0.1.1 → mockstack-0.3.0}/uv.lock +29 -0
- mockstack-0.1.1/.env.example +0 -7
- mockstack-0.1.1/mockstack/strategies/factory.py +0 -21
- mockstack-0.1.1/mockstack/tests/conftest.py +0 -29
- mockstack-0.1.1/mockstack/tests/routers/__init__.py +0 -0
- mockstack-0.1.1/mockstack/tests/strategies/test_filefixtures.py +0 -312
- {mockstack-0.1.1 → mockstack-0.3.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/LICENSE +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/SECURITY.md +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/identifiers.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/main.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/middleware.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/routers/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/routers/catchall.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/routers/homepage.py +0 -0
- {mockstack-0.1.1/mockstack/strategies → mockstack-0.3.0/mockstack/tests}/__init__.py +0 -0
- {mockstack-0.1.1/mockstack/tests → mockstack-0.3.0/mockstack/tests/fixtures/templates}/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
- {mockstack-0.1.1/mockstack/tests/fixtures/templates → mockstack-0.3.0/mockstack/tests/routers}/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/routers/test_catchall.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/routers/test_homepage.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_identifiers.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_middleware.py +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/dependency_links.txt +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/entry_points.txt +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/top_level.txt +0 -0
- {mockstack-0.1.1 → mockstack-0.3.0}/setup.cfg +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Example environment variables for mockstack
|
|
2
|
+
|
|
3
|
+
# the strategy to use
|
|
4
|
+
MOCKSTACK__STRATEGY = filefixtures
|
|
5
|
+
|
|
6
|
+
# strategy-specific settings
|
|
7
|
+
|
|
8
|
+
# base dir for all templates. Used by 'filefixtures' strategy
|
|
9
|
+
# and other hybrid strategies utilizing templates.
|
|
10
|
+
MOCKSTACK__TEMPLATES_DIR = "/some/path/templates/"
|
|
11
|
+
|
|
12
|
+
# rules table for the proxyrules strategy.
|
|
13
|
+
# see .mockstack/tests/fixtures/ for examples.
|
|
14
|
+
MOCKSTACK__PROXYRULES_RULES_FILENAME="/some/path/proxyrules.yml"
|
|
15
|
+
|
|
16
|
+
MOCKSTACK__PROXYRULES_SIMULATE_CREATE_ON_MISSING = true
|
|
17
|
+
|
|
18
|
+
# OpenTelemetry settings
|
|
19
|
+
MOCKSTACK__OPENTELEMETRY__ENABLED = true
|
|
20
|
+
MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY = true
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
- docs/**
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
deploy:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Configure Git Credentials
|
|
18
|
+
run: |
|
|
19
|
+
git config --global user.name "${GITHUB_ACTOR}"
|
|
20
|
+
git config --global user.email "${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com"
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: 3.x
|
|
24
|
+
- run: echo "cache_id=$(date --utc '+%V')" >> "${GITHUB_ENV}"
|
|
25
|
+
- uses: actions/cache@v4
|
|
26
|
+
with:
|
|
27
|
+
key: mkdocs-material-${{ env.cache_id }}
|
|
28
|
+
path: .cache
|
|
29
|
+
restore-keys: |
|
|
30
|
+
mkdocs-material-
|
|
31
|
+
- run: pip install mkdocs-material
|
|
32
|
+
- name: copy README.md to docs/
|
|
33
|
+
run: |
|
|
34
|
+
cp README.md docs/
|
|
35
|
+
- run: mkdocs gh-deploy --force
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# MacOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
1
4
|
# Byte-compiled / optimized / DLL files
|
|
2
5
|
__pycache__/
|
|
3
6
|
*.py[cod]
|
|
@@ -71,6 +74,9 @@ instance/
|
|
|
71
74
|
# Sphinx documentation
|
|
72
75
|
docs/_build/
|
|
73
76
|
|
|
77
|
+
# Do not check in README.md under docs/ - it gets copied there by build-docs CI workflow.
|
|
78
|
+
docs/README.md
|
|
79
|
+
|
|
74
80
|
# PyBuilder
|
|
75
81
|
.pybuilder/
|
|
76
82
|
target/
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
fail_fast: true
|
|
2
2
|
repos:
|
|
3
|
+
- repo: local
|
|
4
|
+
hooks:
|
|
5
|
+
# do not allow commits with files containing certain blacklisted strings
|
|
6
|
+
- id: dontship
|
|
7
|
+
name: detect blacklisted strings
|
|
8
|
+
exclude: ^.pre-commit-config.yaml
|
|
9
|
+
entry: codeartifact
|
|
10
|
+
language: pygrep
|
|
11
|
+
types: [text]
|
|
3
12
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
4
13
|
rev: v5.0.0
|
|
5
14
|
hooks:
|
|
@@ -8,12 +17,12 @@ repos:
|
|
|
8
17
|
- id: check-case-conflict
|
|
9
18
|
- id: check-json
|
|
10
19
|
- id: check-yaml
|
|
20
|
+
args: [ --unsafe ]
|
|
11
21
|
- id: detect-aws-credentials
|
|
12
22
|
- id: detect-private-key
|
|
13
23
|
- id: end-of-file-fixer
|
|
14
24
|
- id: trailing-whitespace
|
|
15
25
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
16
|
-
# Ruff version.
|
|
17
26
|
rev: v0.5.0
|
|
18
27
|
hooks:
|
|
19
28
|
# Run the linter.
|
|
@@ -25,6 +34,7 @@ repos:
|
|
|
25
34
|
rev: v1.15.0
|
|
26
35
|
hooks:
|
|
27
36
|
- id: mypy
|
|
37
|
+
additional_dependencies: [types-pyyaml>=6.0.12]
|
|
28
38
|
- repo: local
|
|
29
39
|
hooks:
|
|
30
40
|
- id: pytest
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mockstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: An API mocking workhorse
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/adamhadani/mockstack
|
|
@@ -11,16 +11,21 @@ Description-Content-Type: text/markdown
|
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Requires-Dist: colorama>=0.4.6
|
|
13
13
|
Requires-Dist: fastapi[standard]>=0.115.12
|
|
14
|
+
Requires-Dist: httpx>=0.28.1
|
|
14
15
|
Requires-Dist: jinja2>=3.1.6
|
|
15
16
|
Requires-Dist: opentelemetry-distro[otlp]>=0.53b1
|
|
16
17
|
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.53b1
|
|
17
18
|
Requires-Dist: pydantic>=2.11.3
|
|
18
19
|
Requires-Dist: pydantic-settings>=2.9.1
|
|
20
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
19
21
|
Requires-Dist: typer>=0.15.2
|
|
20
22
|
Requires-Dist: uvicorn>=0.34.2
|
|
21
23
|
Dynamic: license-file
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
--------------------------------------------------------------------------------
|
|
28
|
+
|
|
24
29
|
|
|
25
30
|
[](https://github.com/adamhadani/mockstack/actions/workflows/ci.yml)
|
|
26
31
|
[](https://github.com/adamhadani/mockstack/blob/main/LICENSE)
|
|
@@ -51,9 +56,14 @@ or install into a persistent environment and add it to the PATH with:
|
|
|
51
56
|
|
|
52
57
|
## Usage
|
|
53
58
|
|
|
54
|
-
Available configuration options are [here](
|
|
59
|
+
Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py). Setting individual options can be done with env. variables as in the following example:
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
```shell
|
|
62
|
+
export MOCKSTACK__TEMPLATES_DIR=~/mockstack-templates/
|
|
63
|
+
export MOCKSTACK__OPENTELEMETRY__ENABLED=true
|
|
64
|
+
export MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true
|
|
65
|
+
uv run
|
|
66
|
+
```
|
|
57
67
|
|
|
58
68
|
Out of the box, you get the following behavior when using the default `filefixtures` strategy:
|
|
59
69
|
|
|
@@ -87,7 +97,7 @@ If you are contributing to development, you will want to clone this project, and
|
|
|
87
97
|
uv sync
|
|
88
98
|
uv pip install -e .
|
|
89
99
|
|
|
90
|
-
Copy the included [.env.example](.env.example) file to `.env` and fill in configuration as needed based on the given examples.
|
|
100
|
+
Copy the included [.env.example](https://github.com/adamhadani/mockstack/blob/main/.env.example) file to `.env` and fill in configuration as needed based on the given examples.
|
|
91
101
|
|
|
92
102
|
Run in development mode (for live-reload of changes when developing):
|
|
93
103
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
--------------------------------------------------------------------------------
|
|
4
|
+
|
|
2
5
|
|
|
3
6
|
[](https://github.com/adamhadani/mockstack/actions/workflows/ci.yml)
|
|
4
7
|
[](https://github.com/adamhadani/mockstack/blob/main/LICENSE)
|
|
@@ -29,9 +32,14 @@ or install into a persistent environment and add it to the PATH with:
|
|
|
29
32
|
|
|
30
33
|
## Usage
|
|
31
34
|
|
|
32
|
-
Available configuration options are [here](
|
|
35
|
+
Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py). Setting individual options can be done with env. variables as in the following example:
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
```shell
|
|
38
|
+
export MOCKSTACK__TEMPLATES_DIR=~/mockstack-templates/
|
|
39
|
+
export MOCKSTACK__OPENTELEMETRY__ENABLED=true
|
|
40
|
+
export MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true
|
|
41
|
+
uv run
|
|
42
|
+
```
|
|
35
43
|
|
|
36
44
|
Out of the box, you get the following behavior when using the default `filefixtures` strategy:
|
|
37
45
|
|
|
@@ -65,8 +73,8 @@ If you are contributing to development, you will want to clone this project, and
|
|
|
65
73
|
uv sync
|
|
66
74
|
uv pip install -e .
|
|
67
75
|
|
|
68
|
-
Copy the included [.env.example](.env.example) file to `.env` and fill in configuration as needed based on the given examples.
|
|
76
|
+
Copy the included [.env.example](https://github.com/adamhadani/mockstack/blob/main/.env.example) file to `.env` and fill in configuration as needed based on the given examples.
|
|
69
77
|
|
|
70
78
|
Run in development mode (for live-reload of changes when developing):
|
|
71
79
|
|
|
72
|
-
uv run uvicorn --factory mockstack.main:create_app --reload
|
|
80
|
+
uv run uvicorn --factory mockstack.main:create_app --reload
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
site_name: mockstack
|
|
2
|
+
site_url: https://adamhadani.github.io/mockstack
|
|
3
|
+
repo_name: mockstack
|
|
4
|
+
repo_url: https://github.com/adamhadani/mockstack
|
|
5
|
+
|
|
6
|
+
theme:
|
|
7
|
+
name: material
|
|
8
|
+
logo: assets/logo.png
|
|
9
|
+
favicon: assets/favicon.ico
|
|
10
|
+
font:
|
|
11
|
+
text: Merriweather Sans
|
|
12
|
+
code: Red Hat Mono
|
|
13
|
+
palette:
|
|
14
|
+
# Dark Mode
|
|
15
|
+
- scheme: slate
|
|
16
|
+
toggle:
|
|
17
|
+
icon: material/weather-sunny
|
|
18
|
+
name: Dark mode
|
|
19
|
+
primary: amber
|
|
20
|
+
accent: deep purple
|
|
21
|
+
# Light Mode
|
|
22
|
+
- scheme: default
|
|
23
|
+
toggle:
|
|
24
|
+
icon: material/weather-night
|
|
25
|
+
name: Light mode
|
|
26
|
+
primary: blue
|
|
27
|
+
accent: deep orange
|
|
28
|
+
features:
|
|
29
|
+
- content.code.copy
|
|
30
|
+
- navigation.footer
|
|
31
|
+
markdown_extensions:
|
|
32
|
+
- admonition
|
|
33
|
+
- attr_list
|
|
34
|
+
- pymdownx.details
|
|
35
|
+
- pymdownx.emoji:
|
|
36
|
+
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
|
37
|
+
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
|
38
|
+
- pymdownx.highlight:
|
|
39
|
+
anchor_linenums: true
|
|
40
|
+
line_spans: __span
|
|
41
|
+
pygments_lang_class: true
|
|
42
|
+
- pymdownx.inlinehilite
|
|
43
|
+
- pymdownx.snippets
|
|
44
|
+
- pymdownx.superfences:
|
|
45
|
+
custom_fences:
|
|
46
|
+
- name: mermaid
|
|
47
|
+
class: mermaid
|
|
48
|
+
format: !!python/name:pymdownx.superfences.fence_code_format
|
|
49
|
+
- pymdownx.tabbed:
|
|
50
|
+
alternate_style: true
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
extra:
|
|
54
|
+
social:
|
|
55
|
+
- icon: simple/github
|
|
56
|
+
link: https://github.com/adamhadani/mockstack
|
|
57
|
+
|
|
58
|
+
copyright: Copyright © 2025 Adam Ever-Hadani
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from functools import lru_cache
|
|
2
|
-
from typing import Any, Literal
|
|
2
|
+
from typing import Any, Literal, Self
|
|
3
3
|
|
|
4
|
-
from pydantic import DirectoryPath
|
|
4
|
+
from pydantic import DirectoryPath, FilePath, model_validator
|
|
5
5
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
6
|
|
|
7
|
+
from mockstack.constants import ProxyRulesRedirectVia
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
class OpenTelemetrySettings(BaseSettings):
|
|
9
11
|
"""Settings for OpenTelemetry."""
|
|
@@ -38,7 +40,18 @@ class Settings(BaseSettings):
|
|
|
38
40
|
strategy: Literal["filefixtures", "proxyrules"] = "filefixtures"
|
|
39
41
|
|
|
40
42
|
# base directory for templates used by strategies
|
|
41
|
-
templates_dir: DirectoryPath =
|
|
43
|
+
templates_dir: DirectoryPath | None = None # type: ignore[assignment]
|
|
44
|
+
|
|
45
|
+
# rules filename for proxyrules strategy
|
|
46
|
+
proxyrules_rules_filename: FilePath | None = None # type: ignore[assignment]
|
|
47
|
+
|
|
48
|
+
# controls behavior of proxying. Whether to use HTTP status code redirects
|
|
49
|
+
# or reverse proxy the request to the target URL "silently".
|
|
50
|
+
proxyrules_redirect_via: ProxyRulesRedirectVia = ProxyRulesRedirectVia.REVERSE_PROXY
|
|
51
|
+
|
|
52
|
+
# controls behavior of proxying. Whether to simulate creation of resources
|
|
53
|
+
# when a POST request is made to a resource that doesn't match any rules..
|
|
54
|
+
proxyrules_simulate_create_on_missing: bool = False
|
|
42
55
|
|
|
43
56
|
# metadata fields to inject into created resources.
|
|
44
57
|
# A few template fields are available. See documentation for more details.
|
|
@@ -65,7 +78,8 @@ class Settings(BaseSettings):
|
|
|
65
78
|
"disable_existing_loggers": False,
|
|
66
79
|
"formatters": {
|
|
67
80
|
"standard": {
|
|
68
|
-
"
|
|
81
|
+
"()": "uvicorn.logging.DefaultFormatter",
|
|
82
|
+
"fmt": "%(levelprefix)s %(message)s",
|
|
69
83
|
},
|
|
70
84
|
},
|
|
71
85
|
"handlers": {
|
|
@@ -77,13 +91,49 @@ class Settings(BaseSettings):
|
|
|
77
91
|
},
|
|
78
92
|
},
|
|
79
93
|
"loggers": {
|
|
94
|
+
"uvicorn": {
|
|
95
|
+
"handlers": ["console"],
|
|
96
|
+
"level": "INFO",
|
|
97
|
+
"propagate": False,
|
|
98
|
+
},
|
|
80
99
|
"FileFixturesStrategy": {
|
|
81
100
|
"handlers": ["console"],
|
|
82
101
|
"level": "INFO",
|
|
102
|
+
"propagate": False,
|
|
103
|
+
},
|
|
104
|
+
"ProxyRulesStrategy": {
|
|
105
|
+
"handlers": ["console"],
|
|
106
|
+
"level": "INFO",
|
|
107
|
+
"propagate": False,
|
|
83
108
|
},
|
|
84
109
|
},
|
|
110
|
+
"root": {
|
|
111
|
+
"handlers": ["console"],
|
|
112
|
+
"level": "INFO",
|
|
113
|
+
"propagate": False,
|
|
114
|
+
},
|
|
85
115
|
}
|
|
86
116
|
|
|
117
|
+
@model_validator(mode="after")
|
|
118
|
+
def validate_strategy_parameters(self) -> Self:
|
|
119
|
+
"""Validate the strategy parameters."""
|
|
120
|
+
|
|
121
|
+
# TODO: make this validation dynamic based on the strategy classes themselves.
|
|
122
|
+
|
|
123
|
+
if self.strategy == "proxyrules":
|
|
124
|
+
if self.proxyrules_rules_filename is None:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"proxyrules_rules_filename is required when strategy is proxyrules"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
elif self.strategy == "filefixtures":
|
|
130
|
+
if self.templates_dir is None:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"templates_dir is required when strategy is proxyrules"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return self
|
|
136
|
+
|
|
87
137
|
|
|
88
138
|
@lru_cache
|
|
89
139
|
def settings_provider() -> Settings:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Constants for mockstack."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
# proxyrules strategy constants
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProxyRulesRedirectVia(StrEnum):
|
|
9
|
+
"""The type of redirect to use for the proxy rules strategy.
|
|
10
|
+
|
|
11
|
+
- HTTP_* type redirects are handled by using a Http redirect response.
|
|
12
|
+
- REVERSE_PROXY type redirects are handled by using a reverse proxy to the target URL
|
|
13
|
+
which is opaque to the client.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
HTTP_TEMPORARY_REDIRECT = "http_307_temporary"
|
|
18
|
+
HTTP_PERMANENT_REDIRECT = "http_301_permanent"
|
|
19
|
+
REVERSE_PROXY = "reverse_proxy"
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
5
7
|
from mockstack.config import Settings
|
|
6
8
|
|
|
7
9
|
|
|
@@ -17,7 +19,7 @@ class ANSIColors:
|
|
|
17
19
|
UNDERLINE = "\033[4m"
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
def announce(settings: Settings):
|
|
22
|
+
def announce(app: FastAPI, settings: Settings):
|
|
21
23
|
"""Log the startup message with the active settings."""
|
|
22
24
|
HIGHLIGHT = ANSIColors.HEADER
|
|
23
25
|
ENDC = ANSIColors.ENDC
|
|
@@ -26,8 +28,8 @@ def announce(settings: Settings):
|
|
|
26
28
|
logger.info(
|
|
27
29
|
f"{HIGHLIGHT}mockstack{ENDC} ready to roll. "
|
|
28
30
|
f"Using strategy: {HIGHLIGHT}{settings.strategy}{ENDC}, "
|
|
29
|
-
f"templates_dir: {HIGHLIGHT}{settings.templates_dir}{ENDC}. "
|
|
30
31
|
)
|
|
32
|
+
logger.info(str(app.state.strategy))
|
|
31
33
|
logger.info(
|
|
32
34
|
f"OpenTelemetry enabled: {HIGHLIGHT}{settings.opentelemetry.enabled}{ENDC}, "
|
|
33
35
|
f"endpoint: {HIGHLIGHT}{settings.opentelemetry.endpoint}{ENDC}, "
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Helpers for deducing user intent from a request."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def wants_json(request: Request) -> bool:
|
|
7
|
+
"""Check if the request wants JSON response."""
|
|
8
|
+
content_type = request.headers.get("Content-Type", "")
|
|
9
|
+
return any(
|
|
10
|
+
(
|
|
11
|
+
content_type.startswith("application/json"),
|
|
12
|
+
content_type.startswith("text/json"),
|
|
13
|
+
request.url.path.endswith(".json"),
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def looks_like_a_search(request: Request) -> bool:
|
|
19
|
+
"""Check if the request looks like a search.
|
|
20
|
+
|
|
21
|
+
This is a heuristic to try and identify cases where a POST
|
|
22
|
+
request is used for issuing a search rather than for creating
|
|
23
|
+
a new resource.
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
return any(
|
|
27
|
+
(
|
|
28
|
+
request.url.path.endswith("_search"),
|
|
29
|
+
request.url.path.endswith("/search"),
|
|
30
|
+
request.url.path.endswith("_query"),
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def looks_like_a_command(request: Request) -> bool:
|
|
36
|
+
"""Check if the request looks like a command.
|
|
37
|
+
|
|
38
|
+
This is a heuristic to try and identify cases where a POST
|
|
39
|
+
request is used for issuing a command rather than for creating
|
|
40
|
+
a new resource.
|
|
41
|
+
"""
|
|
42
|
+
return any(
|
|
43
|
+
(
|
|
44
|
+
request.url.path.endswith("_command"),
|
|
45
|
+
request.url.path.endswith("/command"),
|
|
46
|
+
request.url.path.endswith("_request"),
|
|
47
|
+
request.url.path.endswith("/request"),
|
|
48
|
+
request.url.path.endswith("_run"),
|
|
49
|
+
request.url.path.endswith("/run"),
|
|
50
|
+
request.url.path.endswith("_execute"),
|
|
51
|
+
request.url.path.endswith("/execute"),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
@@ -4,10 +4,15 @@ from abc import ABC, abstractmethod
|
|
|
4
4
|
|
|
5
5
|
from fastapi import Request, Response
|
|
6
6
|
|
|
7
|
+
from mockstack.config import Settings
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
class BaseStrategy(ABC):
|
|
9
11
|
"""Base strategy for MockStack."""
|
|
10
12
|
|
|
13
|
+
def __init__(self, settings: Settings, *args, **kwargs):
|
|
14
|
+
self.settings = settings
|
|
15
|
+
|
|
11
16
|
@abstractmethod
|
|
12
17
|
async def apply(self, request: Request) -> Response:
|
|
13
18
|
"""Apply the strategy to the request and response."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Create mixin class."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from fastapi import Request, Response, status
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
from jinja2 import Environment
|
|
9
|
+
|
|
10
|
+
from mockstack.intent import wants_json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CreateMixin:
|
|
14
|
+
"""A mixin for strategies that need to simulate creation of resources."""
|
|
15
|
+
|
|
16
|
+
async def _create(
|
|
17
|
+
self, request: Request, *, env: Environment, created_resource_metadata: dict
|
|
18
|
+
) -> Response:
|
|
19
|
+
if wants_json(request):
|
|
20
|
+
# We return a 201 CREATED response with the resource as the body,
|
|
21
|
+
# potentially injecting the resource ID into the response.
|
|
22
|
+
resource = await request.json()
|
|
23
|
+
|
|
24
|
+
return JSONResponse(
|
|
25
|
+
status_code=status.HTTP_201_CREATED,
|
|
26
|
+
content=self._content(
|
|
27
|
+
resource,
|
|
28
|
+
request=request,
|
|
29
|
+
env=env,
|
|
30
|
+
created_resource_metadata=created_resource_metadata,
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
else:
|
|
34
|
+
# We return a 201 CREATED response with an empty body.
|
|
35
|
+
return Response(
|
|
36
|
+
status_code=status.HTTP_201_CREATED,
|
|
37
|
+
content=None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _content(
|
|
41
|
+
self,
|
|
42
|
+
resource: dict,
|
|
43
|
+
*,
|
|
44
|
+
env: Environment,
|
|
45
|
+
request: Request,
|
|
46
|
+
created_resource_metadata: dict,
|
|
47
|
+
) -> dict:
|
|
48
|
+
"""Create a new resource given a request resource.
|
|
49
|
+
|
|
50
|
+
We use the request resource as the basis for the new resource.
|
|
51
|
+
We then inject an identifier into the resource if it doesn't already have one,
|
|
52
|
+
as well as any other metadata fields that are configured for the strategy.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def with_metadata(resource: dict, copy=True) -> dict:
|
|
57
|
+
"""Inject metadata fields into the resource."""
|
|
58
|
+
_resource = resource.copy() if copy else resource
|
|
59
|
+
for key, value in created_resource_metadata.items():
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
_resource[key] = env.from_string(value).render(
|
|
62
|
+
self._metadata_context(request)
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
_resource[key] = value
|
|
66
|
+
return _resource
|
|
67
|
+
|
|
68
|
+
return with_metadata(resource)
|
|
69
|
+
|
|
70
|
+
def _metadata_context(self, request: Request) -> dict:
|
|
71
|
+
"""Context for injecting metadata fields into resources.
|
|
72
|
+
|
|
73
|
+
Some care is needed to ensure that we only expose the minimum amount
|
|
74
|
+
of information here since templates are user-defined.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
return {
|
|
78
|
+
"utcnow": lambda: datetime.now(timezone.utc),
|
|
79
|
+
"uuid4": uuid4,
|
|
80
|
+
"request": request,
|
|
81
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Factory for creating strategies."""
|
|
2
|
+
|
|
3
|
+
from typing import Type
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from mockstack.config import Settings
|
|
8
|
+
from mockstack.strategies import BaseStrategy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def name_for(cls: type[BaseStrategy]) -> str:
|
|
12
|
+
"""Get the name of a strategy from its class.
|
|
13
|
+
|
|
14
|
+
we support class attributes for the name, otherwise we use the class name
|
|
15
|
+
without the "Strategy" suffix and convert to lowercase:
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
|
|
19
|
+
>>> name_for(FileFixturesStrategy)
|
|
20
|
+
"filefixtures"
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
return getattr(cls, "name", cls.__name__.replace("Strategy", "").lower())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def available_strategies() -> dict[str, Type[BaseStrategy]]:
|
|
27
|
+
"""Get all available strategies."""
|
|
28
|
+
return {name_for(subclass): subclass for subclass in BaseStrategy.__subclasses__()} # type: ignore[type-abstract]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def strategy_provider(app: FastAPI, settings: Settings) -> BaseStrategy:
|
|
32
|
+
"""Factory for creating strategies."""
|
|
33
|
+
strategies = available_strategies()
|
|
34
|
+
|
|
35
|
+
if settings.strategy not in strategies:
|
|
36
|
+
raise ValueError(f"Unknown strategy: {settings.strategy}")
|
|
37
|
+
|
|
38
|
+
strategy = strategies[settings.strategy](settings)
|
|
39
|
+
|
|
40
|
+
# add strategy to app state for dependency injection
|
|
41
|
+
app.state.strategy = strategy
|
|
42
|
+
|
|
43
|
+
return strategy
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
AVAILABLE_STRATEGIES = tuple(available_strategies().keys())
|
|
47
|
+
|
|
48
|
+
DEFAULT_STRATEGY = "filefixtures"
|