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.
- {mockstack-0.4.2 → mockstack-0.5.1}/PKG-INFO +14 -2
- {mockstack-0.4.2 → mockstack-0.5.1}/README.md +13 -1
- mockstack-0.5.1/examples/filefixtures-with-templates/.env.example +13 -0
- mockstack-0.5.1/examples/filefixtures-with-templates/README.md +10 -0
- mockstack-0.5.1/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +19 -0
- mockstack-0.5.1/examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2 +14 -0
- mockstack-0.5.1/examples/proxyrules-with-rules-file/.env.example +13 -0
- mockstack-0.5.1/examples/proxyrules-with-rules-file/README.md +15 -0
- mockstack-0.5.1/examples/proxyrules-with-rules-file/rules.yml +13 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/config.py +3 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/constants.py +3 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/lifespan.py +15 -4
- mockstack-0.5.1/mockstack/middleware.py +53 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/base.py +9 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/filefixtures.py +9 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/proxyrules.py +31 -15
- mockstack-0.5.1/mockstack/telemetry.py +141 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/conftest.py +11 -1
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/strategies/test_filefixtures.py +6 -3
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/strategies/test_proxyrules.py +10 -5
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/PKG-INFO +14 -2
- {mockstack-0.4.2 → mockstack-0.5.1}/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.1}/.env.example +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/.github/workflows/ci.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/.github/workflows/publish-docs.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/.github/workflows/publish-to-pypi.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/.gitignore +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/.pre-commit-config.yaml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/CODE_OF_CONDUCT.md +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/LICENSE +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/SECURITY.md +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/docs/assets/favicon.ico +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/docs/assets/logo.png +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/docs/assets/mockstack.png +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mkdocs.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/display.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/identifiers.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/intent.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/main.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/routers/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/routers/catchall.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/routers/homepage.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/create_mixin.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/strategies/factory.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/templating.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/fixtures/proxyrules.yml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/fixtures/templates/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/routers/__init__.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/routers/test_catchall.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/routers/test_homepage.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_display.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_identifiers.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_middleware.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack/tests/test_templating.py +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/dependency_links.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/entry_points.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/requires.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/mockstack.egg-info/top_level.txt +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/pyproject.toml +0 -0
- {mockstack-0.4.2 → mockstack-0.5.1}/setup.cfg +0 -0
- {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.
|
|
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
|
|
@@ -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,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__(
|
|
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.
|
|
64
|
-
self.
|
|
65
|
-
|
|
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.
|
|
76
|
-
f"simulate_create_on_missing: [medium_purple]{self.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|