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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fastapi-openrpc
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: FastAPI JSON-RPC 2.0 + OpenRPC 1.0 service discovery plugin
5
5
  Keywords: fastapi,json-rpc,openrpc,rpc,discover,ai-agent
6
6
  Author: Ye Wenbin
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-openrpc"
3
- version = "0.1.7"
3
+ version = "0.2.0"
4
4
  description = "FastAPI JSON-RPC 2.0 + OpenRPC 1.0 service discovery plugin"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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
- ctx: Caller context containing roles and require fields.
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 ctx.require == Require.ALL:
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
- try:
127
- validated = model_cls.model_validate(subdict)
128
- # Remove extracted fields from kwargs and add the model instance
129
- for f in subdict:
130
- del kwargs[f]
131
- kwargs[param_name] = validated
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 is None:
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.context.require = meta.require
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": 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 is automatically
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
- # Pydantic BaseModel serialization
459
- if result_model is not None and _is_pydantic_model_instance(result):
460
- return result.model_dump()
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 a float, rounded to 0.01ms."""
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=f"Batch size {len(data)} exceeds limit of {MAX_BATCH_SIZE}",
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="Batch array must not be empty",
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
- "ok",
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
- "ok",
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
- "ok",
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
@@ -64,7 +64,6 @@ class RpcContext:
64
64
  """
65
65
 
66
66
  roles: list[str] = field(default_factory=list)
67
- require: Require = Require.ANY
68
67
  user_id: str | None = None
69
68
 
70
69