mockstack 0.4.2__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mockstack-0.4.2 → mockstack-0.5.0}/PKG-INFO +14 -2
- {mockstack-0.4.2 → mockstack-0.5.0}/README.md +13 -1
- mockstack-0.5.0/examples/filefixtures-with-templates/.env.example +13 -0
- mockstack-0.5.0/examples/filefixtures-with-templates/README.md +10 -0
- mockstack-0.5.0/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +19 -0
- mockstack-0.5.0/examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2 +14 -0
- mockstack-0.5.0/examples/proxyrules-with-rules-file/.env.example +13 -0
- mockstack-0.5.0/examples/proxyrules-with-rules-file/README.md +15 -0
- mockstack-0.5.0/examples/proxyrules-with-rules-file/rules.yml +13 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/config.py +3 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/constants.py +3 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/lifespan.py +15 -4
- mockstack-0.5.0/mockstack/middleware.py +48 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/strategies/proxyrules.py +9 -14
- mockstack-0.5.0/mockstack/telemetry.py +141 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack.egg-info/PKG-INFO +14 -2
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack.egg-info/SOURCES.txt +7 -0
- mockstack-0.4.2/mockstack/middleware.py +0 -55
- mockstack-0.4.2/mockstack/telemetry.py +0 -63
- {mockstack-0.4.2 → mockstack-0.5.0}/.env.example +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/.github/workflows/ci.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/.github/workflows/publish-docs.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/.gitignore +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/.pre-commit-config.yaml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/LICENSE +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/SECURITY.md +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/docs/assets/favicon.ico +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/docs/assets/logo.png +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/docs/assets/mockstack.png +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mkdocs.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/display.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/identifiers.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/intent.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/main.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/routers/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/routers/catchall.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/routers/homepage.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/strategies/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/strategies/base.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/strategies/create_mixin.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/strategies/factory.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/strategies/filefixtures.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/templating.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/conftest.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/routers/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/routers/test_catchall.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/routers/test_homepage.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/strategies/test_proxyrules.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/test_display.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/test_identifiers.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/test_middleware.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack/tests/test_templating.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack.egg-info/dependency_links.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack.egg-info/entry_points.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack.egg-info/requires.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/mockstack.egg-info/top_level.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/pyproject.toml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/setup.cfg +0 -0
- {mockstack-0.4.2 → mockstack-0.5.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mockstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
@@ -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
|
|
39
|
+
config.dictConfig(logging_dict_config_from(settings))
|
|
29
40
|
|
|
30
41
|
announce(app, settings)
|
|
31
42
|
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
response = await call_next(request)
|
|
40
|
+
|
|
41
|
+
span = with_response_attributes(
|
|
42
|
+
response, span, sensitive_headers=SENSITIVE_HEADERS
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if settings.opentelemetry.capture_response_body:
|
|
46
|
+
response, span = await with_response_body(response, span)
|
|
47
|
+
|
|
48
|
+
return response
|
|
@@ -60,10 +60,9 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
|
60
60
|
def __init__(self, settings: Settings, *args, **kwargs):
|
|
61
61
|
super().__init__(settings, *args, **kwargs)
|
|
62
62
|
self.rules_filename = settings.proxyrules_rules_filename
|
|
63
|
-
self.
|
|
64
|
-
self.
|
|
65
|
-
|
|
66
|
-
)
|
|
63
|
+
self.redirect_via = settings.proxyrules_redirect_via
|
|
64
|
+
self.reverse_proxy_timeout = settings.proxyrules_reverse_proxy_timeout
|
|
65
|
+
self.simulate_create_on_missing = settings.proxyrules_simulate_create_on_missing
|
|
67
66
|
self.created_resource_metadata = settings.created_resource_metadata
|
|
68
67
|
|
|
69
68
|
self.env = Environment()
|
|
@@ -72,8 +71,8 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
|
72
71
|
return (
|
|
73
72
|
f"[medium_purple]proxyrules[/medium_purple] "
|
|
74
73
|
f"rules_filename: [medium_purple]{self.rules_filename}[/medium_purple]. "
|
|
75
|
-
f"redirect_via: [medium_purple]{self.
|
|
76
|
-
f"simulate_create_on_missing: [medium_purple]{self.
|
|
74
|
+
f"redirect_via: [medium_purple]{self.redirect_via}[/medium_purple]. "
|
|
75
|
+
f"simulate_create_on_missing: [medium_purple]{self.simulate_create_on_missing}[/medium_purple]"
|
|
77
76
|
)
|
|
78
77
|
|
|
79
78
|
@cached_property
|
|
@@ -101,9 +100,7 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
|
101
100
|
f"No rule found for request: {request.method} {request.url.path}"
|
|
102
101
|
)
|
|
103
102
|
|
|
104
|
-
if self.
|
|
105
|
-
request
|
|
106
|
-
):
|
|
103
|
+
if self.simulate_create_on_missing and looks_like_a_create(request):
|
|
107
104
|
self.logger.info(
|
|
108
105
|
f"Simulating resource creation for missing rule for {request.method} {request.url.path}"
|
|
109
106
|
)
|
|
@@ -119,7 +116,7 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
|
119
116
|
url = rule.apply(request)
|
|
120
117
|
self.logger.info(f"Redirecting to: {url}")
|
|
121
118
|
|
|
122
|
-
match self.
|
|
119
|
+
match self.redirect_via:
|
|
123
120
|
case ProxyRulesRedirectVia.HTTP_TEMPORARY_REDIRECT:
|
|
124
121
|
return RedirectResponse(
|
|
125
122
|
url=url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
|
@@ -135,13 +132,11 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
|
135
132
|
return response
|
|
136
133
|
|
|
137
134
|
case _:
|
|
138
|
-
raise ValueError(
|
|
139
|
-
f"Invalid redirect via value: {self.proxyrules_redirect_via=}"
|
|
140
|
-
)
|
|
135
|
+
raise ValueError(f"Invalid redirect via value: {self.redirect_via=}")
|
|
141
136
|
|
|
142
137
|
async def reverse_proxy(self, request: Request, url: str) -> Response:
|
|
143
138
|
"""Reverse proxy the request to the target URL."""
|
|
144
|
-
async with httpx.AsyncClient() as client:
|
|
139
|
+
async with httpx.AsyncClient(timeout=self.reverse_proxy_timeout) as client:
|
|
145
140
|
request_content = await request.body()
|
|
146
141
|
request_headers = self.reverse_proxy_headers(request.headers, url=url)
|
|
147
142
|
req = client.build_request(
|
|
@@ -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,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mockstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|