mockstack 0.1.1__tar.gz → 0.2.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.1.1 → mockstack-0.2.0}/.github/workflows/ci.yml +3 -1
- mockstack-0.2.0/.github/workflows/publish-docs.yml +35 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/.gitignore +6 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/.pre-commit-config.yaml +11 -1
- {mockstack-0.1.1 → mockstack-0.2.0}/PKG-INFO +15 -5
- {mockstack-0.1.1 → mockstack-0.2.0}/README.md +13 -5
- mockstack-0.2.0/docs/assets/favicon.ico +0 -0
- mockstack-0.2.0/docs/assets/logo.png +0 -0
- mockstack-0.2.0/docs/assets/mockstack.png +0 -0
- mockstack-0.2.0/mkdocs.yml +58 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/config.py +32 -3
- mockstack-0.2.0/mockstack/constants.py +19 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/display.py +4 -2
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/lifespan.py +1 -1
- mockstack-0.2.0/mockstack/strategies/__init__.py +5 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/strategies/base.py +5 -0
- mockstack-0.2.0/mockstack/strategies/factory.py +48 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/strategies/filefixtures.py +16 -2
- mockstack-0.2.0/mockstack/strategies/proxyrules.py +150 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/templating.py +17 -10
- mockstack-0.2.0/mockstack/tests/conftest.py +52 -0
- mockstack-0.2.0/mockstack/tests/fixtures/proxyrules.yml +8 -0
- mockstack-0.2.0/mockstack/tests/strategies/test_filefixtures.py +97 -0
- mockstack-0.2.0/mockstack/tests/strategies/test_proxyrules.py +230 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_display.py +2 -2
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_templating.py +59 -25
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/PKG-INFO +15 -5
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/SOURCES.txt +10 -1
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/requires.txt +2 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/pyproject.toml +4 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/uv.lock +29 -0
- 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.2.0}/.env.example +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/CODE_OF_CONDUCT.md +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/LICENSE +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/SECURITY.md +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/identifiers.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/main.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/middleware.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/routers/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/routers/catchall.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/routers/homepage.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/telemetry.py +0 -0
- {mockstack-0.1.1/mockstack/strategies → mockstack-0.2.0/mockstack/tests}/__init__.py +0 -0
- {mockstack-0.1.1/mockstack/tests → mockstack-0.2.0/mockstack/tests/fixtures/templates}/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
- {mockstack-0.1.1/mockstack/tests/fixtures/templates → mockstack-0.2.0/mockstack/tests/routers}/__init__.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/routers/test_catchall.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/routers/test_homepage.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_identifiers.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_middleware.py +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/dependency_links.txt +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/entry_points.txt +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/top_level.txt +0 -0
- {mockstack-0.1.1 → mockstack-0.2.0}/setup.cfg +0 -0
|
@@ -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.2.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,14 @@ 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
|
|
42
51
|
|
|
43
52
|
# metadata fields to inject into created resources.
|
|
44
53
|
# A few template fields are available. See documentation for more details.
|
|
@@ -84,6 +93,26 @@ class Settings(BaseSettings):
|
|
|
84
93
|
},
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
@model_validator(mode="after")
|
|
97
|
+
def validate_strategy_parameters(self) -> Self:
|
|
98
|
+
"""Validate the strategy parameters."""
|
|
99
|
+
|
|
100
|
+
# TODO: make this validation dynamic based on the strategy classes themselves.
|
|
101
|
+
|
|
102
|
+
if self.strategy == "proxyrules":
|
|
103
|
+
if self.proxyrules_rules_filename is None:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"proxyrules_rules_filename is required when strategy is proxyrules"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
elif self.strategy == "filefixtures":
|
|
109
|
+
if self.templates_dir is None:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"templates_dir is required when strategy is proxyrules"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return self
|
|
115
|
+
|
|
87
116
|
|
|
88
117
|
@lru_cache
|
|
89
118
|
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}, "
|
|
@@ -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,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"
|
|
@@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse
|
|
|
11
11
|
from jinja2 import Environment, FileSystemLoader
|
|
12
12
|
|
|
13
13
|
from mockstack.config import Settings
|
|
14
|
+
from mockstack.display import ANSIColors
|
|
14
15
|
from mockstack.strategies.base import BaseStrategy
|
|
15
16
|
from mockstack.templating import (
|
|
16
17
|
iter_possible_template_arguments,
|
|
@@ -67,12 +68,25 @@ class FileFixturesStrategy(BaseStrategy):
|
|
|
67
68
|
logger = logging.getLogger("FileFixturesStrategy")
|
|
68
69
|
|
|
69
70
|
def __init__(self, settings: Settings, *args, **kwargs):
|
|
70
|
-
super().__init__(*args, **kwargs)
|
|
71
|
+
super().__init__(settings, *args, **kwargs)
|
|
72
|
+
|
|
73
|
+
if settings.templates_dir is None:
|
|
74
|
+
raise ValueError("templates_dir is not set")
|
|
75
|
+
|
|
71
76
|
self.templates_dir = Path(settings.templates_dir)
|
|
72
77
|
self.created_resource_metadata = settings.created_resource_metadata
|
|
73
78
|
self.missing_resource_fields = settings.missing_resource_fields
|
|
74
79
|
|
|
75
|
-
self.env = Environment(loader=FileSystemLoader(
|
|
80
|
+
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str:
|
|
83
|
+
HIGHLIGHT = ANSIColors.HEADER
|
|
84
|
+
ENDC = ANSIColors.ENDC
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
f"{HIGHLIGHT}[filefixtures]{ENDC} "
|
|
88
|
+
f"templates_dir: {HIGHLIGHT}{self.templates_dir}{ENDC}. "
|
|
89
|
+
)
|
|
76
90
|
|
|
77
91
|
async def apply(self, request: Request) -> Response:
|
|
78
92
|
match request.method:
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Strategy for using proxy rules."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Self
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import yaml
|
|
11
|
+
from fastapi import Request, Response, status
|
|
12
|
+
from fastapi.responses import RedirectResponse
|
|
13
|
+
from starlette.datastructures import Headers
|
|
14
|
+
|
|
15
|
+
from mockstack.config import Settings
|
|
16
|
+
from mockstack.constants import ProxyRulesRedirectVia
|
|
17
|
+
from mockstack.display import ANSIColors
|
|
18
|
+
from mockstack.strategies.base import BaseStrategy
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Rule:
|
|
22
|
+
"""A rule for the proxy rules strategy."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, pattern: str, replacement: str, method: str | None = None):
|
|
25
|
+
self.pattern = pattern
|
|
26
|
+
self.replacement = replacement
|
|
27
|
+
self.method = method
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, data: dict[str, str]) -> Self:
|
|
31
|
+
return cls(
|
|
32
|
+
pattern=data["pattern"],
|
|
33
|
+
replacement=data["replacement"],
|
|
34
|
+
method=data.get("method", None),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def matches(self, request: Request) -> bool:
|
|
38
|
+
"""Check if the rule matches the request."""
|
|
39
|
+
if self.method is not None and request.method.lower() != self.method.lower():
|
|
40
|
+
# if rule is limited to a specific HTTP method, validate first.
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
return re.match(self.pattern, request.url.path) is not None
|
|
44
|
+
|
|
45
|
+
def apply(self, request: Request) -> str:
|
|
46
|
+
"""Apply the rule to the request."""
|
|
47
|
+
return self._url_for(request.url.path)
|
|
48
|
+
|
|
49
|
+
def _url_for(self, path: str) -> str:
|
|
50
|
+
return re.sub(self.pattern, self.replacement, path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProxyRulesStrategy(BaseStrategy):
|
|
54
|
+
"""Strategy for using proxy rules."""
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger("ProxyRulesStrategy")
|
|
57
|
+
|
|
58
|
+
def __init__(self, settings: Settings, *args, **kwargs):
|
|
59
|
+
super().__init__(settings, *args, **kwargs)
|
|
60
|
+
self.rules_filename = settings.proxyrules_rules_filename
|
|
61
|
+
self.proxyrules_redirect_via = settings.proxyrules_redirect_via
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
HIGHLIGHT = ANSIColors.HEADER
|
|
65
|
+
ENDC = ANSIColors.ENDC
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
f"{HIGHLIGHT}[proxyrules]{ENDC} "
|
|
69
|
+
f"rules_filename: {HIGHLIGHT}{self.rules_filename}{ENDC}. "
|
|
70
|
+
f"redirect_via: {HIGHLIGHT}{self.proxyrules_redirect_via}{ENDC}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@cached_property
|
|
74
|
+
def rules(self) -> list[Rule]:
|
|
75
|
+
return self.load_rules()
|
|
76
|
+
|
|
77
|
+
def load_rules(self) -> list[Rule]:
|
|
78
|
+
if self.rules_filename is None:
|
|
79
|
+
raise ValueError("rules_filename is not set")
|
|
80
|
+
|
|
81
|
+
with open(self.rules_filename, "r") as file:
|
|
82
|
+
data = yaml.safe_load(file)
|
|
83
|
+
return [Rule.from_dict(rule) for rule in data["rules"]]
|
|
84
|
+
|
|
85
|
+
def rule_for(self, request: Request) -> Rule | None:
|
|
86
|
+
try:
|
|
87
|
+
return next(rule for rule in self.rules if rule.matches(request))
|
|
88
|
+
except StopIteration:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
async def apply(self, request: Request) -> Response:
|
|
92
|
+
rule = self.rule_for(request)
|
|
93
|
+
if rule is None:
|
|
94
|
+
self.logger.warning(f"No rule found for request: {request.url.path=}")
|
|
95
|
+
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
|
96
|
+
|
|
97
|
+
url = rule.apply(request)
|
|
98
|
+
self.logger.info(f"Redirecting to: {url}")
|
|
99
|
+
|
|
100
|
+
match self.proxyrules_redirect_via:
|
|
101
|
+
case ProxyRulesRedirectVia.HTTP_TEMPORARY_REDIRECT:
|
|
102
|
+
return RedirectResponse(
|
|
103
|
+
url=url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
case ProxyRulesRedirectVia.HTTP_PERMANENT_REDIRECT:
|
|
107
|
+
return RedirectResponse(
|
|
108
|
+
url=url, status_code=status.HTTP_301_MOVED_PERMANENTLY
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
case ProxyRulesRedirectVia.REVERSE_PROXY:
|
|
112
|
+
response = await self.reverse_proxy(request, url)
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
case _:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Invalid redirect via: {self.proxyrules_redirect_via}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def reverse_proxy(self, request: Request, url: str) -> Response:
|
|
121
|
+
"""Reverse proxy the request to the target URL."""
|
|
122
|
+
async with httpx.AsyncClient() as client:
|
|
123
|
+
method = request.method
|
|
124
|
+
request_content = await request.body()
|
|
125
|
+
req = client.build_request(
|
|
126
|
+
method,
|
|
127
|
+
url,
|
|
128
|
+
content=request_content,
|
|
129
|
+
headers=self.reverse_proxy_headers(request.headers, url=url),
|
|
130
|
+
params=request.url.query,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
resp = await client.send(req, stream=False)
|
|
134
|
+
content = resp.read()
|
|
135
|
+
|
|
136
|
+
return Response(
|
|
137
|
+
content=content,
|
|
138
|
+
status_code=resp.status_code,
|
|
139
|
+
headers=resp.headers,
|
|
140
|
+
media_type=resp.headers.get("content-type"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def reverse_proxy_headers(self, headers: Headers, url: str) -> Headers:
|
|
144
|
+
"""Mutate the request headers for the reverse proxy mode."""
|
|
145
|
+
_headers = headers.mutablecopy()
|
|
146
|
+
|
|
147
|
+
# When reverse proxying, we must alter the Host header to the target URL.
|
|
148
|
+
_headers["host"] = urlparse(url).netloc
|
|
149
|
+
|
|
150
|
+
return _headers
|