fastapi-openrpc 0.1.7__tar.gz → 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.
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/PKG-INFO +1 -1
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/pyproject.toml +1 -1
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/authorization.py +5 -2
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/decorator.py +48 -0
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/router.py +83 -54
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/types.py +0 -1
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/README.md +0 -0
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/__init__.py +0 -0
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/authentication.py +0 -0
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/constants.py +0 -0
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/document.py +0 -0
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/errors.py +0 -0
- {fastapi_openrpc-0.1.7 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/registry.py +0 -0
|
@@ -26,6 +26,7 @@ class AuthorizationPolicy(Protocol):
|
|
|
26
26
|
self,
|
|
27
27
|
method_name: str,
|
|
28
28
|
required_roles: list[str],
|
|
29
|
+
require: Require,
|
|
29
30
|
ctx: RpcContext,
|
|
30
31
|
) -> bool:
|
|
31
32
|
"""Determine whether the caller is authorized to invoke the specified method.
|
|
@@ -33,7 +34,8 @@ class AuthorizationPolicy(Protocol):
|
|
|
33
34
|
Args:
|
|
34
35
|
method_name: The name of the RPC method being invoked.
|
|
35
36
|
required_roles: List of roles required by the method.
|
|
36
|
-
|
|
37
|
+
require: Role combination semantics (ANY/ALL) declared by the method.
|
|
38
|
+
ctx: Caller context containing roles and user_id fields.
|
|
37
39
|
|
|
38
40
|
Returns:
|
|
39
41
|
True if authorization is granted, False otherwise.
|
|
@@ -55,6 +57,7 @@ class RoleBasedAuthorizationPolicy:
|
|
|
55
57
|
self,
|
|
56
58
|
method_name: str,
|
|
57
59
|
required_roles: list[str],
|
|
60
|
+
require: Require,
|
|
58
61
|
ctx: RpcContext,
|
|
59
62
|
) -> bool:
|
|
60
63
|
# Empty role list -> skip check (backward compatible, R3)
|
|
@@ -65,7 +68,7 @@ class RoleBasedAuthorizationPolicy:
|
|
|
65
68
|
ctx_roles = {r.lower() for r in ctx.roles}
|
|
66
69
|
needed = {r.lower() for r in required_roles}
|
|
67
70
|
|
|
68
|
-
if
|
|
71
|
+
if require == Require.ALL:
|
|
69
72
|
return needed <= ctx_roles # All required_roles present in ctx.roles
|
|
70
73
|
else:
|
|
71
74
|
# Require.ANY (default)
|
|
@@ -400,6 +400,53 @@ def _resolve_result_model(
|
|
|
400
400
|
return resolved, schema
|
|
401
401
|
|
|
402
402
|
|
|
403
|
+
# JSON-native types allowed as (parts of) return annotations
|
|
404
|
+
_JSON_NATIVE_RETURN_TYPES = (dict, list, str, int, float, bool)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_json_serializable_annotation(annotation: Any) -> bool:
|
|
408
|
+
"""Recursively check that an annotation only contains JSON-native types
|
|
409
|
+
or Pydantic models: dict/list/str/int/float/bool/None/Any, BaseModel
|
|
410
|
+
subclasses, and unions/generics thereof."""
|
|
411
|
+
if annotation is None or annotation is type(None) or annotation is typing.Any:
|
|
412
|
+
return True
|
|
413
|
+
if _is_pydantic_model(annotation):
|
|
414
|
+
return True
|
|
415
|
+
if isinstance(annotation, type) and issubclass(annotation, _JSON_NATIVE_RETURN_TYPES):
|
|
416
|
+
return True
|
|
417
|
+
origin = typing.get_origin(annotation)
|
|
418
|
+
args = typing.get_args(annotation)
|
|
419
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
420
|
+
return all(_is_json_serializable_annotation(a) for a in args)
|
|
421
|
+
if origin in (dict, list):
|
|
422
|
+
# dict[str, X], list[X], typing.Dict/list aliases — get_origin returns builtin
|
|
423
|
+
return all(_is_json_serializable_annotation(a) for a in args)
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _validate_return_annotation(func: Callable[..., Any], name: str) -> None:
|
|
428
|
+
"""Reject return annotations that json.dumps cannot serialize.
|
|
429
|
+
|
|
430
|
+
Results flow into JSONResponse's json.dumps; a datetime/custom-class
|
|
431
|
+
annotation guarantees a runtime TypeError. Failing at registration
|
|
432
|
+
surfaces the misconfiguration at startup instead.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
hints = typing.get_type_hints(func)
|
|
436
|
+
except (TypeError, AttributeError, NameError):
|
|
437
|
+
return
|
|
438
|
+
return_type = hints.get("return")
|
|
439
|
+
if return_type is None:
|
|
440
|
+
return
|
|
441
|
+
if not _is_json_serializable_annotation(return_type):
|
|
442
|
+
raise TypeError(
|
|
443
|
+
f"RPC method {name!r}: return annotation {return_type!r} is not "
|
|
444
|
+
f"JSON-serializable. Allowed: Pydantic BaseModel, "
|
|
445
|
+
f"dict/list/str/int/float/bool/None/Any (and unions/generics "
|
|
446
|
+
f"thereof). Wrap custom types in a Pydantic model."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
403
450
|
def _infer_params_model(func: Callable[..., Any]) -> type | None:
|
|
404
451
|
"""Infer params_model from the first Pydantic BaseModel parameter in a handler.
|
|
405
452
|
|
|
@@ -482,6 +529,7 @@ def _build_method_meta(
|
|
|
482
529
|
resolved_result_model, final_result_schema = _resolve_result_model(
|
|
483
530
|
func, result_schema, result_model
|
|
484
531
|
)
|
|
532
|
+
_validate_return_annotation(func, final_name)
|
|
485
533
|
resolved_params_model = params_model if params_model is not None else _infer_params_model(func)
|
|
486
534
|
|
|
487
535
|
final_required_roles: list[str] = []
|
|
@@ -14,6 +14,7 @@ from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
|
|
|
14
14
|
from uuid import uuid4
|
|
15
15
|
|
|
16
16
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
17
|
+
from fastapi.encoders import jsonable_encoder
|
|
17
18
|
from fastapi.responses import JSONResponse, Response
|
|
18
19
|
|
|
19
20
|
from fastapi_openrpc.authentication import AuthenticationError, AuthenticationMiddleware
|
|
@@ -121,16 +122,13 @@ def _validate_pydantic_params(
|
|
|
121
122
|
# Check if all required fields are present in kwargs (flat parameter structure).
|
|
122
123
|
# Optional fields (with defaults) may be absent — model_validate will use defaults.
|
|
123
124
|
if required_fields <= kwargs.keys():
|
|
124
|
-
# Extract kwargs fields that match model field names
|
|
125
125
|
subdict = {f: kwargs[f] for f in kwargs.keys() if f in all_fields}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
except _pydantic_validation_error():
|
|
133
|
-
raise
|
|
126
|
+
# ValidationError is intentionally not caught: it surfaces as
|
|
127
|
+
# JSONRPC_INVALID_PARAMS in _dispatch_handler.
|
|
128
|
+
validated = model_cls.model_validate(subdict)
|
|
129
|
+
for f in subdict:
|
|
130
|
+
del kwargs[f]
|
|
131
|
+
kwargs[param_name] = validated
|
|
134
132
|
elif param_name in kwargs and isinstance(kwargs[param_name], dict):
|
|
135
133
|
# dict passed directly as parameter value (theoretically shouldn't happen,
|
|
136
134
|
# but handle for compatibility)
|
|
@@ -225,24 +223,9 @@ class RpcDispatcher:
|
|
|
225
223
|
if (err := self._validate_request_structure(request)) is not None:
|
|
226
224
|
return err
|
|
227
225
|
|
|
226
|
+
method_name = request["method"]
|
|
228
227
|
request_id = request.get("id")
|
|
229
228
|
params = request.get("params")
|
|
230
|
-
if params is not None and not isinstance(params, dict | list):
|
|
231
|
-
return self._error(
|
|
232
|
-
JSONRPC_INVALID_PARAMS,
|
|
233
|
-
"Invalid params",
|
|
234
|
-
"params must be a JSON object, an array or omitted",
|
|
235
|
-
request_id=request_id,
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
method_name = request.get("method")
|
|
239
|
-
if not isinstance(method_name, str):
|
|
240
|
-
return self._error(
|
|
241
|
-
JSONRPC_INVALID_REQUEST,
|
|
242
|
-
"Invalid Request",
|
|
243
|
-
"method must be a string",
|
|
244
|
-
request_id=request_id,
|
|
245
|
-
)
|
|
246
229
|
meta = get(method_name)
|
|
247
230
|
if meta is None:
|
|
248
231
|
return self._error(
|
|
@@ -292,7 +275,7 @@ class RpcDispatcher:
|
|
|
292
275
|
)
|
|
293
276
|
|
|
294
277
|
method_name = request.get("method")
|
|
295
|
-
if method_name
|
|
278
|
+
if not isinstance(method_name, str):
|
|
296
279
|
return self._error(
|
|
297
280
|
JSONRPC_INVALID_REQUEST,
|
|
298
281
|
"Invalid Request",
|
|
@@ -347,8 +330,7 @@ class RpcDispatcher:
|
|
|
347
330
|
if not meta.required_roles:
|
|
348
331
|
return None
|
|
349
332
|
|
|
350
|
-
self.
|
|
351
|
-
if not self.policy.authorize(meta.name, meta.required_roles, self.context):
|
|
333
|
+
if not self.policy.authorize(meta.name, meta.required_roles, meta.require, self.context):
|
|
352
334
|
return self._error(
|
|
353
335
|
JSONRPC_FORBIDDEN,
|
|
354
336
|
"Insufficient role",
|
|
@@ -400,10 +382,28 @@ class RpcDispatcher:
|
|
|
400
382
|
|
|
401
383
|
if request_id is None:
|
|
402
384
|
return None
|
|
385
|
+
# Auto-convert escaped non-JSON-native types (datetime, UUID, ...) so
|
|
386
|
+
# JSONResponse's json.dumps never sees them. A conversion failure here
|
|
387
|
+
# must surface as a per-item JSON-RPC error, never an HTTP 500.
|
|
388
|
+
try:
|
|
389
|
+
encoded_result = jsonable_encoder(result)
|
|
390
|
+
except (TypeError, ValueError):
|
|
391
|
+
logger.exception(
|
|
392
|
+
"Result of RPC handler %s is not JSON serializable (type: %s)",
|
|
393
|
+
meta.name,
|
|
394
|
+
type(result).__name__,
|
|
395
|
+
)
|
|
396
|
+
return self._error(
|
|
397
|
+
JSONRPC_INTERNAL_ERROR,
|
|
398
|
+
"Internal error",
|
|
399
|
+
f"Result of method {meta.name!r} is not JSON serializable "
|
|
400
|
+
f"(type: {type(result).__name__})",
|
|
401
|
+
request_id=request_id,
|
|
402
|
+
)
|
|
403
403
|
return {
|
|
404
404
|
"jsonrpc": "2.0",
|
|
405
405
|
"id": request_id,
|
|
406
|
-
"result":
|
|
406
|
+
"result": encoded_result,
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
async def _call_handler(self, func, params: dict, result_model: type | None = None) -> Any:
|
|
@@ -412,8 +412,8 @@ class RpcDispatcher:
|
|
|
412
412
|
Args:
|
|
413
413
|
func: RPC handler function.
|
|
414
414
|
params: JSON-RPC params dict.
|
|
415
|
-
result_model: Pydantic BaseModel subclass; return value
|
|
416
|
-
serialized via .model_dump().
|
|
415
|
+
result_model: Pydantic BaseModel subclass; return value (single instance
|
|
416
|
+
or list of instances) is automatically serialized via .model_dump().
|
|
417
417
|
"""
|
|
418
418
|
sig = inspect.signature(func)
|
|
419
419
|
|
|
@@ -455,9 +455,18 @@ class RpcDispatcher:
|
|
|
455
455
|
lambda _k=_kwargs: func(**_k),
|
|
456
456
|
)
|
|
457
457
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
458
|
+
if result_model is not None:
|
|
459
|
+
if _is_pydantic_model_instance(result):
|
|
460
|
+
# mode="json": python-mode dumps leave nested datetime/UUID as
|
|
461
|
+
# objects that break json.dumps in JSONResponse.
|
|
462
|
+
return result.model_dump(mode="json")
|
|
463
|
+
# Also serialize list[BaseModel]: a single BaseModel check fails for the
|
|
464
|
+
# wrapping list, leaving Pydantic instances inside un-dumped.
|
|
465
|
+
if isinstance(result, list):
|
|
466
|
+
return [
|
|
467
|
+
item.model_dump(mode="json") if _is_pydantic_model_instance(item) else item
|
|
468
|
+
for item in result
|
|
469
|
+
]
|
|
461
470
|
|
|
462
471
|
return result
|
|
463
472
|
|
|
@@ -486,10 +495,30 @@ def _python_type_to_json_type(value: Any) -> str | None:
|
|
|
486
495
|
|
|
487
496
|
|
|
488
497
|
def _elapsed_ms(start_time: float) -> float:
|
|
489
|
-
"""Return milliseconds since ``start_time`` as
|
|
498
|
+
"""Return milliseconds since ``start_time`` as an unrounded float.
|
|
499
|
+
|
|
500
|
+
Callers that need a fixed-precision value must round themselves
|
|
501
|
+
(e.g. ``_log_rpc`` rounds to 0.01ms before emitting the log record).
|
|
502
|
+
"""
|
|
490
503
|
return (time.perf_counter() - start_time) * 1000
|
|
491
504
|
|
|
492
505
|
|
|
506
|
+
def _summarize_results(
|
|
507
|
+
results: list[dict | None],
|
|
508
|
+
) -> tuple[str, int | None, str | None]:
|
|
509
|
+
"""Reduce a dispatch-result list to (status, error_code, error_message) for logging.
|
|
510
|
+
|
|
511
|
+
- If any non-None result carries an ``"error"`` key, return
|
|
512
|
+
``("error", first_error_code, first_error_message)``.
|
|
513
|
+
- Otherwise return ``("ok", None, None)``.
|
|
514
|
+
"""
|
|
515
|
+
first_error = next((r for r in results if r is not None and "error" in r), None)
|
|
516
|
+
if first_error is not None:
|
|
517
|
+
err = first_error["error"]
|
|
518
|
+
return ("error", err.get("code"), err.get("message"))
|
|
519
|
+
return ("ok", None, None)
|
|
520
|
+
|
|
521
|
+
|
|
493
522
|
def _log_rpc(
|
|
494
523
|
method: str | None,
|
|
495
524
|
request_id: Any,
|
|
@@ -546,7 +575,6 @@ def _build_rpc_discover_response(
|
|
|
546
575
|
config: dict[str, Any],
|
|
547
576
|
) -> dict[str, Any]:
|
|
548
577
|
"""Build rpc.discover response using the specified OpenRPC document configuration."""
|
|
549
|
-
# Lazy import to avoid circular dependency
|
|
550
578
|
from fastapi_openrpc.document import build_openrpc_document
|
|
551
579
|
|
|
552
580
|
doc = build_openrpc_document(
|
|
@@ -621,44 +649,41 @@ async def _handle_batch(
|
|
|
621
649
|
) -> JSONResponse | Response:
|
|
622
650
|
"""Handle a batch JSON-RPC request."""
|
|
623
651
|
if len(data) > MAX_BATCH_SIZE:
|
|
652
|
+
msg = f"Batch size {len(data)} exceeds limit of {MAX_BATCH_SIZE}"
|
|
624
653
|
_log_rpc(
|
|
625
654
|
None,
|
|
626
655
|
None,
|
|
627
656
|
"error",
|
|
628
657
|
_elapsed_ms(start_time),
|
|
629
658
|
error_code=JSONRPC_INVALID_PARAMS,
|
|
630
|
-
error_message=
|
|
631
|
-
)
|
|
632
|
-
return _json_error(
|
|
633
|
-
JSONRPC_INVALID_PARAMS,
|
|
634
|
-
"Invalid params",
|
|
635
|
-
f"Batch size {len(data)} exceeds limit of {MAX_BATCH_SIZE}",
|
|
659
|
+
error_message=msg,
|
|
636
660
|
)
|
|
661
|
+
return _json_error(JSONRPC_INVALID_PARAMS, "Invalid params", msg)
|
|
637
662
|
if not data:
|
|
663
|
+
msg = "Batch array must not be empty"
|
|
638
664
|
_log_rpc(
|
|
639
665
|
None,
|
|
640
666
|
None,
|
|
641
667
|
"error",
|
|
642
668
|
_elapsed_ms(start_time),
|
|
643
669
|
error_code=JSONRPC_INVALID_REQUEST,
|
|
644
|
-
error_message=
|
|
645
|
-
)
|
|
646
|
-
return _json_error(
|
|
647
|
-
JSONRPC_INVALID_REQUEST,
|
|
648
|
-
"Invalid Request",
|
|
649
|
-
"Batch array must not be empty",
|
|
670
|
+
error_message=msg,
|
|
650
671
|
)
|
|
672
|
+
return _json_error(JSONRPC_INVALID_REQUEST, "Invalid Request", msg)
|
|
651
673
|
|
|
652
674
|
results = await asyncio.gather(
|
|
653
675
|
*[dispatch_one(item, dispatcher, openrpc_doc_config) for item in data]
|
|
654
676
|
)
|
|
655
677
|
if all(r is None for r in results):
|
|
656
678
|
return Response(status_code=204)
|
|
679
|
+
status, error_code, error_message = _summarize_results(results)
|
|
657
680
|
_log_rpc(
|
|
658
681
|
None,
|
|
659
682
|
None,
|
|
660
|
-
|
|
683
|
+
status,
|
|
661
684
|
_elapsed_ms(start_time),
|
|
685
|
+
error_code=error_code,
|
|
686
|
+
error_message=error_message,
|
|
662
687
|
)
|
|
663
688
|
return JSONResponse(
|
|
664
689
|
status_code=200,
|
|
@@ -704,22 +729,28 @@ async def _handle_single(
|
|
|
704
729
|
resp = await dispatcher.dispatch(data)
|
|
705
730
|
if resp is None:
|
|
706
731
|
return Response(status_code=204)
|
|
732
|
+
status, error_code, error_message = _summarize_results([resp])
|
|
707
733
|
_log_rpc(
|
|
708
734
|
data.get("method"),
|
|
709
735
|
resp.get("id"),
|
|
710
|
-
|
|
736
|
+
status,
|
|
711
737
|
_elapsed_ms(start_time),
|
|
738
|
+
error_code=error_code,
|
|
739
|
+
error_message=error_message,
|
|
712
740
|
)
|
|
713
741
|
return _json_ok(resp)
|
|
714
742
|
|
|
715
743
|
resp = await dispatcher.dispatch(data)
|
|
716
744
|
if resp is None:
|
|
717
745
|
return Response(status_code=204)
|
|
746
|
+
status, error_code, error_message = _summarize_results([resp])
|
|
718
747
|
_log_rpc(
|
|
719
748
|
data.get("method"),
|
|
720
749
|
resp.get("id"),
|
|
721
|
-
|
|
750
|
+
status,
|
|
722
751
|
_elapsed_ms(start_time),
|
|
752
|
+
error_code=error_code,
|
|
753
|
+
error_message=error_message,
|
|
723
754
|
)
|
|
724
755
|
return _json_ok(resp)
|
|
725
756
|
|
|
@@ -875,7 +906,6 @@ class OpenRpcRouter(APIRouter):
|
|
|
875
906
|
|
|
876
907
|
super().__init__(dependencies=dependencies, lifespan=cast(Any, _lifespan))
|
|
877
908
|
|
|
878
|
-
# Register routes
|
|
879
909
|
self.add_api_route(
|
|
880
910
|
"/openrpc.json",
|
|
881
911
|
self._openrpc_doc_handler,
|
|
@@ -931,7 +961,6 @@ class OpenRpcRouter(APIRouter):
|
|
|
931
961
|
|
|
932
962
|
async def _openrpc_doc_handler(self) -> JSONResponse:
|
|
933
963
|
"""Return OpenRPC document."""
|
|
934
|
-
# Lazy import to avoid circular dependency
|
|
935
964
|
from fastapi_openrpc.document import build_openrpc_document
|
|
936
965
|
|
|
937
966
|
doc = build_openrpc_document(
|
|
@@ -944,7 +973,7 @@ class OpenRpcRouter(APIRouter):
|
|
|
944
973
|
terms_of_service=self.terms_of_service,
|
|
945
974
|
openrpc_version=self.openrpc_version,
|
|
946
975
|
)
|
|
947
|
-
return JSONResponse(content=doc)
|
|
976
|
+
return JSONResponse(status_code=200, content=doc)
|
|
948
977
|
|
|
949
978
|
async def _inject_openrpc_doc(self, request: Request) -> None:
|
|
950
979
|
"""FastAPI dependency: inject this router's OpenRPC document config and
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|