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.
Files changed (63) hide show
  1. mockstack-0.3.0/.env.example +20 -0
  2. {mockstack-0.1.1 → mockstack-0.3.0}/.github/workflows/ci.yml +3 -1
  3. mockstack-0.3.0/.github/workflows/publish-docs.yml +35 -0
  4. {mockstack-0.1.1 → mockstack-0.3.0}/.gitignore +6 -0
  5. {mockstack-0.1.1 → mockstack-0.3.0}/.pre-commit-config.yaml +11 -1
  6. {mockstack-0.1.1 → mockstack-0.3.0}/PKG-INFO +15 -5
  7. {mockstack-0.1.1 → mockstack-0.3.0}/README.md +13 -5
  8. mockstack-0.3.0/docs/assets/favicon.ico +0 -0
  9. mockstack-0.3.0/docs/assets/logo.png +0 -0
  10. mockstack-0.3.0/docs/assets/mockstack.png +0 -0
  11. mockstack-0.3.0/mkdocs.yml +58 -0
  12. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/config.py +54 -4
  13. mockstack-0.3.0/mockstack/constants.py +19 -0
  14. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/display.py +4 -2
  15. mockstack-0.3.0/mockstack/intent.py +53 -0
  16. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/lifespan.py +1 -1
  17. mockstack-0.3.0/mockstack/strategies/__init__.py +5 -0
  18. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/strategies/base.py +5 -0
  19. mockstack-0.3.0/mockstack/strategies/create_mixin.py +81 -0
  20. mockstack-0.3.0/mockstack/strategies/factory.py +48 -0
  21. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/strategies/filefixtures.py +27 -103
  22. mockstack-0.3.0/mockstack/strategies/proxyrules.py +171 -0
  23. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/telemetry.py +0 -1
  24. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/templating.py +17 -10
  25. mockstack-0.3.0/mockstack/tests/conftest.py +53 -0
  26. mockstack-0.3.0/mockstack/tests/fixtures/proxyrules.yml +8 -0
  27. mockstack-0.3.0/mockstack/tests/strategies/test_filefixtures.py +97 -0
  28. mockstack-0.3.0/mockstack/tests/strategies/test_proxyrules.py +230 -0
  29. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_display.py +2 -2
  30. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_templating.py +59 -25
  31. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/PKG-INFO +15 -5
  32. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/SOURCES.txt +12 -1
  33. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/requires.txt +2 -0
  34. {mockstack-0.1.1 → mockstack-0.3.0}/pyproject.toml +5 -1
  35. {mockstack-0.1.1 → mockstack-0.3.0}/uv.lock +29 -0
  36. mockstack-0.1.1/.env.example +0 -7
  37. mockstack-0.1.1/mockstack/strategies/factory.py +0 -21
  38. mockstack-0.1.1/mockstack/tests/conftest.py +0 -29
  39. mockstack-0.1.1/mockstack/tests/routers/__init__.py +0 -0
  40. mockstack-0.1.1/mockstack/tests/strategies/test_filefixtures.py +0 -312
  41. {mockstack-0.1.1 → mockstack-0.3.0}/.github/workflows/publish-to-pypi.yml +0 -0
  42. {mockstack-0.1.1 → mockstack-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  43. {mockstack-0.1.1 → mockstack-0.3.0}/LICENSE +0 -0
  44. {mockstack-0.1.1 → mockstack-0.3.0}/SECURITY.md +0 -0
  45. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/__init__.py +0 -0
  46. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/identifiers.py +0 -0
  47. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/main.py +0 -0
  48. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/middleware.py +0 -0
  49. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/routers/__init__.py +0 -0
  50. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/routers/catchall.py +0 -0
  51. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/routers/homepage.py +0 -0
  52. {mockstack-0.1.1/mockstack/strategies → mockstack-0.3.0/mockstack/tests}/__init__.py +0 -0
  53. {mockstack-0.1.1/mockstack/tests → mockstack-0.3.0/mockstack/tests/fixtures/templates}/__init__.py +0 -0
  54. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  55. {mockstack-0.1.1/mockstack/tests/fixtures/templates → mockstack-0.3.0/mockstack/tests/routers}/__init__.py +0 -0
  56. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/routers/test_catchall.py +0 -0
  57. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/routers/test_homepage.py +0 -0
  58. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_identifiers.py +0 -0
  59. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack/tests/test_middleware.py +0 -0
  60. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/dependency_links.txt +0 -0
  61. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/entry_points.txt +0 -0
  62. {mockstack-0.1.1 → mockstack-0.3.0}/mockstack.egg-info/top_level.txt +0 -0
  63. {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
@@ -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.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
- # 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,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 = "./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
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
- "format": " %(levelname)s [%(name)s] %(message)s",
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
+ )
@@ -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,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"