qena-shared-lib 0.1.12__py3-none-any.whl → 0.1.13__py3-none-any.whl

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.
@@ -5,8 +5,8 @@ from . import (
5
5
  exceptions,
6
6
  http,
7
7
  logging,
8
- logstash,
9
8
  rabbitmq,
9
+ remotelogging,
10
10
  scheduler,
11
11
  security,
12
12
  utils,
@@ -19,7 +19,7 @@ __all__ = [
19
19
  "exceptions",
20
20
  "http",
21
21
  "logging",
22
- "logstash",
22
+ "remotelogging",
23
23
  "rabbitmq",
24
24
  "scheduler",
25
25
  "security",
@@ -2,17 +2,17 @@ from enum import Enum
2
2
  from typing import Any, TypeVar
3
3
 
4
4
  from fastapi import APIRouter, FastAPI
5
- from fastapi.exceptions import RequestValidationError
6
5
  from prometheus_fastapi_instrumentator import Instrumentator
7
6
  from punq import Container, Scope, empty
8
7
  from starlette.types import Lifespan
8
+ from typing_extensions import Self
9
9
 
10
10
  from .exception_handlers import (
11
- handle_general_http_exception,
12
- handle_http_service_error,
13
- handle_request_validation_error,
11
+ AbstractHttpExceptionHandler,
12
+ GeneralHttpExceptionHandler,
13
+ HTTPServiceExceptionHandler,
14
+ RequestValidationErrorHandler,
14
15
  )
15
- from .exceptions import ServiceException
16
16
  from .http import ControllerBase
17
17
 
18
18
  __all__ = [
@@ -21,6 +21,7 @@ __all__ = [
21
21
  "FastAPI",
22
22
  ]
23
23
 
24
+
24
25
  D = TypeVar("D")
25
26
 
26
27
 
@@ -46,7 +47,7 @@ class Builder:
46
47
  self._container = Container()
47
48
  self._built = False
48
49
 
49
- def with_environment(self, environment: Environment) -> "Builder":
50
+ def with_environment(self, environment: Environment) -> Self:
50
51
  match environment:
51
52
  case Environment.DEVELOPMENT:
52
53
  self._environment = Environment.DEVELOPMENT
@@ -60,35 +61,33 @@ class Builder:
60
61
 
61
62
  return self
62
63
 
63
- def with_title(self, title: str) -> "Builder":
64
+ def with_title(self, title: str) -> Self:
64
65
  self._title = title
65
66
 
66
67
  return self
67
68
 
68
- def with_description(self, description: str) -> "Builder":
69
+ def with_description(self, description: str) -> Self:
69
70
  self._description = description
70
71
 
71
72
  return self
72
73
 
73
- def with_version(self, version: str) -> "Builder":
74
+ def with_version(self, version: str) -> Self:
74
75
  self._version = version
75
76
 
76
77
  return self
77
78
 
78
- def with_lifespan(self, lifespan: Lifespan) -> "Builder":
79
+ def with_lifespan(self, lifespan: Lifespan) -> Self:
79
80
  self._lifespan = lifespan
80
81
 
81
82
  return self
82
83
 
83
- def with_controllers(
84
- self, controllers: list[type[ControllerBase]]
85
- ) -> "Builder":
84
+ def with_controllers(self, *controllers: type[ControllerBase]) -> Self:
86
85
  for index, controller in enumerate(controllers):
87
86
  if not isinstance(controller, type) or not issubclass(
88
87
  controller, ControllerBase
89
88
  ):
90
89
  raise TypeError(
91
- f"controller {index} is {type(ControllerBase)}, expected instance of type or subclass of `ControllerBase`"
90
+ f"controller {index} is {type(controller)}, expected instance of type or subclass of `ControllerBase`"
92
91
  )
93
92
 
94
93
  self._container.register(
@@ -99,7 +98,7 @@ class Builder:
99
98
 
100
99
  return self
101
100
 
102
- def with_routers(self, routers: list[APIRouter]) -> "Builder":
101
+ def with_routers(self, *routers: APIRouter) -> Self:
103
102
  if any(not isinstance(router, APIRouter) for router in routers):
104
103
  raise TypeError("some routers are not type `APIRouter`")
105
104
 
@@ -107,13 +106,41 @@ class Builder:
107
106
 
108
107
  return self
109
108
 
109
+ def with_exception_handlers(
110
+ self, *exception_handlers: type[AbstractHttpExceptionHandler]
111
+ ) -> Self:
112
+ for index, exception_handler in enumerate(exception_handlers):
113
+ if not isinstance(exception_handler, type) or not issubclass(
114
+ exception_handler, AbstractHttpExceptionHandler
115
+ ):
116
+ raise TypeError(
117
+ f"exception handler {index} is {type(exception_handler)}, expected instance of type or subclass of `AbstractHttpExceptionHandler`"
118
+ )
119
+
120
+ self._container.register(
121
+ service=AbstractHttpExceptionHandler,
122
+ factory=exception_handler,
123
+ scope=Scope.singleton,
124
+ )
125
+
126
+ return self
127
+
128
+ def with_default_exception_handlers(self) -> Self:
129
+ self.with_exception_handlers(
130
+ GeneralHttpExceptionHandler,
131
+ HTTPServiceExceptionHandler,
132
+ RequestValidationErrorHandler,
133
+ )
134
+
135
+ return self
136
+
110
137
  def with_singleton(
111
138
  self,
112
139
  service: type[D],
113
140
  factory: Any = empty,
114
141
  instance: Any = empty,
115
142
  **kwargs: Any,
116
- ) -> "Builder":
143
+ ) -> Self:
117
144
  self._container.register(
118
145
  service=service,
119
146
  factory=factory,
@@ -126,7 +153,7 @@ class Builder:
126
153
 
127
154
  def with_transient(
128
155
  self, service: type[D], factory: Any = empty, **kwargs: Any
129
- ) -> "Builder":
156
+ ) -> Self:
130
157
  self._container.register(
131
158
  service=service,
132
159
  factory=factory,
@@ -136,7 +163,7 @@ class Builder:
136
163
 
137
164
  return self
138
165
 
139
- def with_metrics(self, endpoint: str = "/metrics") -> "Builder":
166
+ def with_metrics(self, endpoint: str = "/metrics") -> Self:
140
167
  self._metrics_endpoint = endpoint
141
168
  self._instrumentator = Instrumentator()
142
169
 
@@ -158,13 +185,8 @@ class Builder:
158
185
  )
159
186
  app.state.container = self._container
160
187
 
161
- app.exception_handler(ServiceException)(handle_http_service_error)
162
- app.exception_handler(RequestValidationError)(
163
- handle_request_validation_error
164
- )
165
- app.exception_handler(Exception)(handle_general_http_exception)
166
-
167
- self._resolve_api_controllers(app)
188
+ self._register_api_controllers(app)
189
+ self._register_exception_handlers(app)
168
190
 
169
191
  if self._instrumentator is not None:
170
192
  self._instrumentator.instrument(app).expose(
@@ -177,14 +199,34 @@ class Builder:
177
199
 
178
200
  return app
179
201
 
180
- def _resolve_api_controllers(self, app: FastAPI) -> None:
181
- api_controller_routers = [
202
+ def _register_api_controllers(self, app: FastAPI) -> None:
203
+ for router in self._routers + self._resolve_api_controllers():
204
+ app.include_router(router)
205
+
206
+ def _resolve_api_controllers(self) -> list[APIRouter]:
207
+ return [
182
208
  api_controller.register_route_handlers()
183
209
  for api_controller in self._container.resolve_all(ControllerBase)
184
210
  ]
185
211
 
186
- for router in self._routers + api_controller_routers:
187
- app.include_router(router)
212
+ def _register_exception_handlers(self, app: FastAPI) -> None:
213
+ for exception_handler in self._resolve_exception_handlers():
214
+ if not callable(exception_handler):
215
+ raise ValueError(
216
+ f"exception handler {exception_handler.__class__.__name__} is not callable"
217
+ )
218
+
219
+ app.exception_handler(exception_handler.exception)(
220
+ exception_handler
221
+ )
222
+
223
+ def _resolve_exception_handlers(self) -> list[AbstractHttpExceptionHandler]:
224
+ return [
225
+ exception_handler
226
+ for exception_handler in self._container.resolve_all(
227
+ AbstractHttpExceptionHandler
228
+ )
229
+ ]
188
230
 
189
231
  @property
190
232
  def environment(self) -> Environment:
@@ -3,13 +3,14 @@ from asyncio import (
3
3
  Task,
4
4
  gather,
5
5
  )
6
+ from typing import Any
6
7
  from uuid import uuid4
7
8
 
8
9
  from prometheus_client import Enum as PrometheusEnum
9
10
  from starlette.background import BackgroundTask
10
11
 
11
12
  from .logging import LoggerProvider
12
- from .logstash import BaseLogstashSender
13
+ from .remotelogging import BaseRemoteLogSender
13
14
  from .utils import AsyncEventLoopMixin
14
15
 
15
16
  __all__ = [
@@ -27,14 +28,14 @@ class Background(AsyncEventLoopMixin):
27
28
 
28
29
  def __init__(
29
30
  self,
30
- logstash: BaseLogstashSender,
31
+ remote_logger: BaseRemoteLogSender,
31
32
  ) -> None:
32
33
  self._queue: Queue[tuple[BackgroundTask | None, str | None]] = Queue()
33
34
  self._started = False
34
35
  self._stopped = False
35
- self._logstash = logstash
36
+ self._remote_logger = remote_logger
36
37
  self._logger = LoggerProvider.default().get_logger("backgroud")
37
- self._tasks: dict[str, Task] = {}
38
+ self._tasks: dict[str, Task[Any]] = {}
38
39
 
39
40
  async def _task_manager(
40
41
  self, task: BackgroundTask, task_id: str | None = None
@@ -53,7 +54,7 @@ class Background(AsyncEventLoopMixin):
53
54
 
54
55
  await self._tasks[task_id]
55
56
  except Exception:
56
- self._logstash.error(
57
+ self._remote_logger.error(
57
58
  "exception occured when running background task {task.func.__name__} with id {task_id}"
58
59
  )
59
60
  finally:
@@ -1,12 +1,11 @@
1
1
  from collections.abc import Iterable
2
- from typing import Any
2
+ from typing import Any, cast
3
3
 
4
4
  from fastapi import Request, Response, status
5
5
  from fastapi.exceptions import RequestValidationError
6
6
  from fastapi.responses import JSONResponse
7
7
  from pydantic_core import to_jsonable_python
8
8
 
9
- from .dependencies.http import get_service
10
9
  from .exceptions import (
11
10
  HTTPServiceError,
12
11
  RabbitMQServiceException,
@@ -14,194 +13,223 @@ from .exceptions import (
14
13
  Severity,
15
14
  )
16
15
  from .logging import LoggerProvider
17
- from .logstash._base import BaseLogstashSender
16
+ from .remotelogging import BaseRemoteLogSender
18
17
 
19
18
  __all__ = [
20
- "handle_http_service_error",
21
- "handle_request_validation_error",
22
- "handle_general_http_exception",
19
+ "AbstractHttpExceptionHandler",
20
+ "GeneralHttpExceptionHandler",
21
+ "HTTPServiceExceptionHandler",
22
+ "RequestValidationErrorHandler",
23
23
  ]
24
24
 
25
25
 
26
- def handle_http_service_error(
27
- request: Request, exception: ServiceException
28
- ) -> Response:
29
- logstash = get_service(app=request.app, service_key=BaseLogstashSender)
30
- logger_provider = get_service(app=request.app, service_key=LoggerProvider)
31
- logger = logger_provider.get_logger("http.exception_handler")
32
- exception_severity = exception.severity or Severity.LOW
33
- user_agent = request.headers.get("user-agent", "__unknown__")
34
- message = exception.message
35
- tags = [
36
- "HTTP",
37
- request.method,
38
- request.url.path,
39
- exception.__class__.__name__,
40
- ]
41
- extra = {
42
- "serviceType": "HTTP",
43
- "method": request.method,
44
- "path": request.url.path,
45
- "userAgent": user_agent,
46
- "exception": exception.__class__.__name__,
47
- }
48
- exc_info = (
49
- (type(exception), exception, exception.__traceback__)
50
- if exception.extract_exc_info
51
- else None
52
- )
53
-
54
- match exception_severity:
55
- case Severity.LOW:
56
- logstash_logger_method = logstash.info
57
- logger_method = logger.info
58
- case Severity.MEDIUM:
59
- logstash_logger_method = logstash.warning
60
- logger_method = logger.warning
61
- case _:
62
- message = "something went wrong"
63
- logstash_logger_method = logstash.error
64
- logger_method = logger.error
65
-
66
- content: dict[str, Any] = {
67
- "severity": exception_severity.name,
68
- "message": message,
69
- }
70
- status_code = _status_code_from_severity(exception.severity)
71
- headers = None
72
-
73
- match exception:
74
- case HTTPServiceError() as http_service_error:
75
- if http_service_error.body is not None:
76
- extra_body = to_jsonable_python(http_service_error.body)
77
- is_updated = False
78
-
79
- try:
80
- if isinstance(extra_body, Iterable):
81
- content.update(extra_body)
82
-
83
- is_updated = True
84
- except:
85
- pass
86
-
87
- if not is_updated:
88
- content["data"] = extra_body
89
-
90
- if http_service_error.response_code is not None:
91
- content["code"] = http_service_error.response_code
92
- str_response_code = str(http_service_error.response_code)
93
- extra["responseCode"] = str_response_code
94
-
95
- tags.append(str_response_code)
96
-
97
- if http_service_error.corrective_action is not None:
98
- content["correctiveAction"] = (
99
- http_service_error.corrective_action
100
- )
101
-
102
- if http_service_error.status_code is not None:
103
- status_code = http_service_error.status_code
104
- str_status_code = str(status_code)
105
- extra["statusCode"] = str_status_code
106
-
107
- tags.append(str_status_code)
108
-
109
- if http_service_error.headers is not None:
110
- headers = http_service_error.headers
111
- case RabbitMQServiceException() as rabbitmq_service_exception:
112
- str_error_code = str(rabbitmq_service_exception.code)
113
- extra["code"] = str_error_code
114
-
115
- tags.append(str_error_code)
116
-
117
- if exception.tags:
118
- tags.extend(exception.tags)
119
-
120
- if exception.extra:
121
- extra.update(exception.extra)
122
-
123
- if exception.logstash_logging:
124
- logstash_logger_method(
125
- message=exception.message,
126
- tags=tags,
127
- extra=extra,
128
- exception=exception if exception.extract_exc_info else None,
129
- )
130
- else:
131
- logger_method(
132
- "\n%s %s\n%s",
133
- request.method,
134
- request.url.path,
135
- exception.message,
136
- exc_info=exc_info,
137
- )
26
+ class AbstractHttpExceptionHandler:
27
+ @property
28
+ def exception(self) -> type[Exception]:
29
+ raise NotImplementedError()
138
30
 
139
- return JSONResponse(
140
- content=content,
141
- status_code=status_code,
142
- headers=headers,
143
- )
144
31
 
32
+ class HTTPServiceExceptionHandler(AbstractHttpExceptionHandler):
33
+ @property
34
+ def exception(self) -> type[Exception]:
35
+ return cast(type[Exception], ServiceException)
145
36
 
146
- def handle_request_validation_error(
147
- request: Request, error: RequestValidationError
148
- ) -> Response:
149
- logger_provider = get_service(app=request.app, service_key=LoggerProvider)
150
- logger = logger_provider.get_logger("http.exception_handler")
151
- message = "invalid request data"
152
-
153
- logger.warning("\n%s %s\n%s", request.method, request.url.path, message)
154
-
155
- return JSONResponse(
156
- content={
157
- "severity": Severity.MEDIUM.name,
158
- "message": message,
159
- "code": 100,
160
- "detail": to_jsonable_python(error.errors()),
161
- },
162
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
163
- )
164
-
165
-
166
- def handle_general_http_exception(
167
- request: Request, exception: Exception
168
- ) -> Response:
169
- logstash = get_service(app=request.app, service_key=BaseLogstashSender)
170
- user_agent = request.get("user-agent", "__unknown__")
171
-
172
- logstash.error(
173
- message=f"something went wrong on endpoint `{request.method} {request.url.path}`",
174
- tags=[
37
+ def __init__(
38
+ self,
39
+ remote_logger: BaseRemoteLogSender,
40
+ logger_provider: LoggerProvider,
41
+ ):
42
+ self._remote_logger = remote_logger
43
+ self._logger = logger_provider.get_logger("http.exception_handler")
44
+
45
+ def __call__(
46
+ self, request: Request, exception: ServiceException
47
+ ) -> Response:
48
+ exception_severity = exception.severity or Severity.LOW
49
+ user_agent = request.headers.get("user-agent", "__unknown__")
50
+ message = exception.message
51
+ tags = [
175
52
  "HTTP",
176
53
  request.method,
177
54
  request.url.path,
178
55
  exception.__class__.__name__,
179
- ],
180
- extra={
56
+ ]
57
+ extra = {
181
58
  "serviceType": "HTTP",
182
59
  "method": request.method,
183
60
  "path": request.url.path,
184
61
  "userAgent": user_agent,
185
62
  "exception": exception.__class__.__name__,
186
- },
187
- exception=exception,
188
- )
189
-
190
- return JSONResponse(
191
- content={
192
- "severity": Severity.HIGH.name,
193
- "message": "something went wrong",
194
- },
195
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
196
- )
197
-
198
-
199
- def _status_code_from_severity(severity: Severity | None) -> int:
200
- if (
201
- severity is None
202
- or severity == Severity.LOW
203
- or severity == Severity.MEDIUM
204
- ):
205
- return status.HTTP_400_BAD_REQUEST
63
+ }
64
+ exc_info = (
65
+ (type(exception), exception, exception.__traceback__)
66
+ if exception.extract_exc_info
67
+ else None
68
+ )
69
+
70
+ match exception_severity:
71
+ case Severity.LOW:
72
+ remote_logger_method = self._remote_logger.info
73
+ logger_method = self._logger.info
74
+ case Severity.MEDIUM:
75
+ remote_logger_method = self._remote_logger.warning
76
+ logger_method = self._logger.warning
77
+ case _:
78
+ message = "something went wrong"
79
+ remote_logger_method = self._remote_logger.error
80
+ logger_method = self._logger.error
81
+
82
+ content: dict[str, Any] = {
83
+ "severity": exception_severity.name,
84
+ "message": message,
85
+ }
86
+ status_code = self._status_code_from_severity(exception.severity)
87
+ headers = None
88
+
89
+ match exception:
90
+ case HTTPServiceError() as http_service_error:
91
+ if http_service_error.body is not None:
92
+ extra_body = to_jsonable_python(http_service_error.body)
93
+ is_updated = False
94
+
95
+ try:
96
+ if isinstance(extra_body, Iterable):
97
+ content.update(extra_body)
98
+
99
+ is_updated = True
100
+ except:
101
+ pass
102
+
103
+ if not is_updated:
104
+ content["data"] = extra_body
105
+
106
+ if http_service_error.response_code is not None:
107
+ content["code"] = http_service_error.response_code
108
+ str_response_code = str(http_service_error.response_code)
109
+ extra["responseCode"] = str_response_code
110
+
111
+ tags.append(str_response_code)
112
+
113
+ if http_service_error.corrective_action is not None:
114
+ content["correctiveAction"] = (
115
+ http_service_error.corrective_action
116
+ )
117
+
118
+ if http_service_error.status_code is not None:
119
+ status_code = http_service_error.status_code
120
+ str_status_code = str(status_code)
121
+ extra["statusCode"] = str_status_code
122
+
123
+ tags.append(str_status_code)
124
+
125
+ if http_service_error.headers is not None:
126
+ headers = http_service_error.headers
127
+ case RabbitMQServiceException() as rabbitmq_service_exception:
128
+ str_error_code = str(rabbitmq_service_exception.code)
129
+ extra["code"] = str_error_code
130
+
131
+ tags.append(str_error_code)
132
+
133
+ if exception.tags:
134
+ tags.extend(exception.tags)
135
+
136
+ if exception.extra:
137
+ extra.update(exception.extra)
138
+
139
+ if exception.remote_logging:
140
+ remote_logger_method(
141
+ message=exception.message,
142
+ tags=tags,
143
+ extra=extra,
144
+ exception=exception if exception.extract_exc_info else None,
145
+ )
146
+ else:
147
+ logger_method(
148
+ "\n%s %s\n%s",
149
+ request.method,
150
+ request.url.path,
151
+ exception.message,
152
+ exc_info=exc_info,
153
+ )
154
+
155
+ return JSONResponse(
156
+ content=content,
157
+ status_code=status_code,
158
+ headers=headers,
159
+ )
160
+
161
+ def _status_code_from_severity(self, severity: Severity | None) -> int:
162
+ if (
163
+ severity is None
164
+ or severity is Severity.LOW
165
+ or severity is Severity.MEDIUM
166
+ ):
167
+ return cast(int, status.HTTP_400_BAD_REQUEST)
206
168
 
207
- return status.HTTP_500_INTERNAL_SERVER_ERROR
169
+ return cast(int, status.HTTP_500_INTERNAL_SERVER_ERROR)
170
+
171
+
172
+ class RequestValidationErrorHandler(AbstractHttpExceptionHandler):
173
+ @property
174
+ def exception(self) -> type[Exception]:
175
+ return cast(type[Exception], RequestValidationError)
176
+
177
+ def __init__(self, logger_provider: LoggerProvider):
178
+ self._logger = logger_provider.get_logger("http.exception_handler")
179
+
180
+ def __call__(
181
+ self, request: Request, error: RequestValidationError
182
+ ) -> Response:
183
+ message = "invalid request data"
184
+
185
+ self._logger.warning(
186
+ "\n%s %s\n%s", request.method, request.url.path, message
187
+ )
188
+
189
+ return JSONResponse(
190
+ content={
191
+ "severity": Severity.MEDIUM.name,
192
+ "message": message,
193
+ "code": 100,
194
+ "detail": to_jsonable_python(error.errors()),
195
+ },
196
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
197
+ )
198
+
199
+
200
+ class GeneralHttpExceptionHandler(AbstractHttpExceptionHandler):
201
+ @property
202
+ def exception(self) -> type[Exception]:
203
+ return Exception
204
+
205
+ def __init__(self, remote_logger: BaseRemoteLogSender):
206
+ self._remote_logger = remote_logger
207
+
208
+ def __call__(self, request: Request, exception: Exception) -> Response:
209
+ user_agent = request.get("user-agent", "__unknown__")
210
+
211
+ self._remote_logger.error(
212
+ message=f"something went wrong on endpoint `{request.method} {request.url.path}`",
213
+ tags=[
214
+ "HTTP",
215
+ request.method,
216
+ request.url.path,
217
+ exception.__class__.__name__,
218
+ ],
219
+ extra={
220
+ "serviceType": "HTTP",
221
+ "method": request.method,
222
+ "path": request.url.path,
223
+ "userAgent": user_agent,
224
+ "exception": exception.__class__.__name__,
225
+ },
226
+ exception=exception,
227
+ )
228
+
229
+ return JSONResponse(
230
+ content={
231
+ "severity": Severity.HIGH.name,
232
+ "message": "something went wrong",
233
+ },
234
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
235
+ )