Skip to content

Commit 0fc1fc7

Browse files
committed
feat(openapi): Harden @api_endpoint decorator and OpenAPI generator
1 parent e6305e2 commit 0fc1fc7

File tree

11 files changed

+474
-59
lines changed

11 files changed

+474
-59
lines changed

hathor/api/openapi/decorators.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ class EndpointMetadata:
5858
deprecated: bool
5959
path_params_regex: dict[str, str]
6060
path_params_descriptions: dict[str, str]
61+
catch_hathor_exceptions: bool
62+
max_body_size: int
6163

6264

6365
# Global registry of endpoint metadata
@@ -152,6 +154,17 @@ def _parse_rate_limit(rl: dict[str, Any]) -> RateLimitConfig:
152154
return RateLimitConfig(rate=rl['rate'], burst=rl['burst'], delay=rl['delay'])
153155

154156

157+
def _sanitize_validation_error(e: Any) -> str:
158+
"""Format a Pydantic ValidationError into a safe, user-facing message.
159+
160+
Strips model internals and only includes field paths and messages.
161+
"""
162+
return '; '.join(
163+
f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}"
164+
for err in e.errors()
165+
)
166+
167+
155168
def api_endpoint(
156169
*,
157170
path: str,
@@ -169,6 +182,8 @@ def api_endpoint(
169182
deprecated: bool = False,
170183
path_params_regex: dict[str, str] | None = None,
171184
path_params_descriptions: dict[str, str] | None = None,
185+
catch_hathor_exceptions: bool = True,
186+
max_body_size: int = 1_000_000,
172187
) -> Callable[[F], F]:
173188
"""Decorator to register an endpoint with OpenAPI metadata and auto-validate/serialize.
174189
@@ -204,6 +219,8 @@ def api_endpoint(
204219
deprecated: Whether this endpoint is deprecated
205220
path_params_regex: Regex patterns for path parameters
206221
path_params_descriptions: Descriptions for path parameters
222+
catch_hathor_exceptions: Whether to catch HathorError and return ErrorResponse (default True)
223+
max_body_size: Maximum request body size in bytes (default 1MB)
207224
"""
208225
def decorator(func: F) -> F:
209226
# Parse rate limit configs
@@ -226,8 +243,18 @@ def decorator(func: F) -> F:
226243
deprecated=deprecated,
227244
path_params_regex=path_params_regex or {},
228245
path_params_descriptions=path_params_descriptions or {},
246+
catch_hathor_exceptions=catch_hathor_exceptions,
247+
max_body_size=max_body_size,
229248
)
230249

250+
# Check for duplicate registrations
251+
key = (metadata.path, metadata.method)
252+
for existing in _endpoint_registry:
253+
if (existing.path, existing.method) == key:
254+
raise ValueError(
255+
f"Duplicate endpoint registration: {metadata.method} {metadata.path}"
256+
)
257+
231258
# Resolve imports once per decorated function, not per request
232259
import json as _json
233260

@@ -236,6 +263,7 @@ def decorator(func: F) -> F:
236263
from twisted.web.server import NOT_DONE_YET as _NOT_DONE_YET
237264

238265
from hathor.api_util import set_cors
266+
from hathor.exception import HathorError
239267
from hathor.utils.api import ErrorResponse as _LegacyErrorResponse
240268

241269
@functools.wraps(func)
@@ -262,16 +290,31 @@ def wrapper(self: Any, request: Request, *args: Any, **kwargs: Any) -> Any:
262290
request.setResponseCode(400)
263291
return error.json_dumpb()
264292
body_bytes = request.content.read()
293+
if len(body_bytes) > max_body_size:
294+
error = ErrorResponse(error=f'Request body too large (max {max_body_size} bytes)')
295+
request.setResponseCode(413)
296+
return error.json_dumpb()
265297
body_data = _json.loads(body_bytes)
266298
body = request_model.model_validate(body_data)
267-
except (ValidationError, _json.JSONDecodeError, UnicodeDecodeError) as e:
268-
error = ErrorResponse(error=str(e))
299+
except ValidationError as e:
300+
error = ErrorResponse(error=_sanitize_validation_error(e))
301+
request.setResponseCode(400)
302+
return error.json_dumpb()
303+
except (_json.JSONDecodeError, UnicodeDecodeError):
304+
error = ErrorResponse(error='Request body is not valid JSON')
269305
request.setResponseCode(400)
270306
return error.json_dumpb()
271307
kwargs['body'] = body
272308

273309
# Call the actual handler
274-
result = func(self, request, *args, **kwargs)
310+
try:
311+
result = func(self, request, *args, **kwargs)
312+
except HathorError as e:
313+
if not catch_hathor_exceptions:
314+
raise
315+
error = ErrorResponse(error=str(e))
316+
request.setResponseCode(getattr(e, 'status_code', 400))
317+
return error.json_dumpb()
275318

276319
# Auto-serialize response
277320
if isinstance(result, Deferred):

hathor/api/openapi/generator.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,14 @@ def _get_response_models(self, metadata: EndpointMetadata) -> list[type[BaseMode
121121
# Check if it's a Union type
122122
args = typing.get_args(metadata.response_model)
123123
if args:
124-
return list(args)
124+
models = []
125+
for a in args:
126+
if a is type(None):
127+
continue
128+
if not (isinstance(a, type) and issubclass(a, BaseModel)):
129+
raise TypeError(f"response_model Union contains non-BaseModel type: {a}")
130+
models.append(a)
131+
return models
125132

126133
# Single model
127134
return [metadata.response_model]
@@ -311,4 +318,8 @@ def _extract_defs(self, schema: dict[str, Any], target: dict[str, Any]) -> None:
311318
if '$defs' in schema:
312319
for def_name, def_schema in schema.pop('$defs').items():
313320
self._extract_defs(def_schema, target)
321+
if def_name in target and target[def_name] != def_schema:
322+
raise ValueError(
323+
f"$defs collision for '{def_name}': different schemas share the same name"
324+
)
314325
target[def_name] = def_schema

hathor/api/schemas/__init__.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,12 @@
1414

1515
"""Pydantic schemas for API request/response validation and OpenAPI generation."""
1616

17-
from hathor.api.schemas.base import (
18-
ErrorResponse,
19-
ErrorResponseModel,
20-
OpenAPIExample,
21-
RequestModel,
22-
ResponseModel,
23-
SuccessResponse,
24-
)
17+
from hathor.api.schemas.base import ErrorResponse, ErrorResponseModel, OpenAPIExample, ResponseModel, SuccessResponse
2518

2619
__all__ = [
2720
'ErrorResponse',
2821
'ErrorResponseModel',
2922
'OpenAPIExample',
30-
'RequestModel',
3123
'ResponseModel',
3224
'SuccessResponse',
3325
]

hathor/api/schemas/base.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,6 @@ class OpenAPIExample:
3333
value: BaseModel
3434

3535

36-
class RequestModel(BaseModel):
37-
"""Base class for POST/PUT request bodies.
38-
39-
Inherits from BaseModel with extra='forbid' and frozen=True.
40-
"""
41-
pass
42-
43-
4436
class ResponseModel(BaseModel):
4537
"""Base class for all API responses.
4638

hathor/healthcheck/resources/healthcheck.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def __init__(self, manager: HathorManager):
189189
rate_limit_global=[{'rate': '10r/s', 'burst': 10, 'delay': 5}],
190190
rate_limit_per_ip=[{'rate': '1r/s', 'burst': 3, 'delay': 2}],
191191
query_params_model=HealthcheckParams,
192-
response_model=Union[HealthcheckSuccessResponse, HealthcheckFailResponse],
192+
response_model=Union[HealthcheckSuccessResponse, HealthcheckFailResponse, HealthcheckStrictFailResponse],
193193
)
194194
def render_GET(self, request: Request, *, params: HealthcheckParams) -> Deferred:
195195
""" GET request /health/

hathor/transaction/resources/validate_address.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class _ValidateAddressResource(Resource):
7777
"""
7878
isLeaf = True
7979

80-
def __init__(self, manager: HathorManager, address: Union[str, bytes]):
80+
def __init__(self, manager: HathorManager, address: Union[str, bytes]) -> None:
8181
super().__init__()
8282
# Important to have the manager so we can know the tx_storage
8383
self.manager = manager
@@ -108,7 +108,7 @@ def render_GET(self, request):
108108
except Exception as e:
109109
return ValidateAddressErrorResponse(
110110
valid=False,
111-
error=type(e).__name__,
111+
error='invalid_address',
112112
msg=str(e),
113113
)
114114

hathor/version_resource.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ class VersionResponse(ResponseModel):
8080

8181
@register_resource
8282
class VersionResource(Resource):
83-
""" Implements a web server API with POST to return the api version and some configuration
83+
""" Implements a web server API with GET to return the api version and some configuration
8484
8585
You must run with option `--status <PORT>`.
8686
"""

hathor_cli/openapi_json.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def main():
9494
parser = create_parser()
9595
parser.add_argument('--indent', type=int, default=None, help='Number of spaces to use for indentation')
9696
parser.add_argument('out', type=argparse.FileType('w', encoding='UTF-8'), default=get_default_output_path(),
97-
nargs='?', help='Output file where OpenSPI json will be written')
97+
nargs='?', help='Output file where OpenAPI json will be written')
9898
args = parser.parse_args()
9999

100100
openapi = get_openapi_dict()

0 commit comments

Comments
 (0)