mockstack 0.2.0__tar.gz → 0.4.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.4.0/.env.example +20 -0
  2. {mockstack-0.2.0/mockstack.egg-info → mockstack-0.4.0}/PKG-INFO +10 -7
  3. {mockstack-0.2.0 → mockstack-0.4.0}/README.md +9 -6
  4. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/config.py +69 -11
  5. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/constants.py +4 -1
  6. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/display.py +2 -1
  7. mockstack-0.4.0/mockstack/intent.py +53 -0
  8. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/lifespan.py +6 -1
  9. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/main.py +16 -18
  10. mockstack-0.4.0/mockstack/strategies/create_mixin.py +81 -0
  11. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/strategies/filefixtures.py +11 -101
  12. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/strategies/proxyrules.py +26 -5
  13. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/telemetry.py +0 -1
  14. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/conftest.py +1 -0
  15. {mockstack-0.2.0 → mockstack-0.4.0/mockstack.egg-info}/PKG-INFO +10 -7
  16. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack.egg-info/SOURCES.txt +2 -0
  17. mockstack-0.4.0/mockstack.egg-info/entry_points.txt +2 -0
  18. {mockstack-0.2.0 → mockstack-0.4.0}/pyproject.toml +2 -2
  19. mockstack-0.2.0/.env.example +0 -7
  20. mockstack-0.2.0/mockstack.egg-info/entry_points.txt +0 -2
  21. {mockstack-0.2.0 → mockstack-0.4.0}/.github/workflows/ci.yml +0 -0
  22. {mockstack-0.2.0 → mockstack-0.4.0}/.github/workflows/publish-docs.yml +0 -0
  23. {mockstack-0.2.0 → mockstack-0.4.0}/.github/workflows/publish-to-pypi.yml +0 -0
  24. {mockstack-0.2.0 → mockstack-0.4.0}/.gitignore +0 -0
  25. {mockstack-0.2.0 → mockstack-0.4.0}/.pre-commit-config.yaml +0 -0
  26. {mockstack-0.2.0 → mockstack-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  27. {mockstack-0.2.0 → mockstack-0.4.0}/LICENSE +0 -0
  28. {mockstack-0.2.0 → mockstack-0.4.0}/SECURITY.md +0 -0
  29. {mockstack-0.2.0 → mockstack-0.4.0}/docs/assets/favicon.ico +0 -0
  30. {mockstack-0.2.0 → mockstack-0.4.0}/docs/assets/logo.png +0 -0
  31. {mockstack-0.2.0 → mockstack-0.4.0}/docs/assets/mockstack.png +0 -0
  32. {mockstack-0.2.0 → mockstack-0.4.0}/mkdocs.yml +0 -0
  33. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/__init__.py +0 -0
  34. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/identifiers.py +0 -0
  35. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/middleware.py +0 -0
  36. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/routers/__init__.py +0 -0
  37. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/routers/catchall.py +0 -0
  38. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/routers/homepage.py +0 -0
  39. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/strategies/__init__.py +0 -0
  40. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/strategies/base.py +0 -0
  41. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/strategies/factory.py +0 -0
  42. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/templating.py +0 -0
  43. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/__init__.py +0 -0
  44. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
  45. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
  46. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  47. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/routers/__init__.py +0 -0
  48. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/routers/test_catchall.py +0 -0
  49. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/routers/test_homepage.py +0 -0
  50. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
  51. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/strategies/test_proxyrules.py +0 -0
  52. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/test_display.py +0 -0
  53. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/test_identifiers.py +0 -0
  54. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/test_middleware.py +0 -0
  55. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack/tests/test_templating.py +0 -0
  56. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack.egg-info/dependency_links.txt +0 -0
  57. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack.egg-info/requires.txt +0 -0
  58. {mockstack-0.2.0 → mockstack-0.4.0}/mockstack.egg-info/top_level.txt +0 -0
  59. {mockstack-0.2.0 → mockstack-0.4.0}/setup.cfg +0 -0
  60. {mockstack-0.2.0 → mockstack-0.4.0}/uv.lock +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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: An API mocking workhorse
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/adamhadani/mockstack
@@ -47,7 +47,7 @@ Highlights include:
47
47
 
48
48
  Install using [uv](https://docs.astral.sh/uv/). This package conforms the concept of a [tool](https://docs.astral.sh/uv/concepts/tools/) and hence can simply install / run with `uvx`:
49
49
 
50
- uvx mockstack
50
+ uvx mockstack --help
51
51
 
52
52
  or install into a persistent environment and add it to the PATH with:
53
53
 
@@ -56,15 +56,20 @@ or install into a persistent environment and add it to the PATH with:
56
56
 
57
57
  ## Usage
58
58
 
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:
59
+ Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py).
60
+
61
+ Setting individual options can be done either through an `.env` file, individual environment variables, or command-line arguments. For example:
60
62
 
61
63
  ```shell
64
+ export MOCKSTACK__STRATEGY=filefixtures
62
65
  export MOCKSTACK__TEMPLATES_DIR=~/mockstack-templates/
63
66
  export MOCKSTACK__OPENTELEMETRY__ENABLED=true
64
67
  export MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true
65
- uv run
68
+ uvx mockstack
66
69
  ```
67
70
 
71
+ See also the included [.env.example](https://github.com/adamhadani/mockstack/blob/main/.env.example) for more examples. You can copy that file to `.env` and fill in configuration as needed based on the given examples.
72
+
68
73
  Out of the box, you get the following behavior when using the default `filefixtures` strategy:
69
74
 
70
75
  - The HTTP request `GET /someservice/api/v1/user/c27f5b2b-6e81-420d-a4e4-6426e1c32db8` will try to find `<templates_dir>/someservice-api-v1-user.c27f5b2b-6e81-420d-a4e4-6426e1c32db8.j2`,
@@ -97,8 +102,6 @@ If you are contributing to development, you will want to clone this project, and
97
102
  uv sync
98
103
  uv pip install -e .
99
104
 
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.
101
-
102
105
  Run in development mode (for live-reload of changes when developing):
103
106
 
104
- uv run uvicorn --factory mockstack.main:create_app --reload
107
+ uv run -- mockstack --debug
@@ -23,7 +23,7 @@ Highlights include:
23
23
 
24
24
  Install using [uv](https://docs.astral.sh/uv/). This package conforms the concept of a [tool](https://docs.astral.sh/uv/concepts/tools/) and hence can simply install / run with `uvx`:
25
25
 
26
- uvx mockstack
26
+ uvx mockstack --help
27
27
 
28
28
  or install into a persistent environment and add it to the PATH with:
29
29
 
@@ -32,15 +32,20 @@ or install into a persistent environment and add it to the PATH with:
32
32
 
33
33
  ## Usage
34
34
 
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:
35
+ Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py).
36
+
37
+ Setting individual options can be done either through an `.env` file, individual environment variables, or command-line arguments. For example:
36
38
 
37
39
  ```shell
40
+ export MOCKSTACK__STRATEGY=filefixtures
38
41
  export MOCKSTACK__TEMPLATES_DIR=~/mockstack-templates/
39
42
  export MOCKSTACK__OPENTELEMETRY__ENABLED=true
40
43
  export MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true
41
- uv run
44
+ uvx mockstack
42
45
  ```
43
46
 
47
+ See also the included [.env.example](https://github.com/adamhadani/mockstack/blob/main/.env.example) for more examples. You can copy that file to `.env` and fill in configuration as needed based on the given examples.
48
+
44
49
  Out of the box, you get the following behavior when using the default `filefixtures` strategy:
45
50
 
46
51
  - The HTTP request `GET /someservice/api/v1/user/c27f5b2b-6e81-420d-a4e4-6426e1c32db8` will try to find `<templates_dir>/someservice-api-v1-user.c27f5b2b-6e81-420d-a4e4-6426e1c32db8.j2`,
@@ -73,8 +78,6 @@ If you are contributing to development, you will want to clone this project, and
73
78
  uv sync
74
79
  uv pip install -e .
75
80
 
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.
77
-
78
81
  Run in development mode (for live-reload of changes when developing):
79
82
 
80
- uv run uvicorn --factory mockstack.main:create_app --reload
83
+ uv run -- mockstack --debug
@@ -2,9 +2,19 @@ from functools import lru_cache
2
2
  from typing import Any, Literal, Self
3
3
 
4
4
  from pydantic import DirectoryPath, FilePath, model_validator
5
- from pydantic_settings import BaseSettings, SettingsConfigDict
5
+ from pydantic_settings import (
6
+ BaseSettings,
7
+ CliImplicitFlag,
8
+ CliSuppress,
9
+ SettingsConfigDict,
10
+ )
6
11
 
7
- from mockstack.constants import ProxyRulesRedirectVia
12
+ from mockstack.constants import (
13
+ ENV_FILE,
14
+ ENV_NESTED_DELIMITER,
15
+ ENV_PREFIX,
16
+ ProxyRulesRedirectVia,
17
+ )
8
18
 
9
19
 
10
20
  class OpenTelemetrySettings(BaseSettings):
@@ -28,11 +38,20 @@ class Settings(BaseSettings):
28
38
  """
29
39
 
30
40
  model_config = SettingsConfigDict(
31
- env_prefix="mockstack__",
32
- env_file=".env",
33
- env_nested_delimiter="__",
41
+ env_prefix=ENV_PREFIX,
42
+ env_file=ENV_FILE,
43
+ env_nested_delimiter=ENV_NESTED_DELIMITER,
34
44
  )
35
45
 
46
+ # whether to run in debug mode
47
+ debug: CliImplicitFlag[bool] = False
48
+
49
+ # host to run the server on
50
+ host: str = "0.0.0.0"
51
+
52
+ # port to run the server on
53
+ port: int = 8000
54
+
36
55
  # OpenTelemetry configuration
37
56
  opentelemetry: OpenTelemetrySettings = OpenTelemetrySettings()
38
57
 
@@ -49,9 +68,13 @@ class Settings(BaseSettings):
49
68
  # or reverse proxy the request to the target URL "silently".
50
69
  proxyrules_redirect_via: ProxyRulesRedirectVia = ProxyRulesRedirectVia.REVERSE_PROXY
51
70
 
71
+ # controls behavior of proxying. Whether to simulate creation of resources
72
+ # when a POST request is made to a resource that doesn't match any rules..
73
+ proxyrules_simulate_create_on_missing: CliImplicitFlag[bool] = False
74
+
52
75
  # metadata fields to inject into created resources.
53
76
  # A few template fields are available. See documentation for more details.
54
- created_resource_metadata: dict[str, Any] = {
77
+ created_resource_metadata: CliSuppress[dict[str, Any]] = {
55
78
  "id": "{{ uuid4() }}",
56
79
  "createdAt": "{{ utcnow().isoformat() }}",
57
80
  "updatedAt": "{{ utcnow().isoformat() }}",
@@ -61,7 +84,7 @@ class Settings(BaseSettings):
61
84
 
62
85
  # fields to inject into missing resources response json.
63
86
  # some services may require such additional fields to be present in the response.
64
- missing_resource_fields: dict[str, Any] = dict(
87
+ missing_resource_fields: CliSuppress[dict[str, Any]] = dict(
65
88
  code=404,
66
89
  message="mockstack: resource not found",
67
90
  retryable=False,
@@ -69,28 +92,45 @@ class Settings(BaseSettings):
69
92
 
70
93
  # logging configuration. schema is based on the logging configuration schema:
71
94
  # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema
72
- logging: dict[str, Any] = {
95
+ logging: CliSuppress[dict[str, Any]] = {
73
96
  "version": 1,
74
97
  "disable_existing_loggers": False,
75
98
  "formatters": {
76
99
  "standard": {
77
- "format": " %(levelname)s [%(name)s] %(message)s",
100
+ "()": "uvicorn.logging.DefaultFormatter",
101
+ "fmt": "%(levelprefix)s %(message)s",
78
102
  },
79
103
  },
80
104
  "handlers": {
81
105
  "console": {
82
106
  "class": "logging.StreamHandler",
83
- "level": "DEBUG",
107
+ "level": "INFO",
84
108
  "formatter": "standard",
85
109
  "stream": "ext://sys.stdout",
86
110
  },
87
111
  },
88
112
  "loggers": {
113
+ "uvicorn": {
114
+ "handlers": ["console"],
115
+ "level": "DEBUG",
116
+ "propagate": False,
117
+ },
89
118
  "FileFixturesStrategy": {
90
119
  "handlers": ["console"],
91
- "level": "INFO",
120
+ "level": "DEBUG",
121
+ "propagate": False,
122
+ },
123
+ "ProxyRulesStrategy": {
124
+ "handlers": ["console"],
125
+ "level": "DEBUG",
126
+ "propagate": False,
92
127
  },
93
128
  },
129
+ "root": {
130
+ "handlers": ["console"],
131
+ "level": "DEBUG",
132
+ "propagate": False,
133
+ },
94
134
  }
95
135
 
96
136
  @model_validator(mode="after")
@@ -114,6 +154,24 @@ class Settings(BaseSettings):
114
154
  return self
115
155
 
116
156
 
157
+ # Nb. We separate the Cli-specific parameters since currently breaks pytest
158
+ # when running via pre-commit hooks. Can remove once fixed by pytest / pre-commit.
159
+
160
+
161
+ class CliSettings(Settings):
162
+ """Settings for mockstack CLI."""
163
+
164
+ model_config = SettingsConfigDict(
165
+ env_prefix=ENV_PREFIX,
166
+ env_file=ENV_FILE,
167
+ env_nested_delimiter=ENV_NESTED_DELIMITER,
168
+ cli_parse_args=True,
169
+ cli_kebab_case=True,
170
+ cli_hide_none_type=True,
171
+ cli_avoid_json=True,
172
+ )
173
+
174
+
117
175
  @lru_cache
118
176
  def settings_provider() -> Settings:
119
177
  """Provide the settings for the application."""
@@ -2,7 +2,10 @@
2
2
 
3
3
  from enum import StrEnum
4
4
 
5
- # proxyrules strategy constants
5
+
6
+ ENV_PREFIX = "mockstack__"
7
+ ENV_FILE = ".env"
8
+ ENV_NESTED_DELIMITER = "__"
6
9
 
7
10
 
8
11
  class ProxyRulesRedirectVia(StrEnum):
@@ -27,7 +27,8 @@ def announce(app: FastAPI, settings: Settings):
27
27
  logger = logging.getLogger("uvicorn")
28
28
  logger.info(
29
29
  f"{HIGHLIGHT}mockstack{ENDC} ready to roll. "
30
- f"Using strategy: {HIGHLIGHT}{settings.strategy}{ENDC}, "
30
+ f"debug: {HIGHLIGHT}{settings.debug}{ENDC}. "
31
+ f"strategy: {HIGHLIGHT}{settings.strategy}{ENDC}. "
31
32
  )
32
33
  logger.info(str(app.state.strategy))
33
34
  logger.info(
@@ -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
+ )
@@ -1,7 +1,7 @@
1
1
  """FastAPI application lifecycle management."""
2
2
 
3
3
  from contextlib import asynccontextmanager
4
- from logging import config
4
+ from logging import DEBUG, config
5
5
  from typing import Callable
6
6
 
7
7
  from fastapi import FastAPI
@@ -21,7 +21,12 @@ def lifespan_provider(
21
21
 
22
22
  This is the context manager that FastAPI will use to manage the lifecycle of the application.
23
23
  """
24
+ if settings.debug:
25
+ # Enable verbose debug logging if debug mode is set.
26
+ settings.logging["handlers"]["console"]["level"] = DEBUG
27
+
24
28
  config.dictConfig(settings.logging)
29
+
25
30
  announce(app, settings)
26
31
 
27
32
  yield
@@ -1,9 +1,9 @@
1
1
  """Application entrypoints."""
2
2
 
3
3
  from fastapi import FastAPI
4
- from typer import Typer
4
+ from pydantic_settings import CliApp, CliSettingsSource
5
5
 
6
- from mockstack.config import settings_provider
6
+ from mockstack.config import CliSettings, Settings, settings_provider
7
7
  from mockstack.lifespan import lifespan_provider
8
8
  from mockstack.middleware import middleware_provider
9
9
  from mockstack.routers.catchall import catchall_router_provider
@@ -11,12 +11,10 @@ from mockstack.routers.homepage import homepage_router_provider
11
11
  from mockstack.strategies.factory import strategy_provider
12
12
  from mockstack.telemetry import opentelemetry_provider
13
13
 
14
- cli = Typer()
15
14
 
16
-
17
- def create_app() -> FastAPI:
18
- """Create the FastAPI app."""
19
- settings = settings_provider()
15
+ def create_app(settings: Settings | None = None) -> FastAPI:
16
+ """Create the fastapi app and bootstrap all dependencies."""
17
+ settings = settings or settings_provider()
20
18
 
21
19
  app = FastAPI(lifespan=lifespan_provider(settings))
22
20
 
@@ -30,23 +28,23 @@ def create_app() -> FastAPI:
30
28
  return app
31
29
 
32
30
 
33
- @cli.command()
34
- def run(host: str = "0.0.0.0", port: int = 8000):
35
- """mockstack run CLI entrypoint."""
31
+ def run():
32
+ """run the mockstack server."""
33
+ import argparse
36
34
  import uvicorn
37
35
 
38
- app = create_app()
39
- uvicorn.run(app, host=host, port=port)
36
+ parser = argparse.ArgumentParser()
37
+ cli_settings = CliSettingsSource(CliSettings, root_parser=parser)
38
+ settings = CliApp.run(CliSettings, cli_settings_source=cli_settings)
39
+
40
+ app = create_app(settings=settings)
41
+
42
+ uvicorn.run(app, host=settings.host, port=settings.port)
40
43
 
41
44
 
42
- @cli.command()
43
45
  def version():
44
- """mockstack version CLI entrypoint."""
46
+ """display mockstack version."""
45
47
  from importlib.metadata import version
46
48
 
47
49
  pkg_version = version("mockstack")
48
50
  print(f"mockstack {pkg_version}")
49
-
50
-
51
- if __name__ == "__main__":
52
- cli()
@@ -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
+ }
@@ -2,67 +2,26 @@
2
2
 
3
3
  import logging
4
4
  import os
5
- from datetime import datetime, timezone
6
5
  from pathlib import Path
7
- from uuid import uuid4
8
6
 
9
7
  from fastapi import HTTPException, Request, Response, status
10
- from fastapi.responses import JSONResponse
11
8
  from jinja2 import Environment, FileSystemLoader
12
9
 
13
10
  from mockstack.config import Settings
14
11
  from mockstack.display import ANSIColors
12
+ from mockstack.intent import (
13
+ looks_like_a_command,
14
+ looks_like_a_search,
15
+ )
15
16
  from mockstack.strategies.base import BaseStrategy
17
+ from mockstack.strategies.create_mixin import CreateMixin
16
18
  from mockstack.templating import (
17
19
  iter_possible_template_arguments,
18
20
  missing_template_detail,
19
21
  )
20
22
 
21
23
 
22
- def is_json_media_type(media_type: str) -> bool:
23
- """Check if the media type is JSON."""
24
- return media_type in ("application/json", "text/json")
25
-
26
-
27
- def looks_like_a_search(request: Request) -> bool:
28
- """Check if the request looks like a search.
29
-
30
- This is a heuristic to try and identify cases where a POST
31
- request is used for issuing a search rather than for creating
32
- a new resource.
33
-
34
- """
35
- return any(
36
- (
37
- request.url.path.endswith("_search"),
38
- request.url.path.endswith("/search"),
39
- request.url.path.endswith("_query"),
40
- )
41
- )
42
-
43
-
44
- def looks_like_a_command(request: Request) -> bool:
45
- """Check if the request looks like a command.
46
-
47
- This is a heuristic to try and identify cases where a POST
48
- request is used for issuing a command rather than for creating
49
- a new resource.
50
- """
51
- return any(
52
- (
53
- request.url.path.endswith("_command"),
54
- request.url.path.endswith("/command"),
55
- request.url.path.endswith("_request"),
56
- request.url.path.endswith("/request"),
57
- request.url.path.endswith("_run"),
58
- request.url.path.endswith("/run"),
59
- request.url.path.endswith("_execute"),
60
- request.url.path.endswith("/execute"),
61
- )
62
- )
63
-
64
-
65
- class FileFixturesStrategy(BaseStrategy):
24
+ class FileFixturesStrategy(BaseStrategy, CreateMixin):
66
25
  """Strategy for using file-based fixtures."""
67
26
 
68
27
  logger = logging.getLogger("FileFixturesStrategy")
@@ -126,24 +85,11 @@ class FileFixturesStrategy(BaseStrategy):
126
85
  request, status_code=status.HTTP_201_CREATED
127
86
  )
128
87
  else:
129
- # Creating a new resource.
130
- media_type = request.headers.get("Content-Type", "application/json")
131
-
132
- if is_json_media_type(media_type):
133
- # We return a 201 CREATED response with the resource as the body,
134
- # potentially injecting the resource ID into the response.
135
- resource = await request.json()
136
-
137
- return JSONResponse(
138
- status_code=status.HTTP_201_CREATED,
139
- content=self._created(resource, request=request),
140
- )
141
- else:
142
- # We return a 201 CREATEDresponse with an empty body.
143
- return Response(
144
- status_code=status.HTTP_201_CREATED,
145
- content=None,
146
- )
88
+ return await self._create(
89
+ request,
90
+ env=self.env,
91
+ created_resource_metadata=self.created_resource_metadata,
92
+ )
147
93
 
148
94
  async def _get(self, request: Request) -> Response:
149
95
  """Apply the strategy for GET requests.
@@ -176,42 +122,6 @@ class FileFixturesStrategy(BaseStrategy):
176
122
  """Apply the strategy for PUT requests."""
177
123
  return Response(status_code=status.HTTP_204_NO_CONTENT)
178
124
 
179
- def _created(self, resource: dict, *, request: Request) -> dict:
180
- """Create a new resource given a request resource.
181
-
182
- We use the request resource as the basis for the new resource.
183
- We then inject an identifier into the resource if it doesn't already have one,
184
- as well as any other metadata fields that are configured for the strategy.
185
-
186
- """
187
-
188
- def with_metadata(resource: dict, copy=True) -> dict:
189
- """Inject metadata fields into the resource."""
190
- _resource = resource.copy() if copy else resource
191
- for key, value in self.created_resource_metadata.items():
192
- if isinstance(value, str):
193
- _resource[key] = self.env.from_string(value).render(
194
- self._metadata_context(request)
195
- )
196
- else:
197
- _resource[key] = value
198
- return _resource
199
-
200
- return with_metadata(resource)
201
-
202
- def _metadata_context(self, request: Request) -> dict:
203
- """Context for injecting metadata fields into resources.
204
-
205
- Some care is needed to ensure that we only expose the minimum amount
206
- of information here since templates are user-defined.
207
-
208
- """
209
- return {
210
- "utcnow": lambda: datetime.now(timezone.utc),
211
- "uuid4": uuid4,
212
- "request": request,
213
- }
214
-
215
125
  def _response_from_template(
216
126
  self, request: Request, status_code: int = status.HTTP_200_OK
217
127
  ) -> Response:
@@ -10,12 +10,14 @@ import httpx
10
10
  import yaml
11
11
  from fastapi import Request, Response, status
12
12
  from fastapi.responses import RedirectResponse
13
+ from jinja2 import Environment
13
14
  from starlette.datastructures import Headers
14
15
 
15
16
  from mockstack.config import Settings
16
17
  from mockstack.constants import ProxyRulesRedirectVia
17
18
  from mockstack.display import ANSIColors
18
19
  from mockstack.strategies.base import BaseStrategy
20
+ from mockstack.strategies.create_mixin import CreateMixin
19
21
 
20
22
 
21
23
  class Rule:
@@ -50,7 +52,7 @@ class Rule:
50
52
  return re.sub(self.pattern, self.replacement, path)
51
53
 
52
54
 
53
- class ProxyRulesStrategy(BaseStrategy):
55
+ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
54
56
  """Strategy for using proxy rules."""
55
57
 
56
58
  logger = logging.getLogger("ProxyRulesStrategy")
@@ -59,6 +61,12 @@ class ProxyRulesStrategy(BaseStrategy):
59
61
  super().__init__(settings, *args, **kwargs)
60
62
  self.rules_filename = settings.proxyrules_rules_filename
61
63
  self.proxyrules_redirect_via = settings.proxyrules_redirect_via
64
+ self.proxyrules_simulate_create_on_missing = (
65
+ settings.proxyrules_simulate_create_on_missing
66
+ )
67
+ self.created_resource_metadata = settings.created_resource_metadata
68
+
69
+ self.env = Environment()
62
70
 
63
71
  def __str__(self) -> str:
64
72
  HIGHLIGHT = ANSIColors.HEADER
@@ -67,7 +75,8 @@ class ProxyRulesStrategy(BaseStrategy):
67
75
  return (
68
76
  f"{HIGHLIGHT}[proxyrules]{ENDC} "
69
77
  f"rules_filename: {HIGHLIGHT}{self.rules_filename}{ENDC}. "
70
- f"redirect_via: {HIGHLIGHT}{self.proxyrules_redirect_via}{ENDC}"
78
+ f"redirect_via: {HIGHLIGHT}{self.proxyrules_redirect_via}{ENDC}. "
79
+ f"simulate_create_on_missing: {HIGHLIGHT}{self.proxyrules_simulate_create_on_missing}{ENDC}"
71
80
  )
72
81
 
73
82
  @cached_property
@@ -91,8 +100,20 @@ class ProxyRulesStrategy(BaseStrategy):
91
100
  async def apply(self, request: Request) -> Response:
92
101
  rule = self.rule_for(request)
93
102
  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)
103
+ self.logger.warning(
104
+ f"No rule found for request: {request.method} {request.url.path}"
105
+ )
106
+ if self.proxyrules_simulate_create_on_missing:
107
+ self.logger.info(
108
+ "Simulating resource creation for missing rule for {request.method} {request.url.path}"
109
+ )
110
+ return await self._create(
111
+ request,
112
+ env=self.env,
113
+ created_resource_metadata=self.created_resource_metadata,
114
+ )
115
+ else:
116
+ return Response(status_code=status.HTTP_404_NOT_FOUND)
96
117
 
97
118
  url = rule.apply(request)
98
119
  self.logger.info(f"Redirecting to: {url}")
@@ -114,7 +135,7 @@ class ProxyRulesStrategy(BaseStrategy):
114
135
 
115
136
  case _:
116
137
  raise ValueError(
117
- f"Invalid redirect via: {self.proxyrules_redirect_via}"
138
+ f"Invalid redirect via value: {self.proxyrules_redirect_via=}"
118
139
  )
119
140
 
120
141
  async def reverse_proxy(self, request: Request, url: str) -> Response:
@@ -5,7 +5,6 @@ from importlib import metadata
5
5
  from fastapi import FastAPI, Request
6
6
  from opentelemetry import trace
7
7
  from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
8
- from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
9
8
  from opentelemetry.sdk.resources import Resource
10
9
  from opentelemetry.sdk.trace import TracerProvider
11
10
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
@@ -37,6 +37,7 @@ def settings(templates_dir, proxyrules_rules_filename):
37
37
  templates_dir=templates_dir,
38
38
  proxyrules_rules_filename=proxyrules_rules_filename,
39
39
  proxyrules_redirect_via=ProxyRulesRedirectVia.HTTP_TEMPORARY_REDIRECT,
40
+ proxyrules_simulate_create_on_missing=False,
40
41
  opentelemetry=OpenTelemetrySettings(enabled=False),
41
42
  )
42
43
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: An API mocking workhorse
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/adamhadani/mockstack
@@ -47,7 +47,7 @@ Highlights include:
47
47
 
48
48
  Install using [uv](https://docs.astral.sh/uv/). This package conforms the concept of a [tool](https://docs.astral.sh/uv/concepts/tools/) and hence can simply install / run with `uvx`:
49
49
 
50
- uvx mockstack
50
+ uvx mockstack --help
51
51
 
52
52
  or install into a persistent environment and add it to the PATH with:
53
53
 
@@ -56,15 +56,20 @@ or install into a persistent environment and add it to the PATH with:
56
56
 
57
57
  ## Usage
58
58
 
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:
59
+ Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py).
60
+
61
+ Setting individual options can be done either through an `.env` file, individual environment variables, or command-line arguments. For example:
60
62
 
61
63
  ```shell
64
+ export MOCKSTACK__STRATEGY=filefixtures
62
65
  export MOCKSTACK__TEMPLATES_DIR=~/mockstack-templates/
63
66
  export MOCKSTACK__OPENTELEMETRY__ENABLED=true
64
67
  export MOCKSTACK__OPENTELEMETRY__CAPTURE_RESPONSE_BODY=true
65
- uv run
68
+ uvx mockstack
66
69
  ```
67
70
 
71
+ See also the included [.env.example](https://github.com/adamhadani/mockstack/blob/main/.env.example) for more examples. You can copy that file to `.env` and fill in configuration as needed based on the given examples.
72
+
68
73
  Out of the box, you get the following behavior when using the default `filefixtures` strategy:
69
74
 
70
75
  - The HTTP request `GET /someservice/api/v1/user/c27f5b2b-6e81-420d-a4e4-6426e1c32db8` will try to find `<templates_dir>/someservice-api-v1-user.c27f5b2b-6e81-420d-a4e4-6426e1c32db8.j2`,
@@ -97,8 +102,6 @@ If you are contributing to development, you will want to clone this project, and
97
102
  uv sync
98
103
  uv pip install -e .
99
104
 
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.
101
-
102
105
  Run in development mode (for live-reload of changes when developing):
103
106
 
104
- uv run uvicorn --factory mockstack.main:create_app --reload
107
+ uv run -- mockstack --debug
@@ -19,6 +19,7 @@ mockstack/config.py
19
19
  mockstack/constants.py
20
20
  mockstack/display.py
21
21
  mockstack/identifiers.py
22
+ mockstack/intent.py
22
23
  mockstack/lifespan.py
23
24
  mockstack/main.py
24
25
  mockstack/middleware.py
@@ -35,6 +36,7 @@ mockstack/routers/catchall.py
35
36
  mockstack/routers/homepage.py
36
37
  mockstack/strategies/__init__.py
37
38
  mockstack/strategies/base.py
39
+ mockstack/strategies/create_mixin.py
38
40
  mockstack/strategies/factory.py
39
41
  mockstack/strategies/filefixtures.py
40
42
  mockstack/strategies/proxyrules.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mockstack = mockstack.main:run
@@ -30,7 +30,7 @@ Homepage = "https://github.com/adamhadani/mockstack"
30
30
  Issues = "https://github.com/adamhadani/mockstack/issues"
31
31
 
32
32
  [project.scripts]
33
- mockstack = "mockstack.main:cli"
33
+ mockstack = "mockstack.main:run"
34
34
 
35
35
  [dependency-groups]
36
36
  dev = [
@@ -47,9 +47,9 @@ dev = [
47
47
 
48
48
  [tool.mypy]
49
49
  namespace_packages = true
50
+ ignore_missing_imports = true
50
51
 
51
52
  [tool.pytest.ini_options]
52
53
  asyncio_default_fixture_loop_scope = "function"
53
54
 
54
55
  [tool.ruff.lint]
55
- select = ["I"]
@@ -1,7 +0,0 @@
1
- # Example environment variables for mockstack
2
-
3
- # base dir for all templates
4
- MOCKSTACK__TEMPLATES_DIR = "./templates/"
5
-
6
- # OpenTelemetry settings
7
- MOCKSTACK__OPENTELEMETRY__ENABLED="false"
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- mockstack = mockstack.main:cli
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes