macss-modular-api 0.4.2__tar.gz → 0.4.4__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.2/src/macss_modular_api.egg-info → macss_modular_api-0.4.4}/PKG-INFO +1 -1
  2. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/pyproject.toml +1 -1
  3. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4/src/macss_modular_api.egg-info}/PKG-INFO +1 -1
  4. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/module_builder.py +13 -32
  5. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/registry.py +2 -2
  6. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/usecase.py +7 -15
  7. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/usecase_handler.py +3 -3
  8. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/openapi/openapi.py +4 -3
  9. macss_modular_api-0.4.4/src/modular_api/openapi/swagger_docs.py +48 -0
  10. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_fromjson_validation.py +2 -10
  11. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_modular_api.py +3 -11
  12. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_module_builder.py +2 -14
  13. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_schema_conformance.py +8 -8
  14. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_usecase.py +8 -16
  15. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_usecase_handler.py +6 -38
  16. macss_modular_api-0.4.2/src/modular_api/openapi/swagger_docs.py +0 -228
  17. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/LICENSE +0 -0
  18. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/README.md +0 -0
  19. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/setup.cfg +0 -0
  20. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/macss_modular_api.egg-info/SOURCES.txt +0 -0
  21. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/macss_modular_api.egg-info/dependency_links.txt +0 -0
  22. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/macss_modular_api.egg-info/requires.txt +0 -0
  23. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/macss_modular_api.egg-info/top_level.txt +0 -0
  24. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/__init__.py +0 -0
  25. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/__init__.py +0 -0
  26. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/health/__init__.py +0 -0
  27. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/health/health_check.py +0 -0
  28. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/health/health_handler.py +0 -0
  29. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/health/health_service.py +0 -0
  30. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/logger/__init__.py +0 -0
  31. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/logger/logger.py +0 -0
  32. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/logger/logging_middleware.py +0 -0
  33. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/metrics/__init__.py +0 -0
  34. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/metrics/metric.py +0 -0
  35. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/metrics/metric_registry.py +0 -0
  36. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/metrics/metrics_middleware.py +0 -0
  37. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/modular_api.py +0 -0
  38. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/core/use_case_exception.py +0 -0
  39. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/middlewares/__init__.py +0 -0
  40. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/middlewares/cors.py +0 -0
  41. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/openapi/__init__.py +0 -0
  42. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/src/modular_api/py.typed +0 -0
  43. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_auto_schema.py +0 -0
  44. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_field_example.py +0 -0
  45. {macss_modular_api-0.4.2 → macss_modular_api-0.4.4}/tests/test_use_case_exception.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macss-modular-api
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "macss-modular-api"
7
- version = "0.4.2"
7
+ version = "0.4.4"
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.2
3
+ Version: 0.4.4
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
@@ -91,7 +91,7 @@ class ModuleBuilder:
91
91
 
92
92
  def usecase(
93
93
  self,
94
- name: str,
94
+ command: str,
95
95
  factory: UseCaseFactory,
96
96
  *,
97
97
  method: str = "POST",
@@ -100,15 +100,19 @@ class ModuleBuilder:
100
100
  ) -> ModuleBuilder:
101
101
  """Register a use case as an HTTP endpoint on this module.
102
102
 
103
+ ``command`` becomes the route segment and the OpenAPI operationId root.
104
+ Convention: command, class name, and file name share the same root.
105
+ Example: command ``'hello-world'`` → class ``HelloWorld`` → file ``hello_world.py``.
106
+
103
107
  Returns ``self`` for fluent chaining.
104
108
  """
105
- clean_name = name.strip().lstrip("/")
109
+ clean_command = command.strip().lstrip("/")
106
110
  method_upper = method.upper()
107
- full_path = f"{self._normalize_base(self._base_path)}/{self._module_name}/{clean_name}"
111
+ full_path = f"{self._normalize_base(self._base_path)}/{self._module_name}/{clean_command}"
108
112
 
109
113
  # Mount endpoint on internal route list
110
114
  self._routes.append(
111
- Route(f"/{clean_name}", endpoint=usecase_handler(factory), methods=[method_upper]),
115
+ Route(f"/{clean_command}", endpoint=usecase_handler(factory), methods=[method_upper]),
112
116
  )
113
117
 
114
118
  # Capture schemas via a dummy factory call (fail-safe)
@@ -116,15 +120,15 @@ class ModuleBuilder:
116
120
 
117
121
  # Register metadata for OpenAPI generation
118
122
  doc = UseCaseDocMeta(
119
- summary=summary or f"Use case {clean_name} in module {self._module_name}",
120
- description=description or f"Auto-generated documentation for {clean_name}",
123
+ summary=summary or f"Use case {clean_command} in module {self._module_name}",
124
+ description=description or f"Auto-generated documentation for {clean_command}",
121
125
  tags=[self._module_name],
122
126
  )
123
127
 
124
128
  api_registry.routes.append(
125
129
  UseCaseRegistration(
126
130
  module=self._module_name,
127
- name=clean_name,
131
+ command=clean_command,
128
132
  method=method_upper,
129
133
  path=full_path,
130
134
  factory=factory,
@@ -145,16 +149,12 @@ class ModuleBuilder:
145
149
  def _extract_schemas(factory: UseCaseFactory) -> dict[str, dict[str, Any]]:
146
150
  """Capture Input and Output schemas from a UseCase factory.
147
151
 
148
- Strategy (in order of preference):
149
- 1. Class-level extraction via ``to_schema()`` classmethod on BaseModel DTOs.
150
- Inspects the factory's type hints to find the Input/Output classes.
151
- 2. Fallback: instantiate via ``factory({})`` and call ``to_schema()``
152
- on the resulting UseCase's input/output (legacy pattern).
152
+ Uses class-level extraction via ``to_schema()`` classmethod on BaseModel DTOs.
153
+ Inspects the factory's type hints to find the Input/Output classes.
153
154
  """
154
155
  input_schema: dict[str, Any] = {}
155
156
  output_schema: dict[str, Any] = {}
156
157
 
157
- # --- Strategy 1: class-level extraction from type hints ---
158
158
  return_hint = _get_return_type_hint(factory)
159
159
 
160
160
  if return_hint is not None:
@@ -172,25 +172,6 @@ class ModuleBuilder:
172
172
  except Exception:
173
173
  pass
174
174
 
175
- if input_schema or output_schema:
176
- return {"input": input_schema, "output": output_schema}
177
-
178
- # --- Strategy 2: legacy factory({}) fallback ---
179
- try:
180
- instance = factory({})
181
- except Exception:
182
- return {"input": input_schema, "output": output_schema}
183
-
184
- try:
185
- input_schema = instance.input.to_schema()
186
- except Exception:
187
- pass
188
-
189
- try:
190
- output_schema = instance.output.to_schema()
191
- except Exception:
192
- pass
193
-
194
175
  return {"input": input_schema, "output": output_schema}
195
176
 
196
177
  @staticmethod
@@ -38,9 +38,9 @@ class UseCaseRegistration:
38
38
  """
39
39
 
40
40
  module: str
41
- name: str
41
+ command: str # route segment and operationId root, e.g. 'hello-world'
42
42
  method: str # uppercase: "POST" | "GET" | "PUT" | "PATCH" | "DELETE"
43
- path: str # e.g. "/api/users/create"
43
+ path: str # e.g. "/api/v1/greetings/hello-world"
44
44
  factory: UseCaseFactory
45
45
  schemas: dict[str, dict[str, Any]] = field(default_factory=dict)
46
46
  doc: UseCaseDocMeta | None = None
@@ -172,8 +172,11 @@ O = TypeVar("O", bound=Output)
172
172
  class UseCase(ABC, Generic[I, O]):
173
173
  """Contract for business logic units.
174
174
 
175
- Pure interface: all members must be provided by the implementor.
176
- No default behavior is inherited every UseCase is self-contained.
175
+ Lifecycle (handled by the framework):
176
+ 1. ``from_json(json)`` static factory, builds the use case
177
+ 2. ``validate()`` — return error string or ``None``
178
+ 3. ``execute()`` — run business logic, return Output
179
+ 4. handler serializes — ``output.to_json()`` → HTTP response
177
180
  """
178
181
 
179
182
  # Request-scoped logger injected by the framework before execute().
@@ -186,23 +189,12 @@ class UseCase(ABC, Generic[I, O]):
186
189
  """Input DTO — set in constructor."""
187
190
  ...
188
191
 
189
- @property
190
- @abstractmethod
191
- def output(self) -> O:
192
- """Output DTO — set in ``execute()``."""
193
- ...
194
-
195
192
  @abstractmethod
196
193
  def validate(self) -> str | None:
197
194
  """Validate the input. Return an error message or ``None``."""
198
195
  ...
199
196
 
200
197
  @abstractmethod
201
- async def execute(self) -> None:
202
- """Run the business logic and set ``self.output``."""
203
- ...
204
-
205
- @abstractmethod
206
- def to_json(self) -> dict[str, object]:
207
- """Serialize the output for the HTTP response body."""
198
+ async def execute(self) -> O:
199
+ """Run the business logic and return the Output."""
208
200
  ...
@@ -104,12 +104,12 @@ def usecase_handler(factory: UseCaseFactory) -> Any:
104
104
  )
105
105
 
106
106
  # 4. Execute
107
- await use_case.execute()
107
+ output = await use_case.execute()
108
108
 
109
109
  # 5. Respond
110
110
  return Response(
111
- content=json.dumps(use_case.to_json()),
112
- status_code=use_case.output.status_code,
111
+ content=json.dumps(output.to_json()),
112
+ status_code=output.status_code,
113
113
  media_type=_JSON_CONTENT_TYPE,
114
114
  )
115
115
 
@@ -89,8 +89,9 @@ def _add_route_to_spec(
89
89
  component_schemas: dict[str, Any],
90
90
  ) -> None:
91
91
  """Append a single route's path entry, schemas, and operation to the spec."""
92
- input_ref_name = f"{route.module}_{route.name}_Input"
93
- output_ref_name = f"{route.module}_{route.name}_Output"
92
+ safe_command = route.command.replace("-", "_")
93
+ input_ref_name = f"{route.module}_{safe_command}_Input"
94
+ output_ref_name = f"{route.module}_{safe_command}_Output"
94
95
 
95
96
  # Store schemas in components
96
97
  component_schemas[input_ref_name] = route.schemas.get("input", {"type": "object"})
@@ -102,7 +103,7 @@ def _add_route_to_spec(
102
103
  description = route.doc.description if route.doc else None
103
104
 
104
105
  operation: dict[str, Any] = {
105
- "operationId": f"{route.module}_{route.name}_{method_lower}",
106
+ "operationId": f"{route.module}_{safe_command}_{method_lower}",
106
107
  "tags": tags,
107
108
  "responses": {
108
109
  "200": {
@@ -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
@@ -41,26 +41,18 @@ class StrictUseCase(UseCase[StrictInput, StrictOutput]):
41
41
 
42
42
  def __init__(self, data: dict) -> None:
43
43
  self._input = StrictInput.from_json(data)
44
- self._output = StrictOutput()
45
44
 
46
45
  @property
47
46
  def input(self) -> StrictInput:
48
47
  return self._input
49
48
 
50
- @property
51
- def output(self) -> StrictOutput:
52
- return self._output
53
-
54
49
  def validate(self) -> str | None:
55
50
  if not self.input.name:
56
51
  return "name is required"
57
52
  return None
58
53
 
59
- async def execute(self) -> None:
60
- self._output = StrictOutput(greeting=f"Hi {self.input.name}, age {self.input.age}")
61
-
62
- def to_json(self) -> dict:
63
- return self._output.to_json()
54
+ async def execute(self) -> StrictOutput:
55
+ return StrictOutput(greeting=f"Hi {self.input.name}, age {self.input.age}")
64
56
 
65
57
 
66
58
  def _app():
@@ -32,24 +32,16 @@ class _PingOutput(Output):
32
32
  class _PingUseCase(UseCase[_PingInput, _PingOutput]):
33
33
  def __init__(self, inp: _PingInput) -> None:
34
34
  self._input = inp
35
- self._output = _PingOutput()
36
35
 
37
36
  @property
38
37
  def input(self) -> _PingInput:
39
38
  return self._input
40
39
 
41
- @property
42
- def output(self) -> _PingOutput:
43
- return self._output
44
-
45
40
  def validate(self) -> str | None:
46
41
  return None
47
42
 
48
- async def execute(self) -> None:
49
- self._output = _PingOutput()
50
-
51
- def to_json(self) -> dict:
52
- return self.output.to_json()
43
+ async def execute(self) -> _PingOutput:
44
+ return _PingOutput()
53
45
 
54
46
  @staticmethod
55
47
  def from_json(json: dict) -> _PingUseCase:
@@ -213,7 +205,7 @@ class TestAutoMountedEndpoints:
213
205
  response = client.get("/docs")
214
206
  assert response.status_code == 200
215
207
  assert "text/html" in response.headers["content-type"]
216
- assert "swagger-ui-dist@5" in response.text
208
+ assert "@macss/docs-ui" in response.text
217
209
  assert "Test API" in response.text
218
210
 
219
211
 
@@ -34,20 +34,11 @@ class StubUseCase(UseCase[StubInput, StubOutput]):
34
34
 
35
35
  def __init__(self, input_dto: StubInput) -> None:
36
36
  self._input = input_dto
37
- self._output = StubOutput(message="")
38
37
 
39
38
  @property
40
39
  def input(self) -> StubInput:
41
40
  return self._input
42
41
 
43
- @property
44
- def output(self) -> StubOutput:
45
- return self._output
46
-
47
- @output.setter
48
- def output(self, value: StubOutput) -> None:
49
- self._output = value
50
-
51
42
  @classmethod
52
43
  def from_json(cls, json: dict) -> StubUseCase:
53
44
  return cls(StubInput.from_json(json))
@@ -57,11 +48,8 @@ class StubUseCase(UseCase[StubInput, StubOutput]):
57
48
  return "name is required"
58
49
  return None
59
50
 
60
- async def execute(self) -> None:
61
- self.output = StubOutput(message=f"Hello, {self.input.name}!")
62
-
63
- def to_json(self) -> dict:
64
- return self.output.to_json()
51
+ async def execute(self) -> StubOutput:
52
+ return StubOutput(message=f"Hello, {self.input.name}!")
65
53
 
66
54
 
67
55
  # ── Tests ─────────────────────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- """Conformance tests: HelloInput/HelloOutput schemas match shared fixtures."""
1
+ """Conformance tests: HelloWorldInput/HelloWorldOutput schemas match shared fixtures."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -10,7 +10,7 @@ import sys
10
10
  _EXAMPLE_DIR = Path(__file__).resolve().parent.parent / "example"
11
11
  sys.path.insert(0, str(_EXAMPLE_DIR))
12
12
 
13
- from example import HelloInput, HelloOutput # type: ignore[import-untyped]
13
+ from modules.greetings.usecases.hello_world import HelloWorldInput, HelloWorldOutput # type: ignore[import-untyped]
14
14
 
15
15
  _FIXTURES = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
16
16
 
@@ -25,26 +25,26 @@ class TestSchemaConformance:
25
25
  def test_hello_input_schema_matches_fixture(self) -> None:
26
26
  fixture = _load_fixture("hello_input_schema.json")
27
27
  # to_schema() is now a classmethod — call on class, not instance
28
- assert HelloInput.to_schema() == fixture
28
+ assert HelloWorldInput.to_schema() == fixture
29
29
 
30
30
  def test_hello_output_schema_matches_fixture(self) -> None:
31
31
  fixture = _load_fixture("hello_output_schema.json")
32
- assert HelloOutput.to_schema() == fixture
32
+ assert HelloWorldOutput.to_schema() == fixture
33
33
 
34
34
  def test_hello_input_from_json(self) -> None:
35
35
  """from_json populates fields correctly."""
36
- instance = HelloInput.from_json({"name": "Carlos"})
36
+ instance = HelloWorldInput.from_json({"name": "Carlos"})
37
37
  assert instance.name == "Carlos"
38
38
 
39
39
  def test_hello_input_to_json(self) -> None:
40
40
  """to_json serializes correctly."""
41
- instance = HelloInput(name="Carlos")
41
+ instance = HelloWorldInput(name="Carlos")
42
42
  assert instance.to_json() == {"name": "Carlos"}
43
43
 
44
44
  def test_hello_output_from_json(self) -> None:
45
- instance = HelloOutput.from_json({"message": "Hello!"})
45
+ instance = HelloWorldOutput.from_json({"message": "Hello!"})
46
46
  assert instance.message == "Hello!"
47
47
 
48
48
  def test_hello_output_to_json(self) -> None:
49
- instance = HelloOutput(message="Hello!")
49
+ instance = HelloWorldOutput(message="Hello!")
50
50
  assert instance.to_json() == {"message": "Hello!"}
@@ -32,16 +32,11 @@ class SumUseCase(UseCase[SumInput, SumOutput]):
32
32
 
33
33
  def __init__(self, input_dto: SumInput) -> None:
34
34
  self._input = input_dto
35
- self._output = SumOutput(resultado=0)
36
35
 
37
36
  @property
38
37
  def input(self) -> SumInput:
39
38
  return self._input
40
39
 
41
- @property
42
- def output(self) -> SumOutput:
43
- return self._output
44
-
45
40
  def validate(self) -> str | None:
46
41
  if self._input.a is None:
47
42
  return "a is required"
@@ -49,13 +44,10 @@ class SumUseCase(UseCase[SumInput, SumOutput]):
49
44
  return "b is required"
50
45
  return None
51
46
 
52
- async def execute(self) -> None:
47
+ async def execute(self) -> SumOutput:
53
48
  a = self._input.a or 0
54
49
  b = self._input.b or 0
55
- self._output = SumOutput(resultado=a + b)
56
-
57
- def to_json(self) -> dict[str, object]:
58
- return self._output.to_json()
50
+ return SumOutput(resultado=a + b)
59
51
 
60
52
 
61
53
  # ── Input tests ─────────────────────────────────────────────────────
@@ -116,16 +108,16 @@ class TestUseCase:
116
108
  uc = SumUseCase(SumInput(a=5, b=None))
117
109
  assert uc.validate() is not None
118
110
 
119
- async def test_execute_sets_output(self) -> None:
111
+ async def test_execute_returns_output(self) -> None:
120
112
  uc = SumUseCase(SumInput(a=3, b=4))
121
113
  assert uc.validate() is None
122
- await uc.execute()
123
- assert uc.output.resultado == 7
114
+ output = await uc.execute()
115
+ assert output.resultado == 7
124
116
 
125
- async def test_to_json_returns_output_json(self) -> None:
117
+ async def test_execute_output_serializes(self) -> None:
126
118
  uc = SumUseCase(SumInput(a=10, b=20))
127
- await uc.execute()
128
- assert uc.to_json() == {"resultado": 30}
119
+ output = await uc.execute()
120
+ assert output.to_json() == {"resultado": 30}
129
121
 
130
122
  def test_logger_defaults_to_none(self) -> None:
131
123
  uc = SumUseCase(SumInput(a=1, b=2))
@@ -32,26 +32,18 @@ class GreetUseCase(UseCase[GreetInput, GreetOutput]):
32
32
 
33
33
  def __init__(self, data: dict) -> None:
34
34
  self._input = GreetInput.from_json(data)
35
- self._output = GreetOutput()
36
35
 
37
36
  @property
38
37
  def input(self) -> GreetInput:
39
38
  return self._input
40
39
 
41
- @property
42
- def output(self) -> GreetOutput:
43
- return self._output
44
-
45
40
  def validate(self) -> str | None:
46
41
  if not self._input.name:
47
42
  return "name is required"
48
43
  return None
49
44
 
50
- async def execute(self) -> None:
51
- self._output = GreetOutput(greeting=f"Hello, {self._input.name}!")
52
-
53
- def to_json(self) -> dict:
54
- return self._output.to_json()
45
+ async def execute(self) -> GreetOutput:
46
+ return GreetOutput(greeting=f"Hello, {self._input.name}!")
55
47
 
56
48
 
57
49
  class FailingUseCase(UseCase[GreetInput, GreetOutput]):
@@ -59,54 +51,38 @@ class FailingUseCase(UseCase[GreetInput, GreetOutput]):
59
51
 
60
52
  def __init__(self, data: dict) -> None:
61
53
  self._input = GreetInput.from_json(data)
62
- self._output = GreetOutput()
63
54
 
64
55
  @property
65
56
  def input(self) -> GreetInput:
66
57
  return self._input
67
58
 
68
- @property
69
- def output(self) -> GreetOutput:
70
- return self._output
71
-
72
59
  def validate(self) -> str | None:
73
60
  return None
74
61
 
75
- async def execute(self) -> None:
62
+ async def execute(self) -> GreetOutput:
76
63
  raise UseCaseException(
77
64
  status_code=409,
78
65
  message="Already exists",
79
66
  error_code="CONFLICT",
80
67
  )
81
68
 
82
- def to_json(self) -> dict:
83
- return self._output.to_json()
84
-
85
69
 
86
70
  class CrashingUseCase(UseCase[GreetInput, GreetOutput]):
87
71
  """Raises an unexpected error during execute."""
88
72
 
89
73
  def __init__(self, data: dict) -> None:
90
74
  self._input = GreetInput(name="x")
91
- self._output = GreetOutput()
92
75
 
93
76
  @property
94
77
  def input(self) -> GreetInput:
95
78
  return self._input
96
79
 
97
- @property
98
- def output(self) -> GreetOutput:
99
- return self._output
100
-
101
80
  def validate(self) -> str | None:
102
81
  return None
103
82
 
104
- async def execute(self) -> None:
83
+ async def execute(self) -> GreetOutput:
105
84
  raise RuntimeError("disk on fire")
106
85
 
107
- def to_json(self) -> dict:
108
- return self._output.to_json()
109
-
110
86
 
111
87
  # ── Helpers ───────────────────────────────────────────────────────────────
112
88
 
@@ -201,25 +177,17 @@ class TestUseCaseLifecycle:
201
177
  class SpyUseCase(UseCase[GreetInput, GreetOutput]):
202
178
  def __init__(self, data: dict) -> None:
203
179
  self._input = GreetInput(name="x")
204
- self._output = GreetOutput()
205
180
 
206
181
  @property
207
182
  def input(self) -> GreetInput:
208
183
  return self._input
209
184
 
210
- @property
211
- def output(self) -> GreetOutput:
212
- return self._output
213
-
214
185
  def validate(self) -> str | None:
215
186
  return None
216
187
 
217
- async def execute(self) -> None:
188
+ async def execute(self) -> GreetOutput:
218
189
  captured_loggers.append(self.logger)
219
- self._output = GreetOutput(greeting="ok")
220
-
221
- def to_json(self) -> dict:
222
- return self._output.to_json()
190
+ return GreetOutput(greeting="ok")
223
191
 
224
192
  # Middleware that fakes a logger in request.state
225
193
  from starlette.middleware import Middleware
@@ -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