mockstack 0.4.2__tar.gz → 0.5.1__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 (67) hide show
  1. {mockstack-0.4.2 → mockstack-0.5.1}/PKG-INFO +14 -2
  2. {mockstack-0.4.2 → mockstack-0.5.1}/README.md +13 -1
  3. mockstack-0.5.1/examples/filefixtures-with-templates/.env.example +13 -0
  4. mockstack-0.5.1/examples/filefixtures-with-templates/README.md +10 -0
  5. mockstack-0.5.1/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +19 -0
  6. mockstack-0.5.1/examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2 +14 -0
  7. mockstack-0.5.1/examples/proxyrules-with-rules-file/.env.example +13 -0
  8. mockstack-0.5.1/examples/proxyrules-with-rules-file/README.md +15 -0
  9. mockstack-0.5.1/examples/proxyrules-with-rules-file/rules.yml +13 -0
  10. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/config.py +3 -0
  11. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/constants.py +3 -0
  12. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/lifespan.py +15 -4
  13. mockstack-0.5.1/mockstack/middleware.py +53 -0
  14. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/base.py +9 -0
  15. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/filefixtures.py +9 -0
  16. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/proxyrules.py +31 -15
  17. mockstack-0.5.1/mockstack/telemetry.py +141 -0
  18. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/conftest.py +11 -1
  19. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/strategies/test_filefixtures.py +6 -3
  20. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/strategies/test_proxyrules.py +10 -5
  21. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/PKG-INFO +14 -2
  22. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/SOURCES.txt +7 -0
  23. mockstack-0.4.2/mockstack/middleware.py +0 -55
  24. mockstack-0.4.2/mockstack/telemetry.py +0 -63
  25. {mockstack-0.4.2 → mockstack-0.5.1}/.env.example +0 -0
  26. {mockstack-0.4.2 → mockstack-0.5.1}/.github/workflows/ci.yml +0 -0
  27. {mockstack-0.4.2 → mockstack-0.5.1}/.github/workflows/publish-docs.yml +0 -0
  28. {mockstack-0.4.2 → mockstack-0.5.1}/.github/workflows/publish-to-pypi.yml +0 -0
  29. {mockstack-0.4.2 → mockstack-0.5.1}/.gitignore +0 -0
  30. {mockstack-0.4.2 → mockstack-0.5.1}/.pre-commit-config.yaml +0 -0
  31. {mockstack-0.4.2 → mockstack-0.5.1}/CODE_OF_CONDUCT.md +0 -0
  32. {mockstack-0.4.2 → mockstack-0.5.1}/LICENSE +0 -0
  33. {mockstack-0.4.2 → mockstack-0.5.1}/SECURITY.md +0 -0
  34. {mockstack-0.4.2 → mockstack-0.5.1}/docs/assets/favicon.ico +0 -0
  35. {mockstack-0.4.2 → mockstack-0.5.1}/docs/assets/logo.png +0 -0
  36. {mockstack-0.4.2 → mockstack-0.5.1}/docs/assets/mockstack.png +0 -0
  37. {mockstack-0.4.2 → mockstack-0.5.1}/mkdocs.yml +0 -0
  38. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/__init__.py +0 -0
  39. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/display.py +0 -0
  40. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/identifiers.py +0 -0
  41. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/intent.py +0 -0
  42. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/main.py +0 -0
  43. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/routers/__init__.py +0 -0
  44. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/routers/catchall.py +0 -0
  45. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/routers/homepage.py +0 -0
  46. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/__init__.py +0 -0
  47. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/create_mixin.py +0 -0
  48. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/factory.py +0 -0
  49. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/templating.py +0 -0
  50. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/__init__.py +0 -0
  51. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/fixtures/proxyrules.yml +0 -0
  52. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/fixtures/templates/__init__.py +0 -0
  53. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  54. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/routers/__init__.py +0 -0
  55. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/routers/test_catchall.py +0 -0
  56. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/routers/test_homepage.py +0 -0
  57. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_display.py +0 -0
  58. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_identifiers.py +0 -0
  59. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_middleware.py +0 -0
  60. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_templating.py +0 -0
  61. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/dependency_links.txt +0 -0
  62. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/entry_points.txt +0 -0
  63. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/requires.txt +0 -0
  64. {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/top_level.txt +0 -0
  65. {mockstack-0.4.2 → mockstack-0.5.1}/pyproject.toml +0 -0
  66. {mockstack-0.4.2 → mockstack-0.5.1}/setup.cfg +0 -0
  67. {mockstack-0.4.2 → mockstack-0.5.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.4.2
3
+ Version: 0.5.1
4
4
  Summary: An API mocking workhorse
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/adamhadani/mockstack
@@ -34,7 +34,17 @@ Dynamic: license-file
34
34
 
35
35
  An API mocking workhorse :racehorse:
36
36
 
37
- Enabling a sane development lifecycle for microservice-oriented architectures.
37
+ Enabling a sane development lifecycle for microservice-oriented architectures and LLM-driven tool chains.
38
+
39
+ Use mockstack for:
40
+
41
+ * **Development** :pencil2:. Simulating HTTP-based interactions between a particular component you're developing or debugging locally and multiple other components it depends on during execution of a particular flow. You can create template-based mock responses, simulate creation of resources in a realistic way, as well as proxy to other services using a rich rules DSL. Full request and response metadata and payloads can be observed via OpenTelemetry integration.
42
+
43
+ * **Integration Testing** :ok_hand:. Creating a consistent environment for running integration tests on a single component, using fixture responses.
44
+
45
+ * **LLM-powered Workflows** :alien:. Speeding up development of LLM-based workflows and tools for use with frameworks such as [LangChain](https://python.langchain.com/docs/introduction/), [LangGraph](https://www.langchain.com/langgraph) and others. When developing LLM-driven execution graphs, you often want to have a consistent response from a tool while you're tuning prompts or debugging other aspects of a particular trace. `mockstack` can give you a solid foundation for this.
46
+
47
+ * **Chaos Engineering** :boom:. mockstack can simulate various real-world runtime error scenarios such as timeouts, http error codes, and invalid response payloads. This can be a great way to do some upfront "chaos monkey" type of testing on software components.
38
48
 
39
49
  Highlights include:
40
50
 
@@ -57,6 +67,8 @@ or install into a persistent environment and add it to the PATH with:
57
67
 
58
68
  ## Usage
59
69
 
70
+ See the [examples](https://github.com/adamhadani/mockstack/blob/main/examples/) directory for complete examples with documentation.
71
+
60
72
  Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py).
61
73
 
62
74
  Setting individual options can be done either through an `.env` file, individual environment variables, or command-line arguments. For example:
@@ -9,7 +9,17 @@
9
9
 
10
10
  An API mocking workhorse :racehorse:
11
11
 
12
- Enabling a sane development lifecycle for microservice-oriented architectures.
12
+ Enabling a sane development lifecycle for microservice-oriented architectures and LLM-driven tool chains.
13
+
14
+ Use mockstack for:
15
+
16
+ * **Development** :pencil2:. Simulating HTTP-based interactions between a particular component you're developing or debugging locally and multiple other components it depends on during execution of a particular flow. You can create template-based mock responses, simulate creation of resources in a realistic way, as well as proxy to other services using a rich rules DSL. Full request and response metadata and payloads can be observed via OpenTelemetry integration.
17
+
18
+ * **Integration Testing** :ok_hand:. Creating a consistent environment for running integration tests on a single component, using fixture responses.
19
+
20
+ * **LLM-powered Workflows** :alien:. Speeding up development of LLM-based workflows and tools for use with frameworks such as [LangChain](https://python.langchain.com/docs/introduction/), [LangGraph](https://www.langchain.com/langgraph) and others. When developing LLM-driven execution graphs, you often want to have a consistent response from a tool while you're tuning prompts or debugging other aspects of a particular trace. `mockstack` can give you a solid foundation for this.
21
+
22
+ * **Chaos Engineering** :boom:. mockstack can simulate various real-world runtime error scenarios such as timeouts, http error codes, and invalid response payloads. This can be a great way to do some upfront "chaos monkey" type of testing on software components.
13
23
 
14
24
  Highlights include:
15
25
 
@@ -32,6 +42,8 @@ or install into a persistent environment and add it to the PATH with:
32
42
 
33
43
  ## Usage
34
44
 
45
+ See the [examples](https://github.com/adamhadani/mockstack/blob/main/examples/) directory for complete examples with documentation.
46
+
35
47
  Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py).
36
48
 
37
49
  Setting individual options can be done either through an `.env` file, individual environment variables, or command-line arguments. For example:
@@ -0,0 +1,13 @@
1
+ # Example environment variables for using filefixtures strategy
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 = "./templates/"
11
+
12
+ # OpenTelemetry settings
13
+ MOCKSTACK__OPENTELEMETRY__ENABLED = false
@@ -0,0 +1,10 @@
1
+ # filefixtures example
2
+
3
+ This folder shows an example setup for using mockstack with the `filefixtures` strategy which lets you route
4
+ requests to template files.
5
+
6
+ The folder is comprised of the following:
7
+
8
+ * An `.env.example` file which you would rename `.env` and contains the relevant configuration options needed to run. When you have that file in the working directory when invoking mockstack it will automatically pick up the settings from this file.
9
+ * A `templates/` dir, pointed to be a configuration in the env file, which contains our templates. We have a few example templates showcasing some of the capabilities of template-based mocking. One template file shows how you can have Jinja2 templating conditionals in the template e.g. to handle different responses based on request query parameters. Another template shows the naming convention for templates that depend on an identifier embedded in the request URL, e.g. `GET
10
+ /someservice/api/v1/item/ae420979-33f3-4c99-bc42-9d7cdee5259e` would get routed to the template file `someservice-api-v1-item.ae420979-33f3-4c99-bc42-9d7cdee5259e.j2`. Multiple identifiers in the path are also supported and would appear in filenames separated by dots, according to the order in which they appear in the URL.
@@ -0,0 +1,19 @@
1
+ {% if query.item_id == "533ec889-7c68-45c8-b21e-4a7e455de123" %}
2
+ {
3
+ "items": [
4
+ "createdAt": "2023-05-02T11:48:16.708081+00:00",
5
+ "id": "533ec889-7c68-45c8-b21e-4a7e455de123",
6
+ },
7
+ ],
8
+ "count": 1,
9
+ "limit": 20,
10
+ "offset": 0
11
+ }
12
+ {% else %}
13
+ {
14
+ "items": [],
15
+ "count": 0,
16
+ "limit": 20,
17
+ "offset": 0
18
+ }
19
+ {% endif %}
@@ -0,0 +1,14 @@
1
+ {
2
+ "address": {
3
+ "city": "San Francisco",
4
+ "country": "United States",
5
+ "region": "California",
6
+ "streetAddress1": "123 market st.",
7
+ "streetAddress2": "null",
8
+ "zipCode": ""
9
+ },
10
+ "aliases": [],
11
+ "id": "533ec889-7c68-45c8-b21e-4a7e455d1234",
12
+ "name": "John Doe",
13
+ "updatedAt": "2025-04-15T18:34:17.067320+00:00"
14
+ }
@@ -0,0 +1,13 @@
1
+ # Example environment variables for using filefixtures strategy
2
+
3
+ # the strategy to use
4
+ MOCKSTACK__STRATEGY = proxyrules
5
+
6
+ # strategy-specific settings
7
+
8
+ MOCKSTACK__PROXYRULES_RULES_FILENAME = "./proxyrules.yml"
9
+ MOCKSTACK__PROXYRULES_SIMULATE_CREATE_ON_MISSING = true
10
+
11
+
12
+ # OpenTelemetry settings
13
+ MOCKSTACK__OPENTELEMETRY__ENABLED = false
@@ -0,0 +1,15 @@
1
+ # proxyrules
2
+
3
+ This example shows how to use the `proxyrules` strategy. This strategy lets you define rules in a YAML-based microformat that control how to proxy requests coming into mockstack to the other services.
4
+
5
+ * The included `.env.example` file should be renamed `.env` and contains the needed configuration to use this strategy and point to the rules file.
6
+ * The rules file `rules.yml` showcases some of the capabilities supported for rule-based proxying.
7
+
8
+
9
+ Some of the notable features for this strategy are:
10
+
11
+ * Can configure whether proxuying is done via Http redirects (Temporary Redirect, Permanent Redirect) or reverse proxying (e.g. silently re-routing request). Default is to reverse proxy.
12
+ * Can configure rules per URL mask and optionally limit a rule to specific HTTP methods.
13
+ * Can use regular expression capture groups to refer to matched groups in the "pattern" field when constructring the destination URL.
14
+ * All request metadata and content, including headers, query parameters, and request body when applicable (e.g. for POSTs) will be proxied through.
15
+ * Can simulate creation of resources for cases where we do not wish to proxy the request to a "real" service where creation might have undesirable sideeffects, and instead wish to simply simulate a realistic flow of creating a new resource. This is powered by the same mixin functionality thats used by the other strategies for simulation creation.
@@ -0,0 +1,13 @@
1
+ rules:
2
+ - name: someservice-get
3
+ method: GET
4
+ pattern: ^/someservice/(.*)
5
+ replacement: https://someservice.development.mycoolstartup.io/\1
6
+ - name: someservice-items-search
7
+ method: POST
8
+ pattern: ^/someservice/api/v1/items_search
9
+ replacement: https://someservice.development.mycoolstartup.io/api/v1/items_search
10
+ - name: someotherservice-get
11
+ method: GET
12
+ pattern: ^/someotherservice/(.*)
13
+ replacement: https://someotherservice.development.mycoolstartup.io/\1
@@ -68,6 +68,9 @@ class Settings(BaseSettings):
68
68
  # or reverse proxy the request to the target URL "silently".
69
69
  proxyrules_redirect_via: ProxyRulesRedirectVia = ProxyRulesRedirectVia.REVERSE_PROXY
70
70
 
71
+ # Default timeout for reverse proxy requests. given in seconds. None disables timeouts.
72
+ proxyrules_reverse_proxy_timeout: float | None = 10.0
73
+
71
74
  # controls behavior of proxying. Whether to simulate creation of resources
72
75
  # when a POST request is made to a resource that doesn't match any rules..
73
76
  proxyrules_simulate_create_on_missing: CliImplicitFlag[bool] = False
@@ -20,3 +20,6 @@ class ProxyRulesRedirectVia(StrEnum):
20
20
  HTTP_TEMPORARY_REDIRECT = "http_307_temporary"
21
21
  HTTP_PERMANENT_REDIRECT = "http_301_permanent"
22
22
  REVERSE_PROXY = "reverse_proxy"
23
+
24
+
25
+ SENSITIVE_HEADERS = ["authorization", "cookie", "set-cookie"]
@@ -10,6 +10,20 @@ from mockstack.config import Settings
10
10
  from mockstack.display import announce
11
11
 
12
12
 
13
+ def logging_dict_config_from(settings: Settings) -> dict:
14
+ """Get the logging config from the settings."""
15
+
16
+ def enable_debug_logging(settings: Settings):
17
+ """Enable verbose debug logging."""
18
+ settings.logging["handlers"]["console"]["level"] = DEBUG
19
+
20
+ if settings.debug:
21
+ # Enable verbose debug logging if debug mode is set.
22
+ enable_debug_logging(settings)
23
+
24
+ return settings.logging
25
+
26
+
13
27
  def lifespan_provider(
14
28
  settings: Settings,
15
29
  ) -> Callable:
@@ -21,11 +35,8 @@ def lifespan_provider(
21
35
 
22
36
  This is the context manager that FastAPI will use to manage the lifecycle of the application.
23
37
  """
24
- if settings.debug:
25
- # Enable verbose debug logging if debug mode is set.
26
- settings.logging["handlers"]["console"]["level"] = DEBUG
27
38
 
28
- config.dictConfig(settings.logging)
39
+ config.dictConfig(logging_dict_config_from(settings))
29
40
 
30
41
  announce(app, settings)
31
42
 
@@ -0,0 +1,53 @@
1
+ """Middleware definitionsfor the mockstack app."""
2
+
3
+ import time
4
+
5
+ from fastapi import FastAPI, Request
6
+ from opentelemetry import trace
7
+ from opentelemetry.propagate import extract
8
+
9
+ from mockstack.config import Settings
10
+ from mockstack.constants import SENSITIVE_HEADERS
11
+ from mockstack.telemetry import (
12
+ span_name_for,
13
+ with_request_attributes,
14
+ with_response_attributes,
15
+ with_response_body,
16
+ )
17
+
18
+
19
+ def middleware_provider(app: FastAPI, settings: Settings) -> None:
20
+ """Instrument the middlewares to the mockstack app."""
21
+
22
+ @app.middleware("http")
23
+ async def add_process_time_header(request: Request, call_next):
24
+ start_time = time.time()
25
+ response = await call_next(request)
26
+ process_time = time.time() - start_time
27
+ response.headers["X-Process-Time"] = str(process_time)
28
+ return response
29
+
30
+ @app.middleware("http")
31
+ async def instrument_opentelemetry(request: Request, call_next):
32
+ tracer = trace.get_tracer(__name__)
33
+ ctx = extract(request.headers)
34
+ with tracer.start_as_current_span(span_name_for(request), context=ctx) as span:
35
+ span = with_request_attributes(
36
+ request, span, sensitive_headers=SENSITIVE_HEADERS
37
+ )
38
+
39
+ # Make the current opentelemetry span available to the request.
40
+ # This is useful for strategies that need to add custom attributes
41
+ # to the span associated with the request.
42
+ request.state.span = span
43
+
44
+ response = await call_next(request)
45
+
46
+ span = with_response_attributes(
47
+ response, span, sensitive_headers=SENSITIVE_HEADERS
48
+ )
49
+
50
+ if settings.opentelemetry.capture_response_body:
51
+ response, span = await with_response_body(response, span)
52
+
53
+ return response
@@ -17,3 +17,12 @@ class BaseStrategy(ABC):
17
17
  async def apply(self, request: Request) -> Response:
18
18
  """Apply the strategy to the request and response."""
19
19
  pass
20
+
21
+ def update_opentelemetry(self, request: Request, *args, **kwargs) -> None:
22
+ """Update the opentelemetry span with strategy-specific attributes.
23
+
24
+ A span is made available on `request.state.span` to use.
25
+ When OpenTelemetry is not enabled, this span will exist but will not be reported.
26
+
27
+ """
28
+ pass
@@ -129,6 +129,7 @@ class FileFixturesStrategy(BaseStrategy, CreateMixin):
129
129
  continue
130
130
 
131
131
  self.logger.debug("Found template filename: %s", filename)
132
+ self.update_opentelemetry(request, template_args)
132
133
  template = self.env.get_template(template_args["name"])
133
134
 
134
135
  return Response(
@@ -149,3 +150,11 @@ class FileFixturesStrategy(BaseStrategy, CreateMixin):
149
150
  status_code=status.HTTP_404_NOT_FOUND,
150
151
  )
151
152
  """
153
+
154
+ def update_opentelemetry(self, request: Request, template_args: dict) -> None:
155
+ """Update the opentelemetry span with the file fixtures details."""
156
+ span = request.state.span
157
+
158
+ span.set_attribute(
159
+ "mockstack.filefixtures.template_name", template_args["name"]
160
+ )
@@ -23,10 +23,17 @@ from mockstack.strategies.create_mixin import CreateMixin
23
23
  class Rule:
24
24
  """A rule for the proxy rules strategy."""
25
25
 
26
- def __init__(self, pattern: str, replacement: str, method: str | None = None):
26
+ def __init__(
27
+ self,
28
+ pattern: str,
29
+ replacement: str,
30
+ method: str | None = None,
31
+ name: str | None = None,
32
+ ):
27
33
  self.pattern = pattern
28
34
  self.replacement = replacement
29
35
  self.method = method
36
+ self.name = name
30
37
 
31
38
  @classmethod
32
39
  def from_dict(cls, data: dict[str, str]) -> Self:
@@ -34,6 +41,7 @@ class Rule:
34
41
  pattern=data["pattern"],
35
42
  replacement=data["replacement"],
36
43
  method=data.get("method", None),
44
+ name=data.get("name", None),
37
45
  )
38
46
 
39
47
  def matches(self, request: Request) -> bool:
@@ -60,10 +68,9 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
60
68
  def __init__(self, settings: Settings, *args, **kwargs):
61
69
  super().__init__(settings, *args, **kwargs)
62
70
  self.rules_filename = settings.proxyrules_rules_filename
63
- self.proxyrules_redirect_via = settings.proxyrules_redirect_via
64
- self.proxyrules_simulate_create_on_missing = (
65
- settings.proxyrules_simulate_create_on_missing
66
- )
71
+ self.redirect_via = settings.proxyrules_redirect_via
72
+ self.reverse_proxy_timeout = settings.proxyrules_reverse_proxy_timeout
73
+ self.simulate_create_on_missing = settings.proxyrules_simulate_create_on_missing
67
74
  self.created_resource_metadata = settings.created_resource_metadata
68
75
 
69
76
  self.env = Environment()
@@ -72,8 +79,8 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
72
79
  return (
73
80
  f"[medium_purple]proxyrules[/medium_purple] "
74
81
  f"rules_filename: [medium_purple]{self.rules_filename}[/medium_purple]. "
75
- f"redirect_via: [medium_purple]{self.proxyrules_redirect_via}[/medium_purple]. "
76
- f"simulate_create_on_missing: [medium_purple]{self.proxyrules_simulate_create_on_missing}[/medium_purple]"
82
+ f"redirect_via: [medium_purple]{self.redirect_via}[/medium_purple]. "
83
+ f"simulate_create_on_missing: [medium_purple]{self.simulate_create_on_missing}[/medium_purple]"
77
84
  )
78
85
 
79
86
  @cached_property
@@ -101,9 +108,7 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
101
108
  f"No rule found for request: {request.method} {request.url.path}"
102
109
  )
103
110
 
104
- if self.proxyrules_simulate_create_on_missing and looks_like_a_create(
105
- request
106
- ):
111
+ if self.simulate_create_on_missing and looks_like_a_create(request):
107
112
  self.logger.info(
108
113
  f"Simulating resource creation for missing rule for {request.method} {request.url.path}"
109
114
  )
@@ -118,8 +123,9 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
118
123
 
119
124
  url = rule.apply(request)
120
125
  self.logger.info(f"Redirecting to: {url}")
126
+ self.update_opentelemetry(request, rule, url)
121
127
 
122
- match self.proxyrules_redirect_via:
128
+ match self.redirect_via:
123
129
  case ProxyRulesRedirectVia.HTTP_TEMPORARY_REDIRECT:
124
130
  return RedirectResponse(
125
131
  url=url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
@@ -135,13 +141,11 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
135
141
  return response
136
142
 
137
143
  case _:
138
- raise ValueError(
139
- f"Invalid redirect via value: {self.proxyrules_redirect_via=}"
140
- )
144
+ raise ValueError(f"Invalid redirect via value: {self.redirect_via=}")
141
145
 
142
146
  async def reverse_proxy(self, request: Request, url: str) -> Response:
143
147
  """Reverse proxy the request to the target URL."""
144
- async with httpx.AsyncClient() as client:
148
+ async with httpx.AsyncClient(timeout=self.reverse_proxy_timeout) as client:
145
149
  request_content = await request.body()
146
150
  request_headers = self.reverse_proxy_headers(request.headers, url=url)
147
151
  req = client.build_request(
@@ -170,3 +174,15 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
170
174
  _headers["host"] = urlparse(url).netloc
171
175
 
172
176
  return _headers
177
+
178
+ def update_opentelemetry(self, request: Request, rule: Rule, url: str) -> None:
179
+ """Update the opentelemetry span with the proxy rules rule details."""
180
+ span = request.state.span
181
+ if rule.name is not None:
182
+ span.set_attribute("mockstack.proxyrules.rule_name", rule.name)
183
+ if rule.method is not None:
184
+ span.set_attribute("mockstack.proxyrules.rule_method", rule.method)
185
+
186
+ span.set_attribute("mockstack.proxyrules.rule_pattern", rule.pattern)
187
+ span.set_attribute("mockstack.proxyrules.rule_replacement", rule.replacement)
188
+ span.set_attribute("mockstack.proxyrules.rewritten_url", url)
@@ -0,0 +1,141 @@
1
+ """OpenTelemetry integration."""
2
+
3
+ from importlib import metadata
4
+ from typing import List, Tuple
5
+
6
+ from fastapi import FastAPI, Request
7
+ from opentelemetry import trace
8
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
9
+ from opentelemetry.sdk.resources import Resource
10
+ from opentelemetry.sdk.trace import TracerProvider
11
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
12
+ from opentelemetry.trace import Span
13
+ from starlette.responses import StreamingResponse, Response
14
+
15
+ from mockstack.config import Settings
16
+
17
+
18
+ def span_name_for(request: Request) -> str:
19
+ """Get the span name for a request."""
20
+ return f"{request.method.upper()} {request.url.path}"
21
+
22
+
23
+ def with_request_attributes(
24
+ request: Request, span: Span, *, sensitive_headers: List[str] = []
25
+ ) -> Span:
26
+ """Add request attributes to the span."""
27
+ span.set_attribute("http.method", request.method)
28
+ span.set_attribute("http.url", str(request.url))
29
+ span.set_attribute("http.scheme", request.url.scheme)
30
+ if request.url.hostname:
31
+ span.set_attribute("http.host", request.url.hostname)
32
+ span.set_attribute("http.target", request.url.path)
33
+ if request.url.port:
34
+ span.set_attribute("http.server_port", request.url.port)
35
+
36
+ # Client information
37
+ if request.client:
38
+ span.set_attribute("net.peer.ip", request.client.host)
39
+ if request.client.port:
40
+ span.set_attribute("net.peer.port", request.client.port)
41
+
42
+ # Request headers (excluding sensitive headers)
43
+ for header_name, header_value in request.headers.items():
44
+ if header_name.lower() not in sensitive_headers:
45
+ span.set_attribute(
46
+ f"http.request.header.{header_name.lower()}", header_value
47
+ )
48
+
49
+ # Query parameters
50
+ if request.query_params:
51
+ for param_name, param_value in request.query_params.items():
52
+ span.set_attribute(f"http.request.query.{param_name}", param_value)
53
+
54
+ return span
55
+
56
+
57
+ def with_response_attributes(
58
+ response: Response, span: Span, *, sensitive_headers: List[str] = []
59
+ ) -> Span:
60
+ """Add response attributes to the span."""
61
+ # Response attributes
62
+ span.set_attribute("http.status_code", response.status_code)
63
+ span.set_attribute(
64
+ "http.response_content_length", response.headers.get("content-length", 0)
65
+ )
66
+
67
+ # Response headers
68
+ for header_name, header_value in response.headers.items():
69
+ if header_name.lower() not in sensitive_headers:
70
+ span.set_attribute(
71
+ f"http.response.header.{header_name.lower()}", header_value
72
+ )
73
+
74
+ return span
75
+
76
+
77
+ async def with_response_body(
78
+ response: StreamingResponse, span: Span
79
+ ) -> Tuple[Response, Span]:
80
+ """Add the response body to the span."""
81
+ body = await extract_body(response)
82
+
83
+ # for semantics of payload attribute naming see:
84
+ # https://github.com/open-telemetry/oteps/pull/234
85
+ span.set_attribute("http.response.body", body)
86
+
87
+ # recreate response with the same body since when consuming it to log it above
88
+ # we effectively "deplete" the iterator.
89
+ _response = Response(
90
+ content=body,
91
+ status_code=response.status_code,
92
+ headers=dict(response.headers),
93
+ media_type=response.media_type,
94
+ )
95
+
96
+ return _response, span
97
+
98
+
99
+ async def extract_body(response: StreamingResponse) -> str:
100
+ """Extract the body of a response."""
101
+
102
+ async def read_response_body(response: StreamingResponse) -> bytes:
103
+ """Helper function to read response body asynchronously into memory."""
104
+ body = b""
105
+ async for chunk in response.body_iterator:
106
+ if isinstance(chunk, str):
107
+ body += chunk.encode()
108
+ else:
109
+ body += chunk
110
+ return body
111
+
112
+ body = await read_response_body(response)
113
+
114
+ return body.decode()
115
+
116
+
117
+ def opentelemetry_provider(app: FastAPI, settings: Settings) -> None:
118
+ """Initialize OpenTelemetry for the mockstack app."""
119
+ if not settings.opentelemetry.enabled:
120
+ return
121
+
122
+ # Initialize OpenTelemetry
123
+ distribution = metadata.distribution("mockstack")
124
+ resource = Resource(
125
+ attributes={
126
+ "service.name": distribution.name,
127
+ "service.version": distribution.version,
128
+ }
129
+ )
130
+
131
+ tracer_provider = TracerProvider(resource=resource)
132
+ trace.set_tracer_provider(tracer_provider)
133
+
134
+ # Set up OTLP exporter
135
+ otlp_exporter = OTLPSpanExporter(endpoint=settings.opentelemetry.endpoint)
136
+ span_processor = BatchSpanProcessor(otlp_exporter)
137
+ tracer_provider.add_span_processor(span_processor)
138
+
139
+ # Nb. we do not actually use the default FastAPIInstrumentor here
140
+ # because we use custom tracing in various places.
141
+ # FastAPIInstrumentor.instrument_app(app)
@@ -1,6 +1,7 @@
1
1
  """Shared fixtures for the unit-tests."""
2
2
 
3
3
  import os
4
+ from unittest.mock import MagicMock
4
5
 
5
6
  import pytest
6
7
  from fastapi import FastAPI
@@ -11,10 +12,19 @@ from mockstack.strategies.filefixtures import FileFixturesStrategy
11
12
 
12
13
 
13
14
  @pytest.fixture
14
- def app(settings):
15
+ def span():
16
+ """Create a mock span object for testing."""
17
+ span = MagicMock()
18
+ span.set_attribute = MagicMock()
19
+ return span
20
+
21
+
22
+ @pytest.fixture
23
+ def app(settings, span):
15
24
  """Create a FastAPI app for testing."""
16
25
  app = FastAPI()
17
26
  app.state.strategy = FileFixturesStrategy(settings)
27
+ app.state.span = span
18
28
  return app
19
29
 
20
30
 
@@ -18,7 +18,7 @@ def test_filefixtures_strategy_init(settings):
18
18
 
19
19
 
20
20
  @pytest.mark.asyncio
21
- async def test_filefixtures_strategy_apply(settings):
21
+ async def test_filefixtures_strategy_apply(settings, span):
22
22
  """Test the FileFixturesStrategy apply method."""
23
23
  strategy = FileFixturesStrategy(settings)
24
24
  request = Request(
@@ -30,6 +30,7 @@ async def test_filefixtures_strategy_apply(settings):
30
30
  "headers": [],
31
31
  }
32
32
  )
33
+ request.state.span = span
33
34
 
34
35
  with pytest.raises(HTTPException) as exc_info:
35
36
  await strategy.apply(request)
@@ -38,7 +39,7 @@ async def test_filefixtures_strategy_apply(settings):
38
39
 
39
40
 
40
41
  @pytest.mark.asyncio
41
- async def test_file_fixtures_strategy_apply_success(settings):
42
+ async def test_file_fixtures_strategy_apply_success(settings, span):
42
43
  """Test the FileFixturesStrategy apply method when template exists."""
43
44
  # Setup
44
45
  strategy = FileFixturesStrategy(settings)
@@ -61,6 +62,7 @@ async def test_file_fixtures_strategy_apply_success(settings):
61
62
  "headers": [],
62
63
  }
63
64
  )
65
+ request.state.span = span
64
66
 
65
67
  # Execute
66
68
  response = await strategy.apply(request)
@@ -72,7 +74,7 @@ async def test_file_fixtures_strategy_apply_success(settings):
72
74
 
73
75
 
74
76
  @pytest.mark.asyncio
75
- async def test_file_fixtures_strategy_apply_template_not_found(settings):
77
+ async def test_file_fixtures_strategy_apply_template_not_found(settings, span):
76
78
  """Test the FileFixturesStrategy apply method when template doesn't exist."""
77
79
  # Setup
78
80
  strategy = FileFixturesStrategy(settings)
@@ -88,6 +90,7 @@ async def test_file_fixtures_strategy_apply_template_not_found(settings):
88
90
  "headers": [],
89
91
  }
90
92
  )
93
+ request.state.span = span
91
94
 
92
95
  # Execute and Assert
93
96
  with pytest.raises(HTTPException) as exc_info:
@@ -124,7 +124,7 @@ def test_proxy_rules_strategy_load_rules(settings):
124
124
  assert all(isinstance(rule, Rule) for rule in rules)
125
125
 
126
126
 
127
- def test_proxy_rules_strategy_rule_for(settings):
127
+ def test_proxy_rules_strategy_rule_for(settings, span):
128
128
  """Test finding a matching rule for a request."""
129
129
  strategy = ProxyRulesStrategy(settings)
130
130
  request = Request(
@@ -136,12 +136,13 @@ def test_proxy_rules_strategy_rule_for(settings):
136
136
  "headers": [],
137
137
  }
138
138
  )
139
+ request.state.span = span
139
140
  rule = strategy.rule_for(request)
140
141
  assert rule is not None
141
142
  assert isinstance(rule, Rule)
142
143
 
143
144
 
144
- def test_proxy_rules_strategy_rule_for_no_match(settings):
145
+ def test_proxy_rules_strategy_rule_for_no_match(settings, span):
145
146
  """Test when no rule matches a request."""
146
147
  strategy = ProxyRulesStrategy(settings)
147
148
  request = Request(
@@ -153,12 +154,13 @@ def test_proxy_rules_strategy_rule_for_no_match(settings):
153
154
  "headers": [],
154
155
  }
155
156
  )
157
+ request.state.span = span
156
158
  rule = strategy.rule_for(request)
157
159
  assert rule is None
158
160
 
159
161
 
160
162
  @pytest.mark.asyncio
161
- async def test_proxy_rules_strategy_apply(settings):
163
+ async def test_proxy_rules_strategy_apply(settings, span):
162
164
  """Test applying a rule to a request."""
163
165
  strategy = ProxyRulesStrategy(settings)
164
166
  request = Request(
@@ -170,13 +172,14 @@ async def test_proxy_rules_strategy_apply(settings):
170
172
  "headers": [],
171
173
  }
172
174
  )
175
+ request.state.span = span
173
176
  response = await strategy.apply(request)
174
177
  assert isinstance(response, RedirectResponse)
175
178
  assert response.headers["location"] == "/projects/123"
176
179
 
177
180
 
178
181
  @pytest.mark.asyncio
179
- async def test_proxy_rules_strategy_apply_no_match(settings):
182
+ async def test_proxy_rules_strategy_apply_no_match(settings, span):
180
183
  """Test applying strategy when no rule matches."""
181
184
  strategy = ProxyRulesStrategy(settings)
182
185
  request = Request(
@@ -188,13 +191,14 @@ async def test_proxy_rules_strategy_apply_no_match(settings):
188
191
  "headers": [],
189
192
  }
190
193
  )
194
+ request.state.span = span
191
195
  response = await strategy.apply(request)
192
196
  assert response.status_code == 404
193
197
 
194
198
 
195
199
  @pytest.mark.asyncio
196
200
  @pytest.mark.skip(reason="TODO: Fix this test")
197
- async def test_proxy_rules_strategy_apply_reverse_proxy(settings_reverse_proxy):
201
+ async def test_proxy_rules_strategy_apply_reverse_proxy(settings_reverse_proxy, span):
198
202
  """Test applying a rule to a request with reverse proxy enabled."""
199
203
  # Mock the httpx.AsyncClient to avoid making real HTTP requests
200
204
  mock_response = MagicMock() # Use MagicMock for response to avoid async attributes
@@ -218,6 +222,7 @@ async def test_proxy_rules_strategy_apply_reverse_proxy(settings_reverse_proxy):
218
222
  "headers": [("host", "example.com")],
219
223
  }
220
224
  )
225
+ request.state.span = span
221
226
  response = await strategy.apply(request)
222
227
 
223
228
  # Verify the response
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.4.2
3
+ Version: 0.5.1
4
4
  Summary: An API mocking workhorse
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/adamhadani/mockstack
@@ -34,7 +34,17 @@ Dynamic: license-file
34
34
 
35
35
  An API mocking workhorse :racehorse:
36
36
 
37
- Enabling a sane development lifecycle for microservice-oriented architectures.
37
+ Enabling a sane development lifecycle for microservice-oriented architectures and LLM-driven tool chains.
38
+
39
+ Use mockstack for:
40
+
41
+ * **Development** :pencil2:. Simulating HTTP-based interactions between a particular component you're developing or debugging locally and multiple other components it depends on during execution of a particular flow. You can create template-based mock responses, simulate creation of resources in a realistic way, as well as proxy to other services using a rich rules DSL. Full request and response metadata and payloads can be observed via OpenTelemetry integration.
42
+
43
+ * **Integration Testing** :ok_hand:. Creating a consistent environment for running integration tests on a single component, using fixture responses.
44
+
45
+ * **LLM-powered Workflows** :alien:. Speeding up development of LLM-based workflows and tools for use with frameworks such as [LangChain](https://python.langchain.com/docs/introduction/), [LangGraph](https://www.langchain.com/langgraph) and others. When developing LLM-driven execution graphs, you often want to have a consistent response from a tool while you're tuning prompts or debugging other aspects of a particular trace. `mockstack` can give you a solid foundation for this.
46
+
47
+ * **Chaos Engineering** :boom:. mockstack can simulate various real-world runtime error scenarios such as timeouts, http error codes, and invalid response payloads. This can be a great way to do some upfront "chaos monkey" type of testing on software components.
38
48
 
39
49
  Highlights include:
40
50
 
@@ -57,6 +67,8 @@ or install into a persistent environment and add it to the PATH with:
57
67
 
58
68
  ## Usage
59
69
 
70
+ See the [examples](https://github.com/adamhadani/mockstack/blob/main/examples/) directory for complete examples with documentation.
71
+
60
72
  Available configuration options are [here](https://github.com/adamhadani/mockstack/blob/main/mockstack/config.py).
61
73
 
62
74
  Setting individual options can be done either through an `.env` file, individual environment variables, or command-line arguments. For example:
@@ -14,6 +14,13 @@ uv.lock
14
14
  docs/assets/favicon.ico
15
15
  docs/assets/logo.png
16
16
  docs/assets/mockstack.png
17
+ examples/filefixtures-with-templates/.env.example
18
+ examples/filefixtures-with-templates/README.md
19
+ examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2
20
+ examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2
21
+ examples/proxyrules-with-rules-file/.env.example
22
+ examples/proxyrules-with-rules-file/README.md
23
+ examples/proxyrules-with-rules-file/rules.yml
17
24
  mockstack/__init__.py
18
25
  mockstack/config.py
19
26
  mockstack/constants.py
@@ -1,55 +0,0 @@
1
- """Middleware definitionsfor the mockstack app."""
2
-
3
- import time
4
-
5
- from fastapi import FastAPI, Request, Response
6
- from opentelemetry import trace
7
- from opentelemetry.propagate import extract
8
-
9
- from mockstack.config import Settings
10
- from mockstack.telemetry import extract_body, span_name_for
11
-
12
-
13
- def middleware_provider(app: FastAPI, settings: Settings) -> None:
14
- """Instrument the middlewares to the mockstack app."""
15
-
16
- @app.middleware("http")
17
- async def add_process_time_header(request: Request, call_next):
18
- start_time = time.time()
19
- response = await call_next(request)
20
- process_time = time.time() - start_time
21
- response.headers["X-Process-Time"] = str(process_time)
22
- return response
23
-
24
- @app.middleware("http")
25
- async def instrument_opentelemetry(request: Request, call_next):
26
- tracer = trace.get_tracer(__name__)
27
- ctx = extract(request.headers)
28
- with tracer.start_as_current_span(span_name_for(request), context=ctx) as span:
29
- span.set_attribute("http.method", request.method)
30
- span.set_attribute("http.url", str(request.url))
31
-
32
- response = await call_next(request)
33
-
34
- span.set_attribute("http.status_code", response.status_code)
35
-
36
- # Nb. persisting response body can hamper performance,
37
- # expose sensitive / PII data, and / or may not be needed.
38
- # it is therefore an opt-in setting.
39
- if settings.opentelemetry.capture_response_body:
40
- body = await extract_body(response)
41
-
42
- # for semantics of payload collection see:
43
- # https://github.com/open-telemetry/oteps/pull/234
44
- span.set_attribute("http.response.body", body)
45
-
46
- # recreate response with the same body since when consuming it to log it above
47
- # we effectively "deplete" the iterator.
48
- response = Response(
49
- content=body,
50
- status_code=response.status_code,
51
- headers=dict(response.headers),
52
- media_type=response.media_type,
53
- )
54
-
55
- return response
@@ -1,63 +0,0 @@
1
- """OpenTelemetry integration."""
2
-
3
- from importlib import metadata
4
-
5
- from fastapi import FastAPI, Request
6
- from opentelemetry import trace
7
- from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
8
- from opentelemetry.sdk.resources import Resource
9
- from opentelemetry.sdk.trace import TracerProvider
10
- from opentelemetry.sdk.trace.export import BatchSpanProcessor
11
- from starlette.responses import StreamingResponse
12
-
13
- from mockstack.config import Settings
14
-
15
-
16
- def span_name_for(request: Request) -> str:
17
- """Get the span name for a request."""
18
- return f"{request.method.upper()} {request.url.path}"
19
-
20
-
21
- async def extract_body(response: StreamingResponse) -> str:
22
- """Extract the body of a response."""
23
-
24
- async def read_response_body(response: StreamingResponse) -> bytes:
25
- """Helper function to read response body asynchronously into memory."""
26
- body = b""
27
- async for chunk in response.body_iterator:
28
- if isinstance(chunk, str):
29
- body += chunk.encode()
30
- else:
31
- body += chunk
32
- return body
33
-
34
- body = await read_response_body(response)
35
-
36
- return body.decode()
37
-
38
-
39
- def opentelemetry_provider(app: FastAPI, settings: Settings) -> None:
40
- """Initialize OpenTelemetry for the mockstack app."""
41
- if not settings.opentelemetry.enabled:
42
- return
43
-
44
- # Initialize OpenTelemetry
45
- distribution = metadata.distribution("mockstack")
46
- resource = Resource(
47
- attributes={
48
- "service.name": distribution.name,
49
- "service.version": distribution.version,
50
- }
51
- )
52
-
53
- tracer_provider = TracerProvider(resource=resource)
54
- trace.set_tracer_provider(tracer_provider)
55
-
56
- # Set up OTLP exporter
57
- otlp_exporter = OTLPSpanExporter(endpoint=settings.opentelemetry.endpoint)
58
- span_processor = BatchSpanProcessor(otlp_exporter)
59
- tracer_provider.add_span_processor(span_processor)
60
-
61
- # Nb. we do not actually use the default FastAPIInstrumentor here
62
- # because we use custom tracing in various places.
63
- # FastAPIInstrumentor.instrument_app(app)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes