devpablocristo-httpserver 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: devpablocristo-httpserver
3
+ Version: 0.2.0
4
+ Summary: FastAPI/Starlette helpers agnostic to product domain (alineado con los módulos técnicos HTTP de core)
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi<1,>=0.115
8
+ Requires-Dist: starlette<1,>=0.41
9
+ Provides-Extra: dev
10
+ Requires-Dist: httpx<1,>=0.27; extra == "dev"
11
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
12
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
13
+ Requires-Dist: ruff>=0.9.0; extra == "dev"
14
+ Requires-Dist: structlog>=24.0; extra == "dev"
15
+
16
+ # devpablocristo-httpserver
17
+
18
+ Primitivas HTTP reutilizables para FastAPI y Starlette publicadas como paquete versionado de `core`.
@@ -0,0 +1,3 @@
1
+ # devpablocristo-httpserver
2
+
3
+ Primitivas HTTP reutilizables para FastAPI y Starlette publicadas como paquete versionado de `core`.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "devpablocristo-httpserver"
7
+ version = "0.2.0"
8
+ description = "FastAPI/Starlette helpers agnostic to product domain (alineado con los módulos técnicos HTTP de core)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "fastapi>=0.115,<1",
13
+ "starlette>=0.41,<1",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "httpx>=0.27,<1",
19
+ "pytest>=8.3.0",
20
+ "pytest-asyncio>=0.24.0",
21
+ "ruff>=0.9.0",
22
+ "structlog>=24.0",
23
+ ]
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.ruff]
29
+ line-length = 120
30
+ target-version = "py312"
31
+
32
+ [tool.pytest.ini_options]
33
+ pythonpath = ["src"]
34
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: devpablocristo-httpserver
3
+ Version: 0.2.0
4
+ Summary: FastAPI/Starlette helpers agnostic to product domain (alineado con los módulos técnicos HTTP de core)
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi<1,>=0.115
8
+ Requires-Dist: starlette<1,>=0.41
9
+ Provides-Extra: dev
10
+ Requires-Dist: httpx<1,>=0.27; extra == "dev"
11
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
12
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
13
+ Requires-Dist: ruff>=0.9.0; extra == "dev"
14
+ Requires-Dist: structlog>=24.0; extra == "dev"
15
+
16
+ # devpablocristo-httpserver
17
+
18
+ Primitivas HTTP reutilizables para FastAPI y Starlette publicadas como paquete versionado de `core`.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/devpablocristo_httpserver.egg-info/PKG-INFO
4
+ src/devpablocristo_httpserver.egg-info/SOURCES.txt
5
+ src/devpablocristo_httpserver.egg-info/dependency_links.txt
6
+ src/devpablocristo_httpserver.egg-info/requires.txt
7
+ src/devpablocristo_httpserver.egg-info/top_level.txt
8
+ src/httpserver/__init__.py
9
+ src/httpserver/errors.py
10
+ src/httpserver/fastapi_bootstrap.py
11
+ tests/test_fastapi_bootstrap.py
@@ -0,0 +1,9 @@
1
+ fastapi<1,>=0.115
2
+ starlette<1,>=0.41
3
+
4
+ [dev]
5
+ httpx<1,>=0.27
6
+ pytest>=8.3.0
7
+ pytest-asyncio>=0.24.0
8
+ ruff>=0.9.0
9
+ structlog>=24.0
@@ -0,0 +1,16 @@
1
+ """HTTP server helpers for Python services (FastAPI/Starlette), sin dominio de IA."""
2
+
3
+ from httpserver.errors import AppError, error_payload
4
+ from httpserver.fastapi_bootstrap import (
5
+ apply_permissive_cors,
6
+ install_request_context_middleware,
7
+ register_common_exception_handlers,
8
+ )
9
+
10
+ __all__ = [
11
+ "AppError",
12
+ "apply_permissive_cors",
13
+ "error_payload",
14
+ "install_request_context_middleware",
15
+ "register_common_exception_handlers",
16
+ ]
@@ -0,0 +1,28 @@
1
+ """Tipos de error y payloads JSON para APIs HTTP (agnóstico de dominio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class AppError(Exception):
11
+ code: str
12
+ message: str
13
+ status_code: int = 400
14
+ details: dict[str, Any] = field(default_factory=dict)
15
+
16
+ def __str__(self) -> str:
17
+ return self.message
18
+
19
+
20
+ def error_payload(code: str, message: str, request_id: str, details: dict[str, Any] | None = None) -> dict[str, Any]:
21
+ return {
22
+ "error": {
23
+ "code": code,
24
+ "message": message,
25
+ "details": details or {},
26
+ "request_id": request_id,
27
+ }
28
+ }
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+ from uuid import uuid4
5
+
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from fastapi.exceptions import RequestValidationError
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from httpserver.errors import AppError, error_payload
12
+
13
+
14
+ def apply_permissive_cors(app: FastAPI, allowed_origins: list[str] | None = None) -> None:
15
+ """Aplica CORS. Si allowed_origins es None, permite todo sin credentials."""
16
+
17
+ if allowed_origins:
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=allowed_origins,
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+ else:
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_credentials=False,
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+
35
+ def install_request_context_middleware(
36
+ app: FastAPI,
37
+ bind_request_context: Callable[[str], None],
38
+ clear_request_context: Callable[[], None],
39
+ ) -> None:
40
+ @app.middleware("http")
41
+ async def request_context_middleware(request: Request, call_next): # type: ignore[no-untyped-def]
42
+ request_id = request.headers.get("X-Request-ID", f"req_{uuid4().hex[:12]}")
43
+ request.state.request_id = request_id
44
+ bind_request_context(request_id)
45
+ try:
46
+ response = await call_next(request)
47
+ finally:
48
+ clear_request_context()
49
+ response.headers["X-Request-ID"] = request_id
50
+ return response
51
+
52
+
53
+ def register_common_exception_handlers(app: FastAPI, logger: Any) -> None:
54
+ @app.exception_handler(AppError)
55
+ async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
56
+ request_id = getattr(request.state, "request_id", "")
57
+ return JSONResponse(
58
+ status_code=exc.status_code,
59
+ content=error_payload(exc.code, exc.message, request_id, exc.details),
60
+ )
61
+
62
+ @app.exception_handler(HTTPException)
63
+ async def handle_http_error(request: Request, exc: HTTPException) -> JSONResponse:
64
+ request_id = getattr(request.state, "request_id", "")
65
+ return JSONResponse(
66
+ status_code=exc.status_code,
67
+ content=error_payload("http_error", str(exc.detail), request_id),
68
+ )
69
+
70
+ @app.exception_handler(RequestValidationError)
71
+ async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
72
+ request_id = getattr(request.state, "request_id", "")
73
+ return JSONResponse(
74
+ status_code=422,
75
+ content=error_payload("validation_error", "request validation failed", request_id, {"errors": exc.errors()}),
76
+ )
77
+
78
+ @app.exception_handler(Exception)
79
+ async def handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse:
80
+ request_id = getattr(request.state, "request_id", "")
81
+ logger.exception("unhandled_exception", error=str(exc), path=request.url.path)
82
+ return JSONResponse(
83
+ status_code=500,
84
+ content=error_payload("internal_error", "internal server error", request_id),
85
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.testclient import TestClient
7
+
8
+ from httpserver.fastapi_bootstrap import (
9
+ apply_permissive_cors,
10
+ install_request_context_middleware,
11
+ register_common_exception_handlers,
12
+ )
13
+ import structlog
14
+
15
+
16
+ class FastAPIBootstrapTests(unittest.TestCase):
17
+ def test_bootstrap_helpers(self) -> None:
18
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(40))
19
+ log = structlog.get_logger("httpserver_test")
20
+
21
+ app = FastAPI()
22
+ apply_permissive_cors(app)
23
+ install_request_context_middleware(
24
+ app,
25
+ lambda _rid: None,
26
+ lambda: None,
27
+ )
28
+ register_common_exception_handlers(app, log)
29
+
30
+ @app.get("/healthz")
31
+ async def healthz() -> dict[str, str]:
32
+ return {"status": "ok"}
33
+
34
+ client = TestClient(app)
35
+ response = client.get("/healthz")
36
+ self.assertEqual(response.status_code, 200)
37
+ self.assertIn("X-Request-ID", response.headers)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()