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.
Files changed (60) hide show
  1. {mockstack-0.1.1 → mockstack-0.2.0}/.github/workflows/ci.yml +3 -1
  2. mockstack-0.2.0/.github/workflows/publish-docs.yml +35 -0
  3. {mockstack-0.1.1 → mockstack-0.2.0}/.gitignore +6 -0
  4. {mockstack-0.1.1 → mockstack-0.2.0}/.pre-commit-config.yaml +11 -1
  5. {mockstack-0.1.1 → mockstack-0.2.0}/PKG-INFO +15 -5
  6. {mockstack-0.1.1 → mockstack-0.2.0}/README.md +13 -5
  7. mockstack-0.2.0/docs/assets/favicon.ico +0 -0
  8. mockstack-0.2.0/docs/assets/logo.png +0 -0
  9. mockstack-0.2.0/docs/assets/mockstack.png +0 -0
  10. mockstack-0.2.0/mkdocs.yml +58 -0
  11. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/config.py +32 -3
  12. mockstack-0.2.0/mockstack/constants.py +19 -0
  13. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/display.py +4 -2
  14. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/lifespan.py +1 -1
  15. mockstack-0.2.0/mockstack/strategies/__init__.py +5 -0
  16. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/strategies/base.py +5 -0
  17. mockstack-0.2.0/mockstack/strategies/factory.py +48 -0
  18. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/strategies/filefixtures.py +16 -2
  19. mockstack-0.2.0/mockstack/strategies/proxyrules.py +150 -0
  20. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/templating.py +17 -10
  21. mockstack-0.2.0/mockstack/tests/conftest.py +52 -0
  22. mockstack-0.2.0/mockstack/tests/fixtures/proxyrules.yml +8 -0
  23. mockstack-0.2.0/mockstack/tests/strategies/test_filefixtures.py +97 -0
  24. mockstack-0.2.0/mockstack/tests/strategies/test_proxyrules.py +230 -0
  25. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_display.py +2 -2
  26. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_templating.py +59 -25
  27. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/PKG-INFO +15 -5
  28. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/SOURCES.txt +10 -1
  29. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/requires.txt +2 -0
  30. {mockstack-0.1.1 → mockstack-0.2.0}/pyproject.toml +4 -0
  31. {mockstack-0.1.1 → mockstack-0.2.0}/uv.lock +29 -0
  32. mockstack-0.1.1/mockstack/strategies/factory.py +0 -21
  33. mockstack-0.1.1/mockstack/tests/conftest.py +0 -29
  34. mockstack-0.1.1/mockstack/tests/routers/__init__.py +0 -0
  35. mockstack-0.1.1/mockstack/tests/strategies/test_filefixtures.py +0 -312
  36. {mockstack-0.1.1 → mockstack-0.2.0}/.env.example +0 -0
  37. {mockstack-0.1.1 → mockstack-0.2.0}/.github/workflows/publish-to-pypi.yml +0 -0
  38. {mockstack-0.1.1 → mockstack-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  39. {mockstack-0.1.1 → mockstack-0.2.0}/LICENSE +0 -0
  40. {mockstack-0.1.1 → mockstack-0.2.0}/SECURITY.md +0 -0
  41. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/__init__.py +0 -0
  42. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/identifiers.py +0 -0
  43. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/main.py +0 -0
  44. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/middleware.py +0 -0
  45. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/routers/__init__.py +0 -0
  46. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/routers/catchall.py +0 -0
  47. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/routers/homepage.py +0 -0
  48. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/telemetry.py +0 -0
  49. {mockstack-0.1.1/mockstack/strategies → mockstack-0.2.0/mockstack/tests}/__init__.py +0 -0
  50. {mockstack-0.1.1/mockstack/tests → mockstack-0.2.0/mockstack/tests/fixtures/templates}/__init__.py +0 -0
  51. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  52. {mockstack-0.1.1/mockstack/tests/fixtures/templates → mockstack-0.2.0/mockstack/tests/routers}/__init__.py +0 -0
  53. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/routers/test_catchall.py +0 -0
  54. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/routers/test_homepage.py +0 -0
  55. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_identifiers.py +0 -0
  56. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack/tests/test_middleware.py +0 -0
  57. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/dependency_links.txt +0 -0
  58. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/entry_points.txt +0 -0
  59. {mockstack-0.1.1 → mockstack-0.2.0}/mockstack.egg-info/top_level.txt +0 -0
  60. {mockstack-0.1.1 → mockstack-0.2.0}/setup.cfg +0 -0
@@ -44,6 +44,8 @@ jobs:
44
44
  ruff check
45
45
  ruff format
46
46
  - name: Run mypy
47
- run: uv run mypy mockstack
47
+ run: |
48
+ uv pip install types-pyyaml
49
+ uv run mypy mockstack
48
50
  - name: Run tests
49
51
  run: uv run pytest
@@ -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.1.1
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
- # mockstack
25
+ ![mockstack logo](https://github.com/adamhadani/mockstack/raw/main/docs/assets/mockstack.png)
26
+
27
+ --------------------------------------------------------------------------------
28
+
24
29
 
25
30
  [![CI](https://github.com/adamhadani/mockstack/actions/workflows/ci.yml/badge.svg)](https://github.com/adamhadani/mockstack/actions/workflows/ci.yml)
26
31
  [![GitHub License](https://img.shields.io/github/license/adamhadani/mockstack)](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](./mockstack/config.py). Setting individual options can be done with env. variables as in the following example:
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
- MOCKSTACK__OPENTELEMETRY__ENABLED=true MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true uv run
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
- # mockstack
1
+ ![mockstack logo](https://github.com/adamhadani/mockstack/raw/main/docs/assets/mockstack.png)
2
+
3
+ --------------------------------------------------------------------------------
4
+
2
5
 
3
6
  [![CI](https://github.com/adamhadani/mockstack/actions/workflows/ci.yml/badge.svg)](https://github.com/adamhadani/mockstack/actions/workflows/ci.yml)
4
7
  [![GitHub License](https://img.shields.io/github/license/adamhadani/mockstack)](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](./mockstack/config.py). Setting individual options can be done with env. variables as in the following example:
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
- MOCKSTACK__OPENTELEMETRY__ENABLED=true MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true uv run
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
@@ -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 = "./templates" # type: ignore[assignment]
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}, "
@@ -22,7 +22,7 @@ def lifespan_provider(
22
22
  This is the context manager that FastAPI will use to manage the lifecycle of the application.
23
23
  """
24
24
  config.dictConfig(settings.logging)
25
- announce(settings)
25
+ announce(app, settings)
26
26
 
27
27
  yield
28
28
 
@@ -0,0 +1,5 @@
1
+ from mockstack.strategies.base import BaseStrategy
2
+ from mockstack.strategies.filefixtures import FileFixturesStrategy
3
+ from mockstack.strategies.proxyrules import ProxyRulesStrategy
4
+
5
+ __all__ = ["BaseStrategy", "FileFixturesStrategy", "ProxyRulesStrategy"]
@@ -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(settings.templates_dir))
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