fastapi-factory-utilities 0.1.0__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.
Potentially problematic release.
This version of fastapi-factory-utilities might be problematic. Click here for more details.
- fastapi_factory_utilities/__main__.py +6 -0
- fastapi_factory_utilities/core/__init__.py +1 -0
- fastapi_factory_utilities/core/api/__init__.py +25 -0
- fastapi_factory_utilities/core/api/tags.py +9 -0
- fastapi_factory_utilities/core/api/v1/sys/__init__.py +12 -0
- fastapi_factory_utilities/core/api/v1/sys/health.py +53 -0
- fastapi_factory_utilities/core/api/v1/sys/readiness.py +53 -0
- fastapi_factory_utilities/core/app/__init__.py +19 -0
- fastapi_factory_utilities/core/app/base/__init__.py +17 -0
- fastapi_factory_utilities/core/app/base/application.py +123 -0
- fastapi_factory_utilities/core/app/base/config_abstract.py +78 -0
- fastapi_factory_utilities/core/app/base/exceptions.py +25 -0
- fastapi_factory_utilities/core/app/base/fastapi_application_abstract.py +88 -0
- fastapi_factory_utilities/core/app/base/plugins_manager_abstract.py +136 -0
- fastapi_factory_utilities/core/app/enums.py +11 -0
- fastapi_factory_utilities/core/plugins/__init__.py +15 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/__init__.py +97 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/builder.py +239 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/configs.py +17 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/documents.py +31 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/exceptions.py +25 -0
- fastapi_factory_utilities/core/plugins/odm_plugin/repositories.py +172 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/__init__.py +124 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/builder.py +266 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/configs.py +103 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/exceptions.py +13 -0
- fastapi_factory_utilities/core/plugins/opentelemetry_plugin/helpers.py +42 -0
- fastapi_factory_utilities/core/protocols.py +82 -0
- fastapi_factory_utilities/core/utils/configs.py +80 -0
- fastapi_factory_utilities/core/utils/importlib.py +28 -0
- fastapi_factory_utilities/core/utils/log.py +178 -0
- fastapi_factory_utilities/core/utils/uvicorn.py +45 -0
- fastapi_factory_utilities/core/utils/yaml_reader.py +166 -0
- fastapi_factory_utilities/example/__init__.py +11 -0
- fastapi_factory_utilities/example/__main__.py +6 -0
- fastapi_factory_utilities/example/api/__init__.py +19 -0
- fastapi_factory_utilities/example/api/books/__init__.py +5 -0
- fastapi_factory_utilities/example/api/books/responses.py +26 -0
- fastapi_factory_utilities/example/api/books/routes.py +62 -0
- fastapi_factory_utilities/example/app/__init__.py +6 -0
- fastapi_factory_utilities/example/app/app.py +37 -0
- fastapi_factory_utilities/example/app/config.py +12 -0
- fastapi_factory_utilities/example/application.yaml +26 -0
- fastapi_factory_utilities/example/entities/books/__init__.py +7 -0
- fastapi_factory_utilities/example/entities/books/entities.py +16 -0
- fastapi_factory_utilities/example/entities/books/enums.py +16 -0
- fastapi_factory_utilities/example/entities/books/types.py +54 -0
- fastapi_factory_utilities/example/models/__init__.py +1 -0
- fastapi_factory_utilities/example/models/books/__init__.py +6 -0
- fastapi_factory_utilities/example/models/books/document.py +20 -0
- fastapi_factory_utilities/example/models/books/repository.py +11 -0
- fastapi_factory_utilities/example/services/books/__init__.py +5 -0
- fastapi_factory_utilities/example/services/books/services.py +167 -0
- fastapi_factory_utilities-0.1.0.dist-info/LICENSE +21 -0
- fastapi_factory_utilities-0.1.0.dist-info/METADATA +131 -0
- fastapi_factory_utilities-0.1.0.dist-info/RECORD +58 -0
- fastapi_factory_utilities-0.1.0.dist-info/WHEEL +4 -0
- fastapi_factory_utilities-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Provides utilities to handle configurations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from fastapi_factory_utilities.core.utils.importlib import get_path_file_in_package
|
|
8
|
+
from fastapi_factory_utilities.core.utils.yaml_reader import (
|
|
9
|
+
UnableToReadYamlFileError,
|
|
10
|
+
YamlFileReader,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
GenericConfigBaseModelType = TypeVar("GenericConfigBaseModelType", bound=BaseModel) # pylint: disable=invalid-name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigBaseException(BaseException):
|
|
17
|
+
"""Base exception for all the configuration exceptions."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnableToReadConfigFileError(ConfigBaseException):
|
|
23
|
+
"""Exception raised when the configuration file cannot be read.
|
|
24
|
+
|
|
25
|
+
Mainly used when the file is not found or the file is not a YAML file.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ValueErrorConfigError(ConfigBaseException):
|
|
32
|
+
"""Exception raised when the configuration object cannot be created.
|
|
33
|
+
|
|
34
|
+
Mainly used when validation fails when creating the configuration object.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_config_from_file_in_package(
|
|
41
|
+
package_name: str,
|
|
42
|
+
filename: str,
|
|
43
|
+
config_class: type[GenericConfigBaseModelType],
|
|
44
|
+
yaml_base_key: str,
|
|
45
|
+
) -> GenericConfigBaseModelType:
|
|
46
|
+
"""Build a configuration object from a file in a package.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
package_name (str): The package name.
|
|
50
|
+
filename (str): The filename.
|
|
51
|
+
config_class (type[GenericConfigBaseModelType]): The configuration class.
|
|
52
|
+
yaml_base_key (str): The base key in the YAML file.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
GenericConfigBaseModelType: The configuration object.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
UnableToReadConfigFileError: If the configuration file cannot be read.
|
|
59
|
+
ValueErrorConfigError: If the configuration file is invalid.
|
|
60
|
+
"""
|
|
61
|
+
# Read the application configuration file
|
|
62
|
+
try:
|
|
63
|
+
yaml_file_content: dict[str, Any] = YamlFileReader(
|
|
64
|
+
file_path=get_path_file_in_package(
|
|
65
|
+
filename=filename,
|
|
66
|
+
package=package_name,
|
|
67
|
+
),
|
|
68
|
+
yaml_base_key=yaml_base_key,
|
|
69
|
+
use_environment_injection=True,
|
|
70
|
+
).read()
|
|
71
|
+
except (FileNotFoundError, ImportError, UnableToReadYamlFileError) as exception:
|
|
72
|
+
raise UnableToReadConfigFileError("Unable to read the application configuration file.") from exception
|
|
73
|
+
|
|
74
|
+
# Create the application configuration model
|
|
75
|
+
try:
|
|
76
|
+
config: GenericConfigBaseModelType = config_class(**yaml_file_content)
|
|
77
|
+
except ValueError as exception:
|
|
78
|
+
raise ValueErrorConfigError("Unable to create the configuration model.") from exception
|
|
79
|
+
|
|
80
|
+
return config
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Provide importlib functions."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_path_file_in_package(filename: str, package: str) -> Path:
|
|
8
|
+
"""Return Absolute Path of file in package.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
filename (str): Filename to search
|
|
12
|
+
package (str): Package name
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Traversable: File
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
FileNotFoundError: If file not found
|
|
19
|
+
ImportError: If package not found
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
path: Path = Path(str(files(package).joinpath(filename)))
|
|
24
|
+
except FileNotFoundError as exception:
|
|
25
|
+
raise FileNotFoundError(f"File {filename} not found in package {package}") from exception
|
|
26
|
+
except ImportError as exception:
|
|
27
|
+
raise ImportError(f"Package {package} not found") from exception
|
|
28
|
+
return path
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Provides a function to setup the logging configuration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
from pydantic import BaseModel, BeforeValidator
|
|
10
|
+
from structlog.types import EventDict
|
|
11
|
+
|
|
12
|
+
_logger = structlog.getLogger(__package__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ensure_logging_level(level: Any) -> int:
|
|
16
|
+
"""Ensure the logging level.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
level (Any): The logging level.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
int: The logging level.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(level, int):
|
|
25
|
+
return level
|
|
26
|
+
|
|
27
|
+
if isinstance(level, str):
|
|
28
|
+
try:
|
|
29
|
+
return getattr(logging, str(level).upper())
|
|
30
|
+
except AttributeError as exception:
|
|
31
|
+
raise ValueError(f"Invalid logging level: {level}") from exception
|
|
32
|
+
|
|
33
|
+
raise ValueError(f"Invalid logging level: {level}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LoggingConfig(BaseModel):
|
|
37
|
+
"""Logging configuration."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
level: Annotated[int, BeforeValidator(ensure_logging_level)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LogModeEnum(StrEnum):
|
|
44
|
+
"""Defines the possible logging modes."""
|
|
45
|
+
|
|
46
|
+
CONSOLE = auto()
|
|
47
|
+
JSON = auto()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# https://github.com/hynek/structlog/issues/35#issuecomment-591321744
|
|
51
|
+
def _rename_event_key(_: Any, __: Any, event_dict: EventDict) -> EventDict: # pylint: disable=invalid-name
|
|
52
|
+
"""Renames the `event` key to `message` in the event dictionary.
|
|
53
|
+
|
|
54
|
+
Log entries keep the text message in the `event` field, but Datadog
|
|
55
|
+
uses the `message` field. This processor moves the value from one field to
|
|
56
|
+
the other.
|
|
57
|
+
See https://github.com/hynek/structlog/issues/35#issuecomment-591321744
|
|
58
|
+
"""
|
|
59
|
+
event_dict["message"] = event_dict.pop("event")
|
|
60
|
+
return event_dict
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def clean_uvicorn_logger() -> None:
|
|
64
|
+
"""Cleans the uvicorn loggers."""
|
|
65
|
+
for logger_name in ["uvicorn", "uvicorn.error", "uvicorn.access"]:
|
|
66
|
+
# Clear the log handlers for uvicorn loggers, and enable propagation
|
|
67
|
+
# so the messages are caught by our root logger and formatted correctly
|
|
68
|
+
# by structlog
|
|
69
|
+
logging.getLogger(logger_name).handlers.clear()
|
|
70
|
+
logging.getLogger(logger_name).propagate = True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _drop_color_message_key(_: Any, __: Any, event_dict: EventDict) -> EventDict: # pylint: disable=invalid-name
|
|
74
|
+
"""Cleans the `color_message` key from the event dictionary.
|
|
75
|
+
|
|
76
|
+
Uvicorn logs the message a second time in the extra `color_message`, but we don't
|
|
77
|
+
need it. This processor drops the key from the event dict if it exists.
|
|
78
|
+
"""
|
|
79
|
+
event_dict.pop("color_message", None)
|
|
80
|
+
return event_dict
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def setup_log(
|
|
84
|
+
mode: LogModeEnum = LogModeEnum.CONSOLE, log_level: str = "DEBUG", logging_config: list[LoggingConfig] | None = None
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Prepares the logging configuration.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
mode (LogMode): The logging mode to use.
|
|
90
|
+
log_level (str): The log level to use.
|
|
91
|
+
logging_config (List[LoggingConfig], optional): The logging configuration. Defaults to None.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
None
|
|
95
|
+
"""
|
|
96
|
+
processors: list[structlog.typing.Processor] = [
|
|
97
|
+
structlog.stdlib.add_logger_name,
|
|
98
|
+
structlog.stdlib.add_log_level,
|
|
99
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
100
|
+
structlog.processors.UnicodeDecoder(),
|
|
101
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
102
|
+
structlog.stdlib.ExtraAdder(),
|
|
103
|
+
structlog.processors.CallsiteParameterAdder(
|
|
104
|
+
parameters={
|
|
105
|
+
structlog.processors.CallsiteParameter.MODULE: True,
|
|
106
|
+
structlog.processors.CallsiteParameter.FUNC_NAME: True,
|
|
107
|
+
structlog.processors.CallsiteParameter.LINENO: True,
|
|
108
|
+
}
|
|
109
|
+
),
|
|
110
|
+
_drop_color_message_key,
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
log_renderer: structlog.dev.ConsoleRenderer | structlog.processors.JSONRenderer
|
|
114
|
+
match mode:
|
|
115
|
+
case LogModeEnum.CONSOLE:
|
|
116
|
+
log_renderer = structlog.dev.ConsoleRenderer(
|
|
117
|
+
exception_formatter=structlog.dev.RichTracebackFormatter(),
|
|
118
|
+
)
|
|
119
|
+
case LogModeEnum.JSON:
|
|
120
|
+
# We rename the `event` key to `message` only in JSON logs,
|
|
121
|
+
# as Datadog looks for the
|
|
122
|
+
# `message` key but the pretty ConsoleRenderer looks for `event`
|
|
123
|
+
processors.append(_rename_event_key)
|
|
124
|
+
# Format the exception only for JSON logs, as we want
|
|
125
|
+
# to pretty-print them when
|
|
126
|
+
# using the ConsoleRenderer
|
|
127
|
+
processors.append(
|
|
128
|
+
structlog.processors.dict_tracebacks,
|
|
129
|
+
)
|
|
130
|
+
log_renderer = structlog.processors.JSONRenderer()
|
|
131
|
+
|
|
132
|
+
# Remove all existing loggers
|
|
133
|
+
structlog.reset_defaults()
|
|
134
|
+
structlog_processors: list[structlog.typing.Processor] = processors.copy()
|
|
135
|
+
structlog_processors.append(structlog.stdlib.ProcessorFormatter.wrap_for_formatter)
|
|
136
|
+
structlog.configure(
|
|
137
|
+
processors=structlog_processors,
|
|
138
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
139
|
+
cache_logger_on_first_use=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
143
|
+
# These run ONLY on `logging` entries that do NOT originate within
|
|
144
|
+
# structlog.
|
|
145
|
+
foreign_pre_chain=processors,
|
|
146
|
+
# These run on ALL entries after the pre_chain is done.
|
|
147
|
+
processors=[
|
|
148
|
+
# Remove _record & _from_structlog.
|
|
149
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
150
|
+
log_renderer,
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
handler = logging.StreamHandler()
|
|
155
|
+
# Use OUR `ProcessorFormatter` to format all `logging` entries.
|
|
156
|
+
handler.setFormatter(formatter)
|
|
157
|
+
root_logger: logging.Logger = logging.getLogger()
|
|
158
|
+
root_logger.addHandler(handler)
|
|
159
|
+
root_logger.setLevel(log_level.upper())
|
|
160
|
+
|
|
161
|
+
for logging_config_item in logging_config or []:
|
|
162
|
+
logger = logging.getLogger(logging_config_item.name)
|
|
163
|
+
logger.setLevel(logging_config_item.level)
|
|
164
|
+
|
|
165
|
+
def handle_exception(exc_type: Any, exc_value: Any, exc_traceback: Any) -> None:
|
|
166
|
+
"""Handle uncaught exceptions.
|
|
167
|
+
|
|
168
|
+
Log any uncaught exception instead of letting it be printed by Python
|
|
169
|
+
(but leave KeyboardInterrupt untouched to allow users to Ctrl+C to stop)
|
|
170
|
+
See https://stackoverflow.com/a/16993115/3641865
|
|
171
|
+
"""
|
|
172
|
+
if issubclass(exc_type, KeyboardInterrupt):
|
|
173
|
+
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
_logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
|
|
177
|
+
|
|
178
|
+
sys.excepthook = handle_exception
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Provides utilities for the application."""
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
import uvicorn.server
|
|
5
|
+
|
|
6
|
+
from fastapi_factory_utilities.core.protocols import BaseApplicationProtocol
|
|
7
|
+
from fastapi_factory_utilities.core.utils.log import clean_uvicorn_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UvicornUtils:
|
|
11
|
+
"""Provides utilities for Uvicorn."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, app: BaseApplicationProtocol) -> None:
|
|
14
|
+
"""Instantiate the factory.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
app (BaseApplication): The application.
|
|
18
|
+
config (AppConfigAbstract): The application configuration.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
None
|
|
22
|
+
"""
|
|
23
|
+
self._app: BaseApplicationProtocol = app
|
|
24
|
+
|
|
25
|
+
def build_uvicorn_config(self) -> uvicorn.Config:
|
|
26
|
+
"""Build the Uvicorn configuration.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
uvicorn.Config: The Uvicorn configuration.
|
|
30
|
+
"""
|
|
31
|
+
config = uvicorn.Config(
|
|
32
|
+
app=self._app.get_asgi_app(),
|
|
33
|
+
host=self._app.get_config().host,
|
|
34
|
+
port=self._app.get_config().port,
|
|
35
|
+
reload=self._app.get_config().reload,
|
|
36
|
+
workers=self._app.get_config().workers,
|
|
37
|
+
)
|
|
38
|
+
clean_uvicorn_logger()
|
|
39
|
+
return config
|
|
40
|
+
|
|
41
|
+
def serve(self) -> None:
|
|
42
|
+
"""Serve the application."""
|
|
43
|
+
config: uvicorn.Config = self.build_uvicorn_config()
|
|
44
|
+
server: uvicorn.Server = uvicorn.Server(config=config)
|
|
45
|
+
server.run()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Provides a class for reading YAML files and converting them to Pydantic models."""
|
|
2
|
+
|
|
3
|
+
# mypy: disable-error-code="unused-ignore"
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from structlog.stdlib import BoundLogger, get_logger
|
|
11
|
+
from yaml import SafeLoader
|
|
12
|
+
|
|
13
|
+
logger: BoundLogger = get_logger()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnableToReadYamlFileError(Exception):
|
|
17
|
+
"""Raised when there is an error reading a YAML file."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, file_path: Path | None = None, message: str = "") -> None:
|
|
20
|
+
"""Initializes the exception.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
file_path (str): The path to the YAML file.
|
|
24
|
+
message (str): The error message.
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(f"Error reading YAML file: {file_path} - {message}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class YamlFileReader:
|
|
30
|
+
"""Handles reading YAML files and converting them to Pydantic models."""
|
|
31
|
+
|
|
32
|
+
re_pattern: re.Pattern[str] = re.compile(r"\${([A-Za-z0-9\-\_]+):?([A-Za-z0-9\-\_\/\:]*)?}")
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
file_path: Path,
|
|
37
|
+
yaml_base_key: str | None = None,
|
|
38
|
+
use_environment_injection: bool = True,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initializes the YAML file reader.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
file_path (str): The path to the YAML file.
|
|
44
|
+
yaml_base_key (str | None, optional): The base key
|
|
45
|
+
in the YAML file to read from. Defaults to None.
|
|
46
|
+
use_environment_injection (bool, optional): Whether to use
|
|
47
|
+
environment injection. Defaults to True.
|
|
48
|
+
"""
|
|
49
|
+
# Store the file path and base key for YAML reading
|
|
50
|
+
self._yaml_base_key: str | None = yaml_base_key
|
|
51
|
+
self._file_path: Path = file_path
|
|
52
|
+
|
|
53
|
+
# Store whether to use environment injection
|
|
54
|
+
self._use_environment_injection: bool = use_environment_injection
|
|
55
|
+
|
|
56
|
+
def _filter_data_with_base_key(self, yaml_data: dict[str, Any]) -> dict[str, Any] | None:
|
|
57
|
+
"""Extracts the data from the YAML file with the base key.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
yaml_data (dict): The data from the YAML file.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
dict: The filtered data from the YAML file.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
KeyError: If the base key is not found in the YAML file.
|
|
67
|
+
"""
|
|
68
|
+
if self._yaml_base_key is not None:
|
|
69
|
+
keys: list[str] = self._yaml_base_key.split(".")
|
|
70
|
+
while len(keys) != 0:
|
|
71
|
+
key: str = keys.pop(0)
|
|
72
|
+
try:
|
|
73
|
+
yaml_data = yaml_data[key]
|
|
74
|
+
except KeyError:
|
|
75
|
+
logger.warning(f"Base key {key}" " not found in YAML file" + " from {self._yaml_base_key}")
|
|
76
|
+
return dict()
|
|
77
|
+
return yaml_data
|
|
78
|
+
|
|
79
|
+
def _read_yaml_file(self, file_path: Path) -> dict[str, Any]:
|
|
80
|
+
"""Reads the YAML file and returns the data as a dictionary.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
file_path (Path): The path to the YAML file.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
dict: The data from the YAML file.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If there is an error reading the file.
|
|
90
|
+
FileNotFoundError: If the file is not found.
|
|
91
|
+
"""
|
|
92
|
+
if not os.path.exists(file_path):
|
|
93
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
94
|
+
|
|
95
|
+
with open(file=file_path, encoding="UTF-8") as file:
|
|
96
|
+
loader = SafeLoader(file)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
yaml_data: dict[str, Any] = cast(
|
|
100
|
+
dict[str, Any],
|
|
101
|
+
loader.get_data(), # type: ignore
|
|
102
|
+
)
|
|
103
|
+
except Exception as exception:
|
|
104
|
+
raise ValueError(f"Error reading YAML file: {file_path}") from exception
|
|
105
|
+
|
|
106
|
+
return yaml_data
|
|
107
|
+
|
|
108
|
+
def _inject_environment_variables(
|
|
109
|
+
self, yaml_data: dict[str, Any] | str | list[str] | bool | int
|
|
110
|
+
) -> dict[str, Any] | str | list[str] | bool | int:
|
|
111
|
+
"""Injects environment variables into the YAML data recursively.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
yaml_data (dict | str | list | bool): The data from the YAML file.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
dict: The data from the YAML file
|
|
118
|
+
with environment variables injected.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If the YAML data is None.
|
|
122
|
+
"""
|
|
123
|
+
if isinstance(yaml_data, dict):
|
|
124
|
+
for key, value in yaml_data.items():
|
|
125
|
+
yaml_data[key] = self._inject_environment_variables(value)
|
|
126
|
+
elif isinstance(yaml_data, list):
|
|
127
|
+
yaml_data = [cast(str, self._inject_environment_variables(yaml_data=value)) for value in yaml_data]
|
|
128
|
+
elif isinstance(yaml_data, bool) or isinstance(yaml_data, int):
|
|
129
|
+
return yaml_data
|
|
130
|
+
elif isinstance(yaml_data, str): # type: ignore
|
|
131
|
+
while True:
|
|
132
|
+
match = self.re_pattern.search(yaml_data)
|
|
133
|
+
if match is None:
|
|
134
|
+
break
|
|
135
|
+
env_key = match.group(1)
|
|
136
|
+
env_default = match.group(2)
|
|
137
|
+
env_value = os.getenv(env_key, env_default)
|
|
138
|
+
yaml_data = yaml_data.replace(match.group(0), env_value)
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError(f"Type not supported: {type(yaml_data)}")
|
|
141
|
+
return yaml_data
|
|
142
|
+
|
|
143
|
+
def read(self) -> dict[str, Any]:
|
|
144
|
+
"""Reads the YAML file and converts it to a Pydantic model with env injected.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
UnableToReadYamlFileError: If there is an error reading the file.
|
|
148
|
+
"""
|
|
149
|
+
# Read the YAML file and filter the data with the base key
|
|
150
|
+
try:
|
|
151
|
+
yaml_data: dict[str, Any] | None = self._filter_data_with_base_key(
|
|
152
|
+
self._read_yaml_file(file_path=self._file_path)
|
|
153
|
+
)
|
|
154
|
+
except (FileNotFoundError, ValueError, KeyError) as exception:
|
|
155
|
+
raise UnableToReadYamlFileError(file_path=self._file_path, message=str(exception)) from exception
|
|
156
|
+
|
|
157
|
+
if yaml_data is None:
|
|
158
|
+
return dict()
|
|
159
|
+
|
|
160
|
+
if self._use_environment_injection:
|
|
161
|
+
yaml_data_with_env_injected: dict[str, Any] = cast(
|
|
162
|
+
dict[str, Any], self._inject_environment_variables(yaml_data)
|
|
163
|
+
)
|
|
164
|
+
return dict[str, Any](yaml_data_with_env_injected)
|
|
165
|
+
else:
|
|
166
|
+
return dict[str, Any](yaml_data)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Provide the API for the example package."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from .books import api_v1_books_router, api_v2_books_router
|
|
6
|
+
|
|
7
|
+
api_router: APIRouter = APIRouter(prefix="/api")
|
|
8
|
+
|
|
9
|
+
# -- API v1
|
|
10
|
+
api_router_v1: APIRouter = APIRouter(prefix="/v1")
|
|
11
|
+
api_router_v1.include_router(router=api_v1_books_router)
|
|
12
|
+
|
|
13
|
+
# -- API v2
|
|
14
|
+
api_router_v2: APIRouter = APIRouter(prefix="/v2")
|
|
15
|
+
api_router_v2.include_router(router=api_v2_books_router)
|
|
16
|
+
|
|
17
|
+
# -- Include API versions
|
|
18
|
+
api_router.include_router(router=api_router_v1)
|
|
19
|
+
api_router.include_router(router=api_router_v2)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Provides the response objects for the books API."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from fastapi_factory_utilities.example.entities.books import BookName, BookType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BookResponseModel(BaseModel):
|
|
11
|
+
"""Book response model."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="ignore")
|
|
14
|
+
|
|
15
|
+
id: UUID
|
|
16
|
+
title: BookName
|
|
17
|
+
book_type: BookType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BookListReponse(BaseModel):
|
|
21
|
+
"""Book list response."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
books: list[BookResponseModel]
|
|
26
|
+
size: int
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Provides the Books API."""
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Request
|
|
7
|
+
|
|
8
|
+
from fastapi_factory_utilities.example.entities.books import BookEntity
|
|
9
|
+
from fastapi_factory_utilities.example.models.books.repository import BookRepository
|
|
10
|
+
from fastapi_factory_utilities.example.services.books import BookService
|
|
11
|
+
|
|
12
|
+
from .responses import BookListReponse, BookResponseModel
|
|
13
|
+
|
|
14
|
+
api_v1_books_router: APIRouter = APIRouter(prefix="/books")
|
|
15
|
+
api_v2_books_router: APIRouter = APIRouter(prefix="/books")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_book_service(request: Request) -> BookService:
|
|
19
|
+
"""Provide Book Service."""
|
|
20
|
+
return BookService(book_repository=BookRepository(request.app.state.odm_client))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@api_v1_books_router.get(path="", response_model=BookListReponse)
|
|
24
|
+
def get_books(
|
|
25
|
+
books_service: BookService = Depends(get_book_service),
|
|
26
|
+
) -> BookListReponse:
|
|
27
|
+
"""Get all books.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
books_service (BookService): Book service.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
BookListReponse: List of books
|
|
34
|
+
"""
|
|
35
|
+
books: list[BookEntity] = books_service.get_all_books()
|
|
36
|
+
|
|
37
|
+
return BookListReponse(
|
|
38
|
+
books=cast(
|
|
39
|
+
list[BookResponseModel],
|
|
40
|
+
map(lambda book: BookResponseModel(**book.model_dump()), books),
|
|
41
|
+
),
|
|
42
|
+
size=len(books),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@api_v1_books_router.get(path="/{book_id}", response_model=BookResponseModel)
|
|
47
|
+
def get_book(
|
|
48
|
+
book_id: UUID,
|
|
49
|
+
books_service: BookService = Depends(get_book_service),
|
|
50
|
+
) -> BookResponseModel:
|
|
51
|
+
"""Get a book.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
book_id (str): Book id
|
|
55
|
+
books_service (BookService): Book service
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
BookResponseModel: Book
|
|
59
|
+
"""
|
|
60
|
+
book: BookEntity = books_service.get_book(book_id)
|
|
61
|
+
|
|
62
|
+
return BookResponseModel(**book.model_dump())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Provides the concrete application class."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from beanie import Document
|
|
6
|
+
|
|
7
|
+
from fastapi_factory_utilities.core.app import BaseApplication
|
|
8
|
+
from fastapi_factory_utilities.core.app.base.plugins_manager_abstract import (
|
|
9
|
+
PluginsActivationList,
|
|
10
|
+
)
|
|
11
|
+
from fastapi_factory_utilities.example.models.books.document import BookDocument
|
|
12
|
+
|
|
13
|
+
from .config import AppConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class App(BaseApplication):
|
|
17
|
+
"""Concrete application class."""
|
|
18
|
+
|
|
19
|
+
PACKAGE_NAME: str = "fastapi_factory_utilities.example"
|
|
20
|
+
|
|
21
|
+
CONFIG_CLASS = AppConfig
|
|
22
|
+
|
|
23
|
+
ODM_DOCUMENT_MODELS: ClassVar[list[type[Document]]] = [BookDocument]
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: AppConfig, plugin_activation_list: PluginsActivationList | None = None) -> None:
|
|
26
|
+
"""Instantiate the application with the configuration and the API router.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
config (AppConfig): The application configuration.
|
|
30
|
+
plugin_activation_list (PluginsActivationList | None, optional): The plugins activation list.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(config=config, plugin_activation_list=plugin_activation_list)
|
|
33
|
+
|
|
34
|
+
# Prevent circular imports
|
|
35
|
+
from ..api import api_router # pylint: disable=import-outside-toplevel
|
|
36
|
+
|
|
37
|
+
self.get_asgi_app().include_router(router=api_router)
|