macss-modular-api 0.4.3__tar.gz → 0.4.5__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 (45) hide show
  1. {macss_modular_api-0.4.3/src/macss_modular_api.egg-info → macss_modular_api-0.4.5}/PKG-INFO +6 -6
  2. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/README.md +5 -5
  3. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/pyproject.toml +1 -1
  4. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5/src/macss_modular_api.egg-info}/PKG-INFO +6 -6
  5. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/modular_api.py +4 -0
  6. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/usecase.py +4 -2
  7. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/usecase_handler.py +6 -3
  8. macss_modular_api-0.4.5/src/modular_api/openapi/swagger_docs.py +48 -0
  9. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_fromjson_validation.py +26 -0
  10. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_modular_api.py +48 -1
  11. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_schema_conformance.py +18 -0
  12. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_usecase_handler.py +77 -0
  13. macss_modular_api-0.4.3/src/modular_api/openapi/swagger_docs.py +0 -228
  14. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/LICENSE +0 -0
  15. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/setup.cfg +0 -0
  16. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/macss_modular_api.egg-info/SOURCES.txt +0 -0
  17. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/macss_modular_api.egg-info/dependency_links.txt +0 -0
  18. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/macss_modular_api.egg-info/requires.txt +0 -0
  19. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/macss_modular_api.egg-info/top_level.txt +0 -0
  20. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/__init__.py +0 -0
  21. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/__init__.py +0 -0
  22. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/health/__init__.py +0 -0
  23. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/health/health_check.py +0 -0
  24. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/health/health_handler.py +0 -0
  25. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/health/health_service.py +0 -0
  26. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/logger/__init__.py +0 -0
  27. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/logger/logger.py +0 -0
  28. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/logger/logging_middleware.py +0 -0
  29. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/metrics/__init__.py +0 -0
  30. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/metrics/metric.py +0 -0
  31. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/metrics/metric_registry.py +0 -0
  32. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/metrics/metrics_middleware.py +0 -0
  33. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/module_builder.py +0 -0
  34. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/registry.py +0 -0
  35. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/core/use_case_exception.py +0 -0
  36. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/middlewares/__init__.py +0 -0
  37. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/middlewares/cors.py +0 -0
  38. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/openapi/__init__.py +0 -0
  39. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/openapi/openapi.py +0 -0
  40. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/src/modular_api/py.typed +0 -0
  41. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_auto_schema.py +0 -0
  42. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_field_example.py +0 -0
  43. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_module_builder.py +0 -0
  44. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_use_case_exception.py +0 -0
  45. {macss_modular_api-0.4.3 → macss_modular_api-0.4.5}/tests/test_usecase.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macss-modular-api
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: Use-case-centric toolkit for building modular APIs with Starlette. Define UseCase classes (input → validate → execute → output), connect them to HTTP routes, and expose OpenAPI documentation automatically.
5
5
  Author: ccisne.dev
6
6
  License-Expression: MIT
@@ -114,7 +114,7 @@ pip install modular-api[serve]
114
114
  ## Error handling
115
115
 
116
116
  ```python
117
- async def execute(self) -> None:
117
+ async def execute(self) -> FoundUserOutput:
118
118
  user = await repository.find_by_id(self.input.user_id)
119
119
  if not user:
120
120
  raise UseCaseException(
@@ -122,7 +122,7 @@ async def execute(self) -> None:
122
122
  message="User not found",
123
123
  error_code="USER_NOT_FOUND",
124
124
  )
125
- self._output = FoundUserOutput(name=user.name)
125
+ return FoundUserOutput(name=user.name)
126
126
  ```
127
127
 
128
128
  ---
@@ -130,13 +130,13 @@ async def execute(self) -> None:
130
130
  ## Testing
131
131
 
132
132
  ```python
133
- def test_hello_world():
133
+ async def test_hello_world():
134
134
  usecase = HelloWorld(HelloInput(name="World"))
135
135
  error = usecase.validate()
136
136
  assert error is None
137
137
 
138
- await usecase.execute()
139
- assert usecase.output.message == "Hello, World!"
138
+ output = await usecase.execute()
139
+ assert output.message == "Hello, World!"
140
140
  ```
141
141
 
142
142
  See [doc/testing_guide.md](doc/testing_guide.md) for the full testing guide.
@@ -79,7 +79,7 @@ pip install modular-api[serve]
79
79
  ## Error handling
80
80
 
81
81
  ```python
82
- async def execute(self) -> None:
82
+ async def execute(self) -> FoundUserOutput:
83
83
  user = await repository.find_by_id(self.input.user_id)
84
84
  if not user:
85
85
  raise UseCaseException(
@@ -87,7 +87,7 @@ async def execute(self) -> None:
87
87
  message="User not found",
88
88
  error_code="USER_NOT_FOUND",
89
89
  )
90
- self._output = FoundUserOutput(name=user.name)
90
+ return FoundUserOutput(name=user.name)
91
91
  ```
92
92
 
93
93
  ---
@@ -95,13 +95,13 @@ async def execute(self) -> None:
95
95
  ## Testing
96
96
 
97
97
  ```python
98
- def test_hello_world():
98
+ async def test_hello_world():
99
99
  usecase = HelloWorld(HelloInput(name="World"))
100
100
  error = usecase.validate()
101
101
  assert error is None
102
102
 
103
- await usecase.execute()
104
- assert usecase.output.message == "Hello, World!"
103
+ output = await usecase.execute()
104
+ assert output.message == "Hello, World!"
105
105
  ```
106
106
 
107
107
  See [doc/testing_guide.md](doc/testing_guide.md) for the full testing guide.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "macss-modular-api"
7
- version = "0.4.3"
7
+ version = "0.4.5"
8
8
  description = "Use-case-centric toolkit for building modular APIs with Starlette. Define UseCase classes (input → validate → execute → output), connect them to HTTP routes, and expose OpenAPI documentation automatically."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macss-modular-api
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: Use-case-centric toolkit for building modular APIs with Starlette. Define UseCase classes (input → validate → execute → output), connect them to HTTP routes, and expose OpenAPI documentation automatically.
5
5
  Author: ccisne.dev
6
6
  License-Expression: MIT
@@ -114,7 +114,7 @@ pip install modular-api[serve]
114
114
  ## Error handling
115
115
 
116
116
  ```python
117
- async def execute(self) -> None:
117
+ async def execute(self) -> FoundUserOutput:
118
118
  user = await repository.find_by_id(self.input.user_id)
119
119
  if not user:
120
120
  raise UseCaseException(
@@ -122,7 +122,7 @@ async def execute(self) -> None:
122
122
  message="User not found",
123
123
  error_code="USER_NOT_FOUND",
124
124
  )
125
- self._output = FoundUserOutput(name=user.name)
125
+ return FoundUserOutput(name=user.name)
126
126
  ```
127
127
 
128
128
  ---
@@ -130,13 +130,13 @@ async def execute(self) -> None:
130
130
  ## Testing
131
131
 
132
132
  ```python
133
- def test_hello_world():
133
+ async def test_hello_world():
134
134
  usecase = HelloWorld(HelloInput(name="World"))
135
135
  error = usecase.validate()
136
136
  assert error is None
137
137
 
138
- await usecase.execute()
139
- assert usecase.output.message == "Hello, World!"
138
+ output = await usecase.execute()
139
+ assert output.message == "Hello, World!"
140
140
  ```
141
141
 
142
142
  See [doc/testing_guide.md](doc/testing_guide.md) for the full testing guide.
@@ -59,6 +59,7 @@ class ModularApi:
59
59
  title: str = "Modular API",
60
60
  version: str = "0.0.0",
61
61
  release_id: str | None = None,
62
+ servers: list[dict[str, str]] | None = None,
62
63
  metrics_enabled: bool = False,
63
64
  metrics_path: str = "/metrics",
64
65
  log_level: LogLevel = LogLevel.info,
@@ -67,6 +68,7 @@ class ModularApi:
67
68
  self._title = title
68
69
  self._version = version
69
70
  self._release_id = release_id or f"{version}-debug"
71
+ self._servers = servers
70
72
  self._metrics_enabled = metrics_enabled
71
73
  self._metrics_path = metrics_path
72
74
  self._log_level = log_level
@@ -188,6 +190,8 @@ class ModularApi:
188
190
 
189
191
  # OpenAPI endpoints
190
192
  spec_kwargs: dict[str, Any] = {"title": self._title, "port": port, "version": self._version}
193
+ if self._servers is not None:
194
+ spec_kwargs["servers"] = self._servers
191
195
  routes.append(Route("/openapi.json", endpoint=openapi_json_handler(**spec_kwargs)))
192
196
  routes.append(Route("/openapi.yaml", endpoint=openapi_yaml_handler(**spec_kwargs)))
193
197
 
@@ -49,13 +49,15 @@ def _normalize_schema(raw: dict[str, Any]) -> dict[str, object]:
49
49
  # Convert examples → example (nullable fields)
50
50
  if "examples" in prop and prop["examples"]:
51
51
  collapsed["example"] = prop["examples"][0]
52
+ collapsed.pop("additionalProperties", None)
52
53
  normalized_props[name] = _reorder_type_first(collapsed)
53
54
  if name in required:
54
55
  required.remove(name)
55
56
  continue
56
57
 
57
- # Strip Pydantic's auto-generated ``title`` and ``default`` from properties
58
- cleaned = {k: v for k, v in prop.items() if k not in ("title", "default")}
58
+ # Strip Pydantic's auto-generated ``title``, ``default``, and
59
+ # ``additionalProperties`` (emitted for dict[str, Any] fields) from properties.
60
+ cleaned = {k: v for k, v in prop.items() if k not in ("title", "default", "additionalProperties")}
59
61
 
60
62
  # Convert Pydantic ``examples`` (list, Draft 2020-12) → OpenAPI ``example`` (singular)
61
63
  if "examples" in cleaned and cleaned["examples"]:
@@ -8,7 +8,6 @@ the full use-case lifecycle: parse → validate → execute → respond.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
- import sys
12
11
  from typing import Any, Callable
13
12
 
14
13
  from pydantic import ValidationError
@@ -114,7 +113,9 @@ def usecase_handler(factory: UseCaseFactory) -> Any:
114
113
  )
115
114
 
116
115
  except UseCaseException as exc:
117
- print(f"UseCaseException: {exc}", file=sys.stderr)
116
+ logger = getattr(request.state, LOGGER_STATE_KEY, None)
117
+ if logger is not None:
118
+ logger.error("UseCaseException", fields={"error": str(exc), "status": exc.status_code})
118
119
  return Response(
119
120
  content=json.dumps(exc.to_json()),
120
121
  status_code=exc.status_code,
@@ -131,7 +132,9 @@ def usecase_handler(factory: UseCaseFactory) -> Any:
131
132
  media_type=_JSON_CONTENT_TYPE,
132
133
  )
133
134
  except Exception as exc:
134
- print(f"usecase_handler unexpected error: {exc}", file=sys.stderr)
135
+ logger = getattr(request.state, LOGGER_STATE_KEY, None)
136
+ if logger is not None:
137
+ logger.error("Unexpected error in use case handler", fields={"error": str(exc)})
135
138
  return Response(
136
139
  content=json.dumps({"error": "Internal server error"}),
137
140
  status_code=500,
@@ -0,0 +1,48 @@
1
+ """Docs UI handler — serves the ``@macss/docs-ui`` widget from CDN.
2
+
3
+ Loads ``@macss/docs-ui`` from jsdelivr CDN, which wraps Swagger UI with
4
+ system-aware dark mode. The local ``/openapi.json`` endpoint provides
5
+ the spec. Styling, dark mode, and Swagger UI loading are handled
6
+ entirely by docs-ui — no inline CSS or JS in this template.
7
+
8
+ See: https://github.com/macss-dev/modular_api/tree/main/docs-ui
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from starlette.requests import Request
14
+ from starlette.responses import HTMLResponse
15
+
16
+ _DOCS_UI_CDN = "https://cdn.jsdelivr.net/npm/@macss/docs-ui@0.1/dist"
17
+
18
+ # {title} is the only placeholder — str.replace() is a literal match,
19
+ # so JavaScript braces pass through unaffected.
20
+ _DOCS_UI_HTML_TEMPLATE = """\
21
+ <!DOCTYPE html>
22
+ <html>
23
+ <head>
24
+ <title>{title} — API Reference</title>
25
+ <meta charset="utf-8" />
26
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
27
+ </head>
28
+ <body>
29
+ <div id="swagger-ui"></div>
30
+ <script src=""" + f'"{_DOCS_UI_CDN}/docs-ui.js"' + """></script>
31
+ <script>DocsUI.init({ specUrl: "/openapi.json" })</script>
32
+ </body>
33
+ </html>"""
34
+
35
+
36
+ def swagger_docs_handler(*, title: str) -> object:
37
+ """Return a Starlette endpoint that serves a docs-ui HTML page.
38
+
39
+ Usage::
40
+
41
+ Route("/docs", endpoint=swagger_docs_handler(title="My API"))
42
+ """
43
+ html = _DOCS_UI_HTML_TEMPLATE.replace("{title}", title)
44
+
45
+ async def _endpoint(request: Request) -> HTMLResponse:
46
+ return HTMLResponse(html)
47
+
48
+ return _endpoint
@@ -9,6 +9,7 @@ Error message contract (identical across all 3 SDKs for parity):
9
9
  """
10
10
 
11
11
  import json
12
+ from typing import Any
12
13
 
13
14
  import pytest
14
15
  from pydantic import Field, ValidationError
@@ -120,3 +121,28 @@ class TestFromJsonHandlerErrors:
120
121
  assert resp.status_code == 200
121
122
  body = resp.json()
122
123
  assert body["greeting"] == "Hi Alice, age 25"
124
+
125
+
126
+ # ── Unit: dict[str, Any] object type validation ──────────────────────────
127
+
128
+
129
+ class ObjectInput(Input):
130
+ id: str = Field(description="ID")
131
+ details: dict[str, Any] = Field(description="Nested object")
132
+
133
+
134
+ class TestFromJsonObjectType:
135
+ """Pydantic strict mode validates dict[str, Any] as object type."""
136
+
137
+ def test_accepts_dict_for_object_field(self) -> None:
138
+ result = ObjectInput.from_json({"id": "abc", "details": {"amount": 100}})
139
+ assert result.id == "abc"
140
+ assert result.details == {"amount": 100}
141
+
142
+ def test_rejects_string_for_object_field(self) -> None:
143
+ with pytest.raises(ValidationError):
144
+ ObjectInput.from_json({"id": "abc", "details": "not-a-dict"})
145
+
146
+ def test_rejects_list_for_object_field(self) -> None:
147
+ with pytest.raises(ValidationError):
148
+ ObjectInput.from_json({"id": "abc", "details": [1, 2]})
@@ -205,10 +205,57 @@ class TestAutoMountedEndpoints:
205
205
  response = client.get("/docs")
206
206
  assert response.status_code == 200
207
207
  assert "text/html" in response.headers["content-type"]
208
- assert "swagger-ui-dist@5" in response.text
208
+ assert "@macss/docs-ui" in response.text
209
209
  assert "Test API" in response.text
210
210
 
211
211
 
212
+ # ── Custom servers in OpenAPI spec ────────────────────────────
213
+
214
+
215
+ class TestCustomServers:
216
+ """ModularApi propagates servers to the OpenAPI spec."""
217
+
218
+ def _build_client(self, **api_options: object) -> TestClient:
219
+ api = _make_api(**api_options)
220
+ api.module("test", lambda m: m.usecase("ping", _PingUseCase.from_json))
221
+ app = api.build()
222
+ return TestClient(app)
223
+
224
+ def test_uses_localhost_default_when_servers_not_provided(self) -> None:
225
+ client = self._build_client()
226
+ spec = client.get("/openapi.json").json()
227
+ assert len(spec["servers"]) == 1
228
+ assert "localhost" in spec["servers"][0]["url"]
229
+
230
+ def test_propagates_custom_servers_to_openapi_spec(self) -> None:
231
+ client = self._build_client(
232
+ servers=[{"url": "https://miapi.example.com", "description": "Production"}],
233
+ )
234
+ spec = client.get("/openapi.json").json()
235
+ assert len(spec["servers"]) == 1
236
+ assert spec["servers"][0]["url"] == "https://miapi.example.com"
237
+ assert spec["servers"][0]["description"] == "Production"
238
+
239
+ def test_supports_multiple_servers(self) -> None:
240
+ client = self._build_client(
241
+ servers=[
242
+ {"url": "https://prod.example.com", "description": "Production"},
243
+ {"url": "http://192.168.5.82:8080", "description": "LAN"},
244
+ ],
245
+ )
246
+ spec = client.get("/openapi.json").json()
247
+ assert len(spec["servers"]) == 2
248
+ assert spec["servers"][0]["url"] == "https://prod.example.com"
249
+ assert spec["servers"][1]["url"] == "http://192.168.5.82:8080"
250
+
251
+ def test_preserves_server_descriptions(self) -> None:
252
+ client = self._build_client(
253
+ servers=[{"url": "https://api.example.com", "description": "Main API"}],
254
+ )
255
+ spec = client.get("/openapi.json").json()
256
+ assert spec["servers"][0]["description"] == "Main API"
257
+
258
+
212
259
  # ── Step 2.9.3: Middleware pipeline order ─────────────────────
213
260
 
214
261
 
@@ -4,14 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from pathlib import Path
7
+ from typing import Any
7
8
 
8
9
  import sys
9
10
 
11
+ from pydantic import Field
12
+
10
13
  _EXAMPLE_DIR = Path(__file__).resolve().parent.parent / "example"
11
14
  sys.path.insert(0, str(_EXAMPLE_DIR))
12
15
 
13
16
  from modules.greetings.usecases.hello_world import HelloWorldInput, HelloWorldOutput # type: ignore[import-untyped]
14
17
 
18
+ from modular_api.core.usecase import Input
19
+
15
20
  _FIXTURES = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
16
21
 
17
22
 
@@ -48,3 +53,16 @@ class TestSchemaConformance:
48
53
  def test_hello_output_to_json(self) -> None:
49
54
  instance = HelloWorldOutput(message="Hello!")
50
55
  assert instance.to_json() == {"message": "Hello!"}
56
+
57
+
58
+ class WebhookInput(Input):
59
+ instruction_id: str = Field(description="Payment instruction ID", examples=["20260323ABC"])
60
+ transfer_details: dict[str, Any] = Field(description="Nested transfer info", examples=[{"amount": 2300, "currency": "PEN"}])
61
+
62
+
63
+ class TestSchemaConformanceObjectType:
64
+ """Verify dict[str, Any] produces a schema identical to the shared fixture."""
65
+
66
+ def test_webhook_input_schema_matches_fixture(self) -> None:
67
+ fixture = _load_fixture("webhook_input_schema.json")
68
+ assert WebhookInput.to_schema() == fixture
@@ -205,3 +205,80 @@ class TestUseCaseLifecycle:
205
205
  client = TestClient(app)
206
206
  client.post("/spy", json={})
207
207
  assert captured_loggers == ["fake-logger"]
208
+
209
+
210
+ # ── Scoped-logger error integration (issue #7) ───────────────────────────
211
+
212
+
213
+ class TestScopedLoggerInErrorPaths:
214
+ """Catch blocks must log through the request-scoped logger (with trace_id)
215
+ instead of printing to stderr."""
216
+
217
+ @staticmethod
218
+ def _build_app_with_logger(
219
+ factory,
220
+ log_lines: list[str],
221
+ ) -> Starlette:
222
+ """App with logging middleware capturing output into log_lines."""
223
+ from starlette.middleware import Middleware
224
+
225
+ from modular_api.core.logger.logging_middleware import logging_middleware
226
+ from modular_api.core.logger.logger import LogLevel
227
+
228
+ mw_cls = logging_middleware(
229
+ log_level=LogLevel.debug,
230
+ service_name="test-svc",
231
+ write_fn=lambda line: log_lines.append(line),
232
+ )
233
+ return Starlette(
234
+ routes=[Route("/test", usecase_handler(factory), methods=["POST"])],
235
+ middleware=[Middleware(mw_cls)],
236
+ )
237
+
238
+ def test_logs_use_case_exception_through_scoped_logger(self) -> None:
239
+ log_lines: list[str] = []
240
+ app = self._build_app_with_logger(FailingUseCase, log_lines)
241
+ client = TestClient(app, raise_server_exceptions=False)
242
+ resp = client.post(
243
+ "/test",
244
+ json={"name": "x"},
245
+ headers={"X-Request-ID": "trace-py-uce-001"},
246
+ )
247
+ assert resp.status_code == 409
248
+
249
+ error_logs = [
250
+ json.loads(l)
251
+ for l in log_lines
252
+ if '"level": "error"' in l and "UseCaseException" in l
253
+ ]
254
+ assert len(error_logs) > 0, "expected error log from scoped logger"
255
+ assert error_logs[0]["trace_id"] == "trace-py-uce-001"
256
+
257
+ def test_logs_unexpected_error_through_scoped_logger(self) -> None:
258
+ log_lines: list[str] = []
259
+ app = self._build_app_with_logger(CrashingUseCase, log_lines)
260
+ client = TestClient(app, raise_server_exceptions=False)
261
+ resp = client.post(
262
+ "/test",
263
+ json={"name": "x"},
264
+ headers={"X-Request-ID": "trace-py-crash-002"},
265
+ )
266
+ assert resp.status_code == 500
267
+
268
+ error_logs = [
269
+ json.loads(l)
270
+ for l in log_lines
271
+ if '"level": "error"' in l and "Unexpected error" in l
272
+ ]
273
+ assert len(error_logs) > 0, "expected error log from scoped logger"
274
+ assert error_logs[0]["trace_id"] == "trace-py-crash-002"
275
+
276
+ def test_does_not_throw_when_logger_unavailable(self) -> None:
277
+ """When no logging middleware is present, catch blocks must not crash."""
278
+ app = Starlette(
279
+ routes=[Route("/test", usecase_handler(FailingUseCase), methods=["POST"])],
280
+ )
281
+ client = TestClient(app, raise_server_exceptions=False)
282
+ resp = client.post("/test", json={"name": "x"})
283
+ assert resp.status_code == 409
284
+ assert resp.json()["message"] == "Already exists"
@@ -1,228 +0,0 @@
1
- """Swagger UI docs handler — serves a self-contained interactive API explorer.
2
-
3
- Loads swagger-ui-dist@5 (CSS + JS bundle) from the jsdelivr CDN and
4
- points the UI at the local ``/openapi.json`` endpoint. No server-side
5
- dependencies; no npm packages required. Replaces the previous Scalar
6
- widget as specified by PRD-003.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from starlette.requests import Request
12
- from starlette.responses import HTMLResponse
13
-
14
- # Canonical Swagger UI HTML per PRD-003.
15
- # Uses {title} as a plain str.replace() placeholder — Python's replace()
16
- # is a literal match, so JavaScript braces pass through unaffected.
17
- _SWAGGER_UI_HTML_TEMPLATE = """\
18
- <!DOCTYPE html>
19
- <html>
20
- <head>
21
- <title>{title} — API Reference</title>
22
- <meta charset="utf-8" />
23
- <meta name="viewport" content="width=device-width, initial-scale=1" />
24
- <link
25
- rel="stylesheet"
26
- href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"
27
- />
28
- <style>
29
- /* ── Light mode baseline (Swagger UI default) ───────────── */
30
- :root {
31
- --bg-primary: #ffffff;
32
- --bg-secondary: #f7f7f7;
33
- --bg-block: #ffffff;
34
- --border-color: #e8e8e8;
35
- --text-primary: #3b4151;
36
- --text-muted: #6b7280;
37
- --input-bg: #ffffff;
38
- --input-text: #3b4151;
39
- --input-border: #d9d9d9;
40
- }
41
-
42
- /* ── Dark mode overrides ───────────────────────────── */
43
- @media (prefers-color-scheme: dark) {
44
- :root {
45
- --bg-primary: #1a1a1a;
46
- --bg-secondary: #222222;
47
- --bg-block: #2a2a2a;
48
- --border-color: #3a3a3a;
49
- --text-primary: #e0e0e0;
50
- --text-muted: #a0a0a0;
51
- --input-bg: #2a2a2a;
52
- --input-text: #e0e0e0;
53
- --input-border: #444444;
54
- }
55
-
56
- body {
57
- background-color: var(--bg-primary);
58
- }
59
-
60
- /* ── General text ──────────────────────────────── */
61
- .swagger-ui,
62
- .swagger-ui .info .title,
63
- .swagger-ui .info p,
64
- .swagger-ui .info li,
65
- .swagger-ui .info a,
66
- .swagger-ui .opblock-tag,
67
- .swagger-ui .opblock-tag small,
68
- .swagger-ui table thead tr td,
69
- .swagger-ui table thead tr th,
70
- .swagger-ui .response-col_status,
71
- .swagger-ui .response-col_description,
72
- .swagger-ui .parameter__name,
73
- .swagger-ui .parameter__type,
74
- .swagger-ui .parameter__in,
75
- .swagger-ui .tab li,
76
- .swagger-ui .model-title,
77
- .swagger-ui .model,
78
- .swagger-ui .prop-type,
79
- .swagger-ui .prop-format {
80
- color: var(--text-primary);
81
- }
82
-
83
- /* ── Backgrounds ───────────────────────────────── */
84
- .swagger-ui .wrapper,
85
- .swagger-ui .scheme-container,
86
- .swagger-ui section.models,
87
- .swagger-ui section.models .model-container,
88
- .swagger-ui .model-box {
89
- background: var(--bg-secondary);
90
- border-color: var(--border-color);
91
- }
92
-
93
- /* ── Operation blocks ────────────────────────────── */
94
- .swagger-ui .opblock {
95
- background: var(--bg-block);
96
- border-color: var(--border-color);
97
- box-shadow: none;
98
- }
99
-
100
- .swagger-ui .opblock .opblock-summary {
101
- background: var(--bg-block);
102
- border-color: var(--border-color);
103
- }
104
-
105
- .swagger-ui .opblock .opblock-section-header {
106
- background: var(--bg-secondary);
107
- border-color: var(--border-color);
108
- }
109
-
110
- .swagger-ui .opblock .opblock-section-header h4 {
111
- color: var(--text-primary);
112
- }
113
-
114
- /* HTTP method accent colors preserved in dark mode */
115
- .swagger-ui .opblock.opblock-post { border-color: #49cc90; }
116
- .swagger-ui .opblock.opblock-get { border-color: #61affe; }
117
- .swagger-ui .opblock.opblock-put { border-color: #fca130; }
118
- .swagger-ui .opblock.opblock-delete { border-color: #f93e3e; }
119
- .swagger-ui .opblock.opblock-patch { border-color: #50e3c2; }
120
-
121
- .swagger-ui .opblock.opblock-post .opblock-summary-method { background: #49cc90; }
122
- .swagger-ui .opblock.opblock-get .opblock-summary-method { background: #61affe; }
123
- .swagger-ui .opblock.opblock-put .opblock-summary-method { background: #fca130; }
124
- .swagger-ui .opblock.opblock-delete .opblock-summary-method { background: #f93e3e; }
125
- .swagger-ui .opblock.opblock-patch .opblock-summary-method { background: #50e3c2; }
126
-
127
- /* ── Inputs and controls ─────────────────────────── */
128
- .swagger-ui input[type=text],
129
- .swagger-ui input[type=password],
130
- .swagger-ui input[type=search],
131
- .swagger-ui input[type=email],
132
- .swagger-ui textarea,
133
- .swagger-ui select {
134
- background: var(--input-bg);
135
- color: var(--input-text);
136
- border-color: var(--input-border);
137
- }
138
-
139
- /* ── Buttons ───────────────────────────────────── */
140
- .swagger-ui .btn {
141
- background: var(--bg-block);
142
- color: var(--text-primary);
143
- border-color: var(--border-color);
144
- }
145
-
146
- .swagger-ui .btn.execute {
147
- background: #4a90e2;
148
- color: #ffffff;
149
- border-color: #4a90e2;
150
- }
151
-
152
- .swagger-ui .btn.authorize {
153
- color: #49cc90;
154
- border-color: #49cc90;
155
- }
156
-
157
- /* ── Response area ─────────────────────────────── */
158
- .swagger-ui .responses-inner,
159
- .swagger-ui .response-col_description__inner {
160
- background: var(--bg-secondary);
161
- color: var(--text-primary);
162
- }
163
-
164
- .swagger-ui .highlight-code,
165
- .swagger-ui .microlight {
166
- background: #111111 !important;
167
- color: #e0e0e0 !important;
168
- }
169
-
170
- /* ── Topbar ────────────────────────────────────── */
171
- .swagger-ui .topbar {
172
- background-color: #111111;
173
- }
174
-
175
- /* ── Expand/collapse arrows ──────────────────────── */
176
- .swagger-ui .expand-methods svg,
177
- .swagger-ui .expand-operation svg {
178
- fill: var(--text-muted);
179
- }
180
-
181
- /* ── Filter input ──────────────────────────────── */
182
- .swagger-ui .filter .operation-filter-input {
183
- background: var(--input-bg);
184
- color: var(--input-text);
185
- border-color: var(--input-border);
186
- }
187
-
188
- /* ── Scrollbar (webkit) ──────────────────────────── */
189
- ::-webkit-scrollbar { width: 8px; height: 8px; }
190
- ::-webkit-scrollbar-track { background: var(--bg-primary); }
191
- ::-webkit-scrollbar-thumb { background: #444444; border-radius: 4px; }
192
- ::-webkit-scrollbar-thumb:hover { background: #555555; }
193
- }
194
- </style>
195
- </head>
196
- <body>
197
- <div id="swagger-ui"></div>
198
- <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js">
199
- </script>
200
- <script>
201
- SwaggerUIBundle({
202
- url: "/openapi.json",
203
- dom_id: "#swagger-ui",
204
- presets: [
205
- SwaggerUIBundle.presets.apis,
206
- SwaggerUIBundle.SwaggerUIStandalonePreset
207
- ],
208
- layout: "BaseLayout",
209
- deepLinking: true
210
- })
211
- </script>
212
- </body>
213
- </html>"""
214
-
215
-
216
- def swagger_docs_handler(*, title: str) -> object:
217
- """Return a Starlette endpoint that serves a Swagger UI HTML page.
218
-
219
- Usage::
220
-
221
- Route("/docs", endpoint=swagger_docs_handler(title="My API"))
222
- """
223
- html = _SWAGGER_UI_HTML_TEMPLATE.replace("{title}", title)
224
-
225
- async def _endpoint(request: Request) -> HTMLResponse:
226
- return HTMLResponse(html)
227
-
228
- return _endpoint