toms-fast 0.2.1__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.
- toms_fast-0.2.1.dist-info/METADATA +467 -0
- toms_fast-0.2.1.dist-info/RECORD +60 -0
- toms_fast-0.2.1.dist-info/WHEEL +4 -0
- toms_fast-0.2.1.dist-info/entry_points.txt +2 -0
- tomskit/__init__.py +0 -0
- tomskit/celery/README.md +693 -0
- tomskit/celery/__init__.py +4 -0
- tomskit/celery/celery.py +306 -0
- tomskit/celery/config.py +377 -0
- tomskit/cli/__init__.py +207 -0
- tomskit/cli/__main__.py +8 -0
- tomskit/cli/scaffold.py +123 -0
- tomskit/cli/templates/__init__.py +42 -0
- tomskit/cli/templates/base.py +348 -0
- tomskit/cli/templates/celery.py +101 -0
- tomskit/cli/templates/extensions.py +213 -0
- tomskit/cli/templates/fastapi.py +400 -0
- tomskit/cli/templates/migrations.py +281 -0
- tomskit/cli/templates_config.py +122 -0
- tomskit/logger/README.md +466 -0
- tomskit/logger/__init__.py +4 -0
- tomskit/logger/config.py +106 -0
- tomskit/logger/logger.py +290 -0
- tomskit/py.typed +0 -0
- tomskit/redis/README.md +462 -0
- tomskit/redis/__init__.py +6 -0
- tomskit/redis/config.py +85 -0
- tomskit/redis/redis_pool.py +87 -0
- tomskit/redis/redis_sync.py +66 -0
- tomskit/server/__init__.py +47 -0
- tomskit/server/config.py +117 -0
- tomskit/server/exceptions.py +412 -0
- tomskit/server/middleware.py +371 -0
- tomskit/server/parser.py +312 -0
- tomskit/server/resource.py +464 -0
- tomskit/server/server.py +276 -0
- tomskit/server/type.py +263 -0
- tomskit/sqlalchemy/README.md +590 -0
- tomskit/sqlalchemy/__init__.py +20 -0
- tomskit/sqlalchemy/config.py +125 -0
- tomskit/sqlalchemy/database.py +125 -0
- tomskit/sqlalchemy/pagination.py +359 -0
- tomskit/sqlalchemy/property.py +19 -0
- tomskit/sqlalchemy/sqlalchemy.py +131 -0
- tomskit/sqlalchemy/types.py +32 -0
- tomskit/task/README.md +67 -0
- tomskit/task/__init__.py +4 -0
- tomskit/task/task_manager.py +124 -0
- tomskit/tools/README.md +63 -0
- tomskit/tools/__init__.py +18 -0
- tomskit/tools/config.py +70 -0
- tomskit/tools/warnings.py +37 -0
- tomskit/tools/woker.py +81 -0
- tomskit/utils/README.md +666 -0
- tomskit/utils/README_SERIALIZER.md +644 -0
- tomskit/utils/__init__.py +35 -0
- tomskit/utils/fields.py +434 -0
- tomskit/utils/marshal_utils.py +137 -0
- tomskit/utils/response_utils.py +13 -0
- tomskit/utils/serializers.py +447 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from tomskit.server.exceptions import (
|
|
2
|
+
APIException,
|
|
3
|
+
ServiceException,
|
|
4
|
+
raise_api_error,
|
|
5
|
+
format_validation_errors,
|
|
6
|
+
)
|
|
7
|
+
from tomskit.server.server import FastApp, FastModule, current_app
|
|
8
|
+
from tomskit.server.resource import (
|
|
9
|
+
Resource,
|
|
10
|
+
ResourceRouter,
|
|
11
|
+
ResourceRegistry,
|
|
12
|
+
api_doc,
|
|
13
|
+
register_resource,
|
|
14
|
+
)
|
|
15
|
+
from tomskit.server.parser import RequestParser
|
|
16
|
+
from tomskit.server.type import Boolean, IntRange, StrLen, DatetimeString, PhoneNumber, EmailStr, UUIDType
|
|
17
|
+
from tomskit.server.middleware import (
|
|
18
|
+
RequestIDMiddleware,
|
|
19
|
+
ResourceCleanupMiddleware,
|
|
20
|
+
CleanupStrategy,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
'raise_api_error',
|
|
25
|
+
'APIException',
|
|
26
|
+
'ServiceException',
|
|
27
|
+
'format_validation_errors',
|
|
28
|
+
'current_app',
|
|
29
|
+
'Resource',
|
|
30
|
+
'ResourceRouter',
|
|
31
|
+
'ResourceRegistry',
|
|
32
|
+
'api_doc',
|
|
33
|
+
'register_resource',
|
|
34
|
+
'FastApp',
|
|
35
|
+
'FastModule',
|
|
36
|
+
'RequestParser',
|
|
37
|
+
'Boolean',
|
|
38
|
+
'IntRange',
|
|
39
|
+
'StrLen',
|
|
40
|
+
'DatetimeString',
|
|
41
|
+
'PhoneNumber',
|
|
42
|
+
'EmailStr',
|
|
43
|
+
'UUIDType',
|
|
44
|
+
'RequestIDMiddleware',
|
|
45
|
+
'ResourceCleanupMiddleware',
|
|
46
|
+
'CleanupStrategy',
|
|
47
|
+
]
|
tomskit/server/config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
|
|
2
|
+
import typing as t
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Config(dict):
|
|
6
|
+
"""Works exactly like a dict but provides ways to fill it from files
|
|
7
|
+
or special dictionaries. There are two common patterns to populate the
|
|
8
|
+
config.
|
|
9
|
+
|
|
10
|
+
Either you can fill the config from a config file::
|
|
11
|
+
|
|
12
|
+
app.config.from_pyfile('yourconfig.cfg')
|
|
13
|
+
|
|
14
|
+
Or alternatively you can define the configuration options in the
|
|
15
|
+
module that calls :meth:`from_object` or provide an import path to
|
|
16
|
+
a module that should be loaded. It is also possible to tell it to
|
|
17
|
+
use the same module and with that provide the configuration values
|
|
18
|
+
just before the call::
|
|
19
|
+
|
|
20
|
+
DEBUG = True
|
|
21
|
+
SECRET_KEY = 'development key'
|
|
22
|
+
app.config.from_object(__name__)
|
|
23
|
+
|
|
24
|
+
In both cases (loading from any Python file or loading from modules),
|
|
25
|
+
only uppercase keys are added to the config. This makes it possible to use
|
|
26
|
+
lowercase values in the config file for temporary values that are not added
|
|
27
|
+
to the config or to define the config keys in the same file that implements
|
|
28
|
+
the application.
|
|
29
|
+
|
|
30
|
+
Probably the most interesting way to load configurations is from an
|
|
31
|
+
environment variable pointing to a file::
|
|
32
|
+
|
|
33
|
+
app.config.from_envvar('YOURAPPLICATION_SETTINGS')
|
|
34
|
+
|
|
35
|
+
In this case before launching the application you have to set this
|
|
36
|
+
environment variable to the file you want to use. On Linux and OS X
|
|
37
|
+
use the export statement::
|
|
38
|
+
|
|
39
|
+
export YOURAPPLICATION_SETTINGS='/path/to/config/file'
|
|
40
|
+
|
|
41
|
+
On windows use `set` instead.
|
|
42
|
+
|
|
43
|
+
:param root_path: path to which files are read relative from. When the
|
|
44
|
+
config object is created by the application, this is
|
|
45
|
+
the application's :attr:`~flask.Flask.root_path`.
|
|
46
|
+
:param defaults: an optional dictionary of default values
|
|
47
|
+
"""
|
|
48
|
+
def __init__(self,
|
|
49
|
+
defaults: dict[str, t.Any] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(defaults or {})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_mapping(
|
|
55
|
+
self, mapping: t.Mapping[str, t.Any] | None = None, **kwargs: t.Any
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""Updates the config like :meth:`update` ignoring items with
|
|
58
|
+
non-upper keys.
|
|
59
|
+
|
|
60
|
+
:return: Always returns ``True``.
|
|
61
|
+
|
|
62
|
+
.. versionadded:: 0.11
|
|
63
|
+
"""
|
|
64
|
+
mappings: dict[str, t.Any] = {}
|
|
65
|
+
if mapping is not None:
|
|
66
|
+
mappings.update(mapping)
|
|
67
|
+
mappings.update(kwargs)
|
|
68
|
+
for key, value in mappings.items():
|
|
69
|
+
if key.isupper():
|
|
70
|
+
self[key] = value
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def get_namespace(
|
|
74
|
+
self, namespace: str, lowercase: bool = True, trim_namespace: bool = True
|
|
75
|
+
) -> dict[str, t.Any]:
|
|
76
|
+
"""Returns a dictionary containing a subset of configuration options
|
|
77
|
+
that match the specified namespace/prefix. Example usage::
|
|
78
|
+
|
|
79
|
+
app.config['IMAGE_STORE_TYPE'] = 'fs'
|
|
80
|
+
app.config['IMAGE_STORE_PATH'] = '/var/app/images'
|
|
81
|
+
app.config['IMAGE_STORE_BASE_URL'] = 'http://img.website.com'
|
|
82
|
+
image_store_config = app.config.get_namespace('IMAGE_STORE_')
|
|
83
|
+
|
|
84
|
+
The resulting dictionary `image_store_config` would look like::
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
'type': 'fs',
|
|
88
|
+
'path': '/var/app/images',
|
|
89
|
+
'base_url': 'http://img.website.com'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
This is often useful when configuration options map directly to
|
|
93
|
+
keyword arguments in functions or class constructors.
|
|
94
|
+
|
|
95
|
+
:param namespace: a configuration namespace
|
|
96
|
+
:param lowercase: a flag indicating if the keys of the resulting
|
|
97
|
+
dictionary should be lowercase
|
|
98
|
+
:param trim_namespace: a flag indicating if the keys of the resulting
|
|
99
|
+
dictionary should not include the namespace
|
|
100
|
+
|
|
101
|
+
.. versionadded:: 0.11
|
|
102
|
+
"""
|
|
103
|
+
rv = {}
|
|
104
|
+
for k, v in self.items():
|
|
105
|
+
if not k.startswith(namespace):
|
|
106
|
+
continue
|
|
107
|
+
if trim_namespace:
|
|
108
|
+
key = k[len(namespace) :]
|
|
109
|
+
else:
|
|
110
|
+
key = k
|
|
111
|
+
if lowercase:
|
|
112
|
+
key = key.lower()
|
|
113
|
+
rv[key] = v
|
|
114
|
+
return rv
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
return f"<{type(self).__name__} {dict.__repr__(self)}>"
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception handling module.
|
|
3
|
+
|
|
4
|
+
Provides unified exception base classes for layered exception handling:
|
|
5
|
+
- ServiceException: Business layer exception base class (framework-agnostic)
|
|
6
|
+
- APIException: Framework layer exception base class (inherits from HTTPException)
|
|
7
|
+
- raise_api_error: Quick function to raise APIException
|
|
8
|
+
- format_validation_errors: Utility function to format Pydantic ValidationError
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Optional, Any
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ============================================================================
|
|
17
|
+
# Exception Base Classes
|
|
18
|
+
# ============================================================================
|
|
19
|
+
|
|
20
|
+
class ServiceException(Exception):
|
|
21
|
+
"""
|
|
22
|
+
Business exception base class.
|
|
23
|
+
|
|
24
|
+
Used in Service layer (business logic layer) to raise business exceptions.
|
|
25
|
+
|
|
26
|
+
Features:
|
|
27
|
+
- Framework-agnostic: No dependency on FastAPI/HTTP, can be used in any business layer
|
|
28
|
+
- Business semantics: Exception names reflect business meaning
|
|
29
|
+
- Business context: Contains detailed business-related information
|
|
30
|
+
- Serializable: Exception information can be converted to transferable format
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
code (str): Business error code, e.g., "USER_NOT_FOUND"
|
|
34
|
+
message (str): User-friendly error message
|
|
35
|
+
detail (dict): Detailed error information (business context)
|
|
36
|
+
original_exception (Exception, optional): Original exception (if any)
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
>>> raise ServiceException(
|
|
40
|
+
... code="USER_NOT_FOUND",
|
|
41
|
+
... message="User not found",
|
|
42
|
+
... detail={"user_id": "123"}
|
|
43
|
+
... )
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
code: str,
|
|
49
|
+
message: str,
|
|
50
|
+
detail: Optional[dict] = None,
|
|
51
|
+
original_exception: Optional[Exception] = None,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize business exception.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
code: Business error code, e.g., "USER_NOT_FOUND"
|
|
58
|
+
message: User-friendly error message
|
|
59
|
+
detail: Detailed error information (business context), defaults to empty dict
|
|
60
|
+
original_exception: Original exception (if any), used for log tracking
|
|
61
|
+
"""
|
|
62
|
+
self.code = code
|
|
63
|
+
self.message = message
|
|
64
|
+
self.detail = detail or {}
|
|
65
|
+
self.original_exception = original_exception
|
|
66
|
+
super().__init__(self.message)
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return f"{self.__class__.__name__}(code={self.code!r}, message={self.message!r})"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class APIException(HTTPException):
|
|
73
|
+
"""
|
|
74
|
+
Framework unified exception base class.
|
|
75
|
+
|
|
76
|
+
Used in framework layer for unified exception handling, inherits from FastAPI's HTTPException.
|
|
77
|
+
|
|
78
|
+
Features:
|
|
79
|
+
- Inherits from HTTPException (FastAPI native)
|
|
80
|
+
- Contains unified response format
|
|
81
|
+
- Can be handled by global exception handlers
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
code (str): Error code, e.g., "user_not_found"
|
|
85
|
+
message (str): User-friendly error message
|
|
86
|
+
status_code (int): HTTP status code
|
|
87
|
+
detail (dict): Detailed error information
|
|
88
|
+
original_exception (Exception, optional): Original Service exception (optional, for logging)
|
|
89
|
+
|
|
90
|
+
Response format:
|
|
91
|
+
{
|
|
92
|
+
"code": "user_not_found",
|
|
93
|
+
"message": "User not found",
|
|
94
|
+
"status": 404
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
If detail is provided, it will be merged to the top level:
|
|
98
|
+
{
|
|
99
|
+
"code": "user_not_found",
|
|
100
|
+
"message": "User not found",
|
|
101
|
+
"status": 404,
|
|
102
|
+
"user_id": "123", # content from detail
|
|
103
|
+
...
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> raise APIException(
|
|
108
|
+
... code="user_not_found",
|
|
109
|
+
... message="User not found",
|
|
110
|
+
... status_code=404,
|
|
111
|
+
... detail={"user_id": "123"}
|
|
112
|
+
... )
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
code: str,
|
|
118
|
+
message: str,
|
|
119
|
+
status_code: int = 400,
|
|
120
|
+
detail: Optional[dict] = None,
|
|
121
|
+
original_exception: Optional[Exception] = None,
|
|
122
|
+
):
|
|
123
|
+
"""
|
|
124
|
+
Initialize framework exception.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
code: Error code, e.g., "user_not_found"
|
|
128
|
+
message: User-friendly error message
|
|
129
|
+
status_code: HTTP status code, defaults to 400
|
|
130
|
+
detail: Detailed error information, defaults to None (if provided, merged to response top level)
|
|
131
|
+
original_exception: Original Service exception (optional, for log tracking)
|
|
132
|
+
"""
|
|
133
|
+
self.code = code
|
|
134
|
+
self.message = message
|
|
135
|
+
self.original_exception = original_exception
|
|
136
|
+
|
|
137
|
+
# HTTPException's detail parameter is used for response content
|
|
138
|
+
# Format to unified structure: code, message, status at top level
|
|
139
|
+
# If detail is provided, merge to top level (instead of nesting in detail field)
|
|
140
|
+
if detail:
|
|
141
|
+
response_detail = {
|
|
142
|
+
"code": code,
|
|
143
|
+
"message": message,
|
|
144
|
+
"status": status_code,
|
|
145
|
+
**detail, # Merge detail directly using dict unpacking (more efficient)
|
|
146
|
+
}
|
|
147
|
+
else:
|
|
148
|
+
response_detail = {
|
|
149
|
+
"code": code,
|
|
150
|
+
"message": message,
|
|
151
|
+
"status": status_code,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
super().__init__(status_code=status_code, detail=response_detail)
|
|
155
|
+
|
|
156
|
+
def __repr__(self) -> str:
|
|
157
|
+
return (
|
|
158
|
+
f"{self.__class__.__name__}"
|
|
159
|
+
f"(code={self.code!r}, message={self.message!r}, status_code={self.status_code})"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ============================================================================
|
|
164
|
+
# Quick Exception Raising
|
|
165
|
+
# ============================================================================
|
|
166
|
+
|
|
167
|
+
# Default error codes and messages for common HTTP status codes
|
|
168
|
+
_DEFAULT_ERROR_MESSAGES = {
|
|
169
|
+
400: ("bad_request", "Bad Request"),
|
|
170
|
+
401: ("unauthorized", "Unauthorized"),
|
|
171
|
+
403: ("forbidden", "Forbidden"),
|
|
172
|
+
404: ("not_found", "Not Found"),
|
|
173
|
+
409: ("conflict", "Conflict"),
|
|
174
|
+
422: ("validation_error", "Validation Error"),
|
|
175
|
+
500: ("internal_server_error", "Internal Server Error"),
|
|
176
|
+
502: ("bad_gateway", "Bad Gateway"),
|
|
177
|
+
503: ("service_unavailable", "Service Unavailable"),
|
|
178
|
+
504: ("gateway_timeout", "Gateway Timeout"),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def raise_api_error(
|
|
183
|
+
status_code: int,
|
|
184
|
+
code: Optional[str] = None,
|
|
185
|
+
message: Optional[str] = None,
|
|
186
|
+
detail: Optional[dict] = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""
|
|
189
|
+
Quickly raise APIException.
|
|
190
|
+
|
|
191
|
+
Uses unified response format with code, message, and status fields.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
status_code: HTTP status code
|
|
195
|
+
code: Error code (optional), auto-generated from status_code if not provided
|
|
196
|
+
message: Error message (optional), auto-generated from status_code if not provided
|
|
197
|
+
detail: Detailed error information dict (optional), merged to response top level
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
>>> # Basic usage (using default code and message)
|
|
201
|
+
>>> raise_api_error(404)
|
|
202
|
+
|
|
203
|
+
>>> # Specify code and message
|
|
204
|
+
>>> raise_api_error(404, code="page_not_found", message="Page not found")
|
|
205
|
+
|
|
206
|
+
>>> # Add detail information (merged to response top level)
|
|
207
|
+
>>> raise_api_error(
|
|
208
|
+
... status_code=404,
|
|
209
|
+
... code="user_not_found",
|
|
210
|
+
... message="User not found",
|
|
211
|
+
... detail={"user_id": "123", "field": "id"}
|
|
212
|
+
... )
|
|
213
|
+
|
|
214
|
+
Response format:
|
|
215
|
+
{
|
|
216
|
+
"code": "not_found",
|
|
217
|
+
"message": "Not Found",
|
|
218
|
+
"status": 404
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
If detail is provided:
|
|
222
|
+
{
|
|
223
|
+
"code": "user_not_found",
|
|
224
|
+
"message": "User not found",
|
|
225
|
+
"status": 404,
|
|
226
|
+
"user_id": "123",
|
|
227
|
+
"field": "id"
|
|
228
|
+
}
|
|
229
|
+
"""
|
|
230
|
+
# Get default code and message based on status_code
|
|
231
|
+
default_code, default_message = _DEFAULT_ERROR_MESSAGES.get(
|
|
232
|
+
status_code, (f"http_{status_code}", f"HTTP {status_code} Error")
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Determine final code and message
|
|
236
|
+
final_code = code if code is not None else default_code
|
|
237
|
+
final_message = message if message is not None else default_message
|
|
238
|
+
|
|
239
|
+
# Raise APIException
|
|
240
|
+
raise APIException(
|
|
241
|
+
code=final_code,
|
|
242
|
+
message=final_message,
|
|
243
|
+
status_code=status_code,
|
|
244
|
+
detail=detail,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ============================================================================
|
|
249
|
+
# ValidationError Formatting Utilities
|
|
250
|
+
# ============================================================================
|
|
251
|
+
|
|
252
|
+
# Sensitive field list (input values for these fields should not be returned in response)
|
|
253
|
+
# Pre-compute lowercase versions for efficient matching
|
|
254
|
+
_SENSITIVE_FIELDS = {"password", "token", "secret", "api_key", "access_token", "refresh_token"}
|
|
255
|
+
_SENSITIVE_FIELDS_LOWER = {field.lower() for field in _SENSITIVE_FIELDS}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _get_field_path(loc: tuple[Any, ...]) -> str:
|
|
259
|
+
"""
|
|
260
|
+
Convert field location tuple to path string.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
loc: Field location tuple, e.g., ("user", "profile", "name") or ("items", 0, "name")
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Field path string, e.g., "user.profile.name" or "items.0.name"
|
|
267
|
+
|
|
268
|
+
Example:
|
|
269
|
+
>>> _get_field_path(("email",))
|
|
270
|
+
"email"
|
|
271
|
+
>>> _get_field_path(("user", "profile", "name"))
|
|
272
|
+
"user.profile.name"
|
|
273
|
+
>>> _get_field_path(("items", 0, "name"))
|
|
274
|
+
"items.0.name"
|
|
275
|
+
"""
|
|
276
|
+
return ".".join(str(x) for x in loc)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _should_include_input(field_path: str) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Determine whether input value should be included in response.
|
|
282
|
+
|
|
283
|
+
Sensitive field input values should never be returned to avoid leaking sensitive information.
|
|
284
|
+
However, validation errors for sensitive fields (field, message, type) are still included
|
|
285
|
+
to notify users of validation issues.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
field_path: Field path, e.g., "user.password" or "email"
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
True if input value should be included (non-sensitive fields), False otherwise (sensitive fields)
|
|
292
|
+
|
|
293
|
+
Note:
|
|
294
|
+
- This function only determines whether to include the input VALUE
|
|
295
|
+
- Field, message, and type are always included regardless of sensitivity
|
|
296
|
+
- For sensitive fields: field/message/type are included, but input value is never included
|
|
297
|
+
"""
|
|
298
|
+
field_lower = field_path.lower()
|
|
299
|
+
# Check if any sensitive field keyword appears in the field path
|
|
300
|
+
return not any(sensitive in field_lower for sensitive in _SENSITIVE_FIELDS_LOWER)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _format_single_error(error: Any) -> dict[str, Any]:
|
|
304
|
+
"""
|
|
305
|
+
Format a single field error.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
error: Pydantic error dict containing loc, msg, type, input fields
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Formatted error dict containing field, message, type, input (if applicable)
|
|
312
|
+
|
|
313
|
+
Note:
|
|
314
|
+
- All fields (including sensitive fields) will include field, message, type
|
|
315
|
+
to ensure users are notified of validation errors
|
|
316
|
+
- For sensitive fields: input value is never included (even if present)
|
|
317
|
+
- For non-sensitive fields: input value is included only if present (not None)
|
|
318
|
+
"""
|
|
319
|
+
# Pydantic's ErrorDetails can be accessed as dict or object
|
|
320
|
+
# Try dict access first (faster), fallback to getattr
|
|
321
|
+
if isinstance(error, dict):
|
|
322
|
+
loc = error.get("loc", ())
|
|
323
|
+
msg = error.get("msg", "")
|
|
324
|
+
error_type = error.get("type", "")
|
|
325
|
+
input_value = error.get("input")
|
|
326
|
+
else:
|
|
327
|
+
loc = getattr(error, "loc", ())
|
|
328
|
+
msg = getattr(error, "msg", "")
|
|
329
|
+
error_type = getattr(error, "type", "")
|
|
330
|
+
input_value = getattr(error, "input", None)
|
|
331
|
+
|
|
332
|
+
field_path = _get_field_path(loc)
|
|
333
|
+
error_dict: dict[str, Any] = {
|
|
334
|
+
"field": field_path,
|
|
335
|
+
"message": msg,
|
|
336
|
+
"type": error_type,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
# Only include input value for non-sensitive fields
|
|
340
|
+
# For sensitive fields, we never include input value to avoid leaking sensitive information
|
|
341
|
+
# However, field/message/type are always included so users are notified of validation errors
|
|
342
|
+
if _should_include_input(field_path) and input_value is not None:
|
|
343
|
+
error_dict["input"] = input_value
|
|
344
|
+
|
|
345
|
+
return error_dict
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def format_validation_errors(validation_error: ValidationError) -> dict[str, Any]:
|
|
349
|
+
"""
|
|
350
|
+
Format Pydantic ValidationError to unified error format.
|
|
351
|
+
|
|
352
|
+
Convert Pydantic's ValidationError to detailed field error list,
|
|
353
|
+
used in exception handlers to generate unified error responses.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
validation_error: Pydantic ValidationError instance
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Dict containing errors list:
|
|
360
|
+
{
|
|
361
|
+
"errors": [
|
|
362
|
+
{
|
|
363
|
+
"field": "email",
|
|
364
|
+
"message": "value is not a valid email address",
|
|
365
|
+
"type": "value_error.email",
|
|
366
|
+
"input": "invalid-email"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"field": "age",
|
|
370
|
+
"message": "field required",
|
|
371
|
+
"type": "missing",
|
|
372
|
+
"input": null
|
|
373
|
+
},
|
|
374
|
+
...
|
|
375
|
+
]
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
Example:
|
|
379
|
+
>>> from pydantic import BaseModel, ValidationError
|
|
380
|
+
>>>
|
|
381
|
+
>>> class User(BaseModel):
|
|
382
|
+
... email: str
|
|
383
|
+
... age: int
|
|
384
|
+
>>>
|
|
385
|
+
>>> try:
|
|
386
|
+
... User(email="invalid", age="not-int")
|
|
387
|
+
... except ValidationError as e:
|
|
388
|
+
... errors = format_validation_errors(e)
|
|
389
|
+
... print(errors)
|
|
390
|
+
{
|
|
391
|
+
"errors": [
|
|
392
|
+
{
|
|
393
|
+
"field": "email",
|
|
394
|
+
"message": "value is not a valid email address",
|
|
395
|
+
"type": "value_error.email",
|
|
396
|
+
"input": "invalid"
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"field": "age",
|
|
400
|
+
"message": "value is not a valid integer",
|
|
401
|
+
"type": "type_error.integer",
|
|
402
|
+
"input": "not-int"
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
Note:
|
|
408
|
+
- Sensitive fields (e.g., password, token) input values are not included in response
|
|
409
|
+
- Supports nested field paths, e.g., "user.profile.name"
|
|
410
|
+
- Supports array indices, e.g., "items.0.name"
|
|
411
|
+
"""
|
|
412
|
+
return {"errors": [_format_single_error(error) for error in validation_error.errors()]}
|