fastapi-openrpc 0.1.3__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.
@@ -0,0 +1,517 @@
1
+ """@method() decorator and type inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import types
7
+ import typing
8
+ from collections.abc import Callable
9
+ from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
10
+
11
+ from fastapi_openrpc.registry import add
12
+ from fastapi_openrpc.types import MethodMeta, Require, _is_rpc_context_param
13
+
14
+ # ----------------------------------------------------------------------
15
+ # Type Inference
16
+ # ----------------------------------------------------------------------
17
+
18
+
19
+ def _get_json_schema_type(python_type: type[Any] | None) -> str:
20
+ """Map Python type to JSON Schema type string."""
21
+ if python_type is None:
22
+ return "object"
23
+ # Check type aliases
24
+ type_name = getattr(python_type, "__name__", str(python_type))
25
+ if type_name in ("str", "String"):
26
+ return "string"
27
+ if type_name in ("int", "Integer", "int64", "int32"):
28
+ return "integer"
29
+ if type_name in ("float", "Float", "double", "Number"):
30
+ return "number"
31
+ if type_name in ("bool", "Boolean"):
32
+ return "boolean"
33
+ if type_name in ("list", "List", "tuple", "Tuple"):
34
+ return "array"
35
+ if type_name in ("dict", "Dict", "Mapping"):
36
+ return "object"
37
+ return "object"
38
+
39
+
40
+ def _infer_param_schema(annotation: type[Any] | None) -> dict[str, Any]:
41
+ """Infer JSON Schema from a single parameter annotation.
42
+
43
+ - Pydantic BaseModel -> recursively use model_json_schema()
44
+ - Basic types -> direct mapping
45
+ - Optional[T] -> extract inner type
46
+ - List[T] / dict etc -> type: array / object
47
+ - No annotation -> {"type": "object"}
48
+ """
49
+ import typing
50
+
51
+ if annotation is None:
52
+ return {"type": "object"}
53
+
54
+ # Prefer typing.get_origin/get_args (Python 3.8+), overriding getattr version
55
+ # because get_origin correctly handles Optional[str] (__origin__=None but
56
+ # get_origin returns Union)
57
+ try:
58
+ origin = typing.get_origin(annotation)
59
+ args = typing.get_args(annotation)
60
+ except Exception:
61
+ origin = getattr(annotation, "__origin__", None)
62
+ args = getattr(annotation, "__args__", ())
63
+
64
+ # Optional[X] -> X (Union with None)
65
+ if origin is not None:
66
+ # typing.Optional / typing.Union[X, None]
67
+ none_type = type(None)
68
+ non_none = [a for a in args if a is not none_type]
69
+ if len(non_none) == 1 and none_type in args:
70
+ # Optional[T]
71
+ return _infer_param_schema(non_none[0])
72
+
73
+ # List[T] / tuple
74
+ if origin is list or origin is tuple:
75
+ if args:
76
+ return {
77
+ "type": "array",
78
+ "items": _infer_param_schema(args[0]),
79
+ }
80
+ return {"type": "array"}
81
+
82
+ # Dict[str, T]
83
+ if origin is dict:
84
+ if len(args) >= 2:
85
+ return {
86
+ "type": "object",
87
+ "additionalProperties": _infer_param_schema(args[1]),
88
+ }
89
+ return {"type": "object"}
90
+
91
+ # Set[T]
92
+ if origin is set:
93
+ if args:
94
+ return {
95
+ "type": "array",
96
+ "items": _infer_param_schema(args[0]),
97
+ "uniqueItems": True,
98
+ }
99
+ return {"type": "array"}
100
+
101
+ # Pydantic V2 BaseModel -> expand fields to top-level properties
102
+ if _is_pydantic_model(annotation):
103
+ return _expand_pydantic_model_to_params(annotation)
104
+
105
+ # typing.NewType -> extract underlying type
106
+ if hasattr(annotation, "__supertype__"):
107
+ return _infer_param_schema(annotation.__supertype__)
108
+
109
+ return {"type": _get_json_schema_type(annotation)}
110
+
111
+
112
+ def _is_pydantic_model(annotation: type[Any]) -> bool:
113
+ """Check if annotation is a Pydantic BaseModel subclass."""
114
+ # Avoid importing pydantic at module level (reduce import overhead)
115
+ try:
116
+ from pydantic import BaseModel
117
+
118
+ return isinstance(annotation, type) and issubclass(annotation, BaseModel)
119
+ except ImportError:
120
+ return False
121
+
122
+
123
+ def _unwrap_union(annotation: type[Any]) -> type[Any] | None:
124
+ """Extract the first non-None type from a Union type (e.g., Optional[T], T | None)."""
125
+ try:
126
+ origin = typing.get_origin(annotation)
127
+ args = typing.get_args(annotation)
128
+ except (TypeError, AttributeError):
129
+ origin = getattr(annotation, "__origin__", None)
130
+ args = getattr(annotation, "__args__", ())
131
+
132
+ # Handle typing.Union (Optional[T]) and types.UnionType (T | None)
133
+ if origin is typing.Union or origin is types.UnionType:
134
+ for arg in args:
135
+ if arg is not type(None):
136
+ return cast(type[Any], arg)
137
+ return None
138
+
139
+
140
+ def _expand_pydantic_model_to_params(model_cls: type) -> dict[str, Any]:
141
+ """Expand a Pydantic BaseModel's fields into an OpenRPC params SchemaObject.
142
+
143
+ When a Pydantic model is used to describe parameter structure (e.g., handler
144
+ parameter is GetSchemaRequest), each field corresponds to one OpenRPC parameter.
145
+
146
+ For example, GetSchemaRequest(qualified_name: str) generates:
147
+ {
148
+ "type": "object",
149
+ "properties": {"qualified_name": {"type": "string", "description": "..."}},
150
+ "required": ["qualified_name"]
151
+ }
152
+
153
+ This way the JSON-RPC request still uses a flat structure:
154
+ {"jsonrpc": "2.0", "method": "getSchema", "params": {"qualified_name": "..."}}
155
+ """
156
+ schema = _pydantic_model_schema(model_cls)
157
+ properties = schema.get("properties", {})
158
+
159
+ # Collect required fields (Pydantic model_json_schema omits top-level required;
160
+ # derive from fields)
161
+ model_required = schema.get("required", [])
162
+ if not model_required:
163
+ # Get required info via model_fields
164
+ try:
165
+ if hasattr(model_cls, "model_fields"):
166
+ model_required = [
167
+ name
168
+ for name, field_info in model_cls.model_fields.items()
169
+ if field_info.is_required()
170
+ ]
171
+ except Exception:
172
+ pass
173
+
174
+ return {
175
+ "type": "object",
176
+ "properties": properties,
177
+ "required": model_required,
178
+ }
179
+
180
+
181
+ def _pydantic_model_schema(model_cls: type) -> dict[str, Any]:
182
+ """Generate JSON Schema using Pydantic V2 model_json_schema()."""
183
+ try:
184
+ from pydantic import BaseModel
185
+
186
+ if not issubclass(model_cls, BaseModel):
187
+ return {"type": "object"}
188
+ # Pydantic V2
189
+ return model_cls.model_json_schema()
190
+ except (ImportError, AttributeError):
191
+ # Pydantic V1 fallback
192
+ try:
193
+ return cast("BaseModel", model_cls).schema()
194
+ except AttributeError:
195
+ return {"type": "object"}
196
+
197
+
198
+ def infer_params_schema(func: Callable[..., Any]) -> dict[str, Any]:
199
+ """Infer parameter JSON Schema from function signature.
200
+
201
+ Uses typing.get_type_hints() to resolve PEP 563 deferred annotations
202
+ (from __future__ import annotations), ensuring correct type inference even in
203
+ modules with that future directive.
204
+
205
+ Args:
206
+ func: The decorated handler function.
207
+
208
+ Returns:
209
+ OpenRPC params SchemaObject:
210
+ {
211
+ "type": "object",
212
+ "properties": {<name>: <schema>},
213
+ "required": [<name>, ...]
214
+ }
215
+ """
216
+ # get_type_hints() resolves deferred annotation strings;
217
+ # include_extras=True preserves Annotated wrappers
218
+ type_hints: dict[str, Any]
219
+ try:
220
+ type_hints = get_type_hints(func, include_extras=True)
221
+ except NameError:
222
+ # Some annotations cannot be resolved (e.g., locally defined classes);
223
+ # fall back to inspect.signature, still try to resolve types accessible from
224
+ # func.__globals__ (e.g., RpcContext)
225
+ sig = inspect.signature(func)
226
+ type_hints = {}
227
+ for p in sig.parameters.values():
228
+ if p.annotation is inspect.Parameter.empty:
229
+ continue
230
+ ann: Any = p.annotation
231
+ # Try to resolve string annotations (only from function global scope,
232
+ # not eval local scope)
233
+ if isinstance(ann, str):
234
+ try:
235
+ ann = eval(ann, func.__globals__)
236
+ except Exception:
237
+ pass # Keep string; _is_rpc_context_param will handle it specially
238
+ if get_origin(ann) is Annotated:
239
+ ann = get_args(ann)[0]
240
+ type_hints[p.name] = ann
241
+
242
+ sig = inspect.signature(func)
243
+ properties: dict[str, Any] = {}
244
+ required: list[str] = []
245
+
246
+ for param_name, param in sig.parameters.items():
247
+ # Skip FastAPI Depends parameters (injected by OpenRpcRouter, not part of RPC params)
248
+ from fastapi.params import Depends as FastAPIDepends
249
+
250
+ if isinstance(param.default, FastAPIDepends):
251
+ continue
252
+
253
+ annotation = type_hints.get(param_name)
254
+
255
+ # Skip RpcContext parameters (injected by dispatcher, not part of RPC params)
256
+ if _is_rpc_context_param(annotation):
257
+ continue
258
+
259
+ # Pydantic BaseModel parameter: expand model fields to top-level.
260
+ # For example handler(params: GetSchemaRequest) -> params_schema only has
261
+ # qualified_name field, not nested in params object. This way JSON-RPC flat
262
+ # params {"qualified_name": "..."} can pass through _validate_params directly,
263
+ # and dispatcher packs it into a model instance at runtime.
264
+ if annotation is not None and _is_pydantic_model(cast(type[Any], annotation)):
265
+ expanded = _expand_pydantic_model_to_params(cast(type[Any], annotation))
266
+ # Merge fields into top-level properties and required
267
+ for fname, fschema in expanded.get("properties", {}).items():
268
+ properties[fname] = fschema
269
+ for fname in expanded.get("required", []):
270
+ required.append(fname)
271
+ continue
272
+
273
+ schema = _infer_param_schema(annotation)
274
+ properties[param_name] = schema
275
+
276
+ # required: no default value and not Optional
277
+ if param.default is inspect.Parameter.empty:
278
+ required.append(param_name)
279
+ elif param.default is not None:
280
+ # Has default value -> set as optional, provide default
281
+ properties[param_name]["default"] = param.default
282
+
283
+ return {
284
+ "type": "object",
285
+ "properties": properties,
286
+ "required": list(dict.fromkeys(required)),
287
+ }
288
+
289
+
290
+ def infer_result_schema(func: Callable[..., Any]) -> dict[str, Any]:
291
+ """Infer return value JSON Schema from function signature.
292
+
293
+ Uses typing.get_type_hints() to resolve PEP 563 deferred annotations.
294
+
295
+ - Pydantic BaseModel -> model_json_schema()
296
+ - Basic types -> direct mapping
297
+ - No annotation -> {"type": "object"}
298
+ """
299
+ import typing
300
+
301
+ # Use get_type_hints to resolve PEP 563 deferred annotations
302
+ try:
303
+ type_hints = typing.get_type_hints(func)
304
+ return_type = type_hints.get("return")
305
+ except Exception:
306
+ sig = inspect.signature(func)
307
+ return_type = sig.return_annotation
308
+
309
+ if return_type is None:
310
+ return {"type": "object"}
311
+
312
+ # Pydantic BaseModel
313
+ if _is_pydantic_model(return_type):
314
+ return _pydantic_model_schema(return_type)
315
+
316
+ # typing.List[T]
317
+ try:
318
+ origin = typing.get_origin(return_type)
319
+ args = typing.get_args(return_type)
320
+ except Exception:
321
+ origin = getattr(return_type, "__origin__", None)
322
+ args = getattr(return_type, "__args__", ())
323
+ if origin is list or origin is tuple:
324
+ if args:
325
+ return {
326
+ "type": "array",
327
+ "items": _infer_param_schema(args[0]),
328
+ }
329
+ return {"type": "array"}
330
+
331
+ # Union type (e.g., str | None, int | None) -- extract non-None type
332
+ # _infer_param_schema already handles Optional[T], reuse it
333
+ if origin is not None:
334
+ none_type = type(None)
335
+ non_none = [a for a in args if a is not none_type]
336
+ if len(non_none) == 1 and none_type in args:
337
+ return _infer_param_schema(non_none[0])
338
+
339
+ return {"type": _get_json_schema_type(return_type)}
340
+
341
+
342
+ # ----------------------------------------------------------------------
343
+ # @method() Decorator
344
+ # ----------------------------------------------------------------------
345
+
346
+
347
+ def method(
348
+ *,
349
+ name: str | None = None,
350
+ tags: list[str] | None = None,
351
+ params_schema: dict[str, Any] | None = None,
352
+ params_model: type | None = None,
353
+ result_schema: dict[str, Any] | None = None,
354
+ result_model: type | None = None,
355
+ summary: str | None = None,
356
+ description: str | None = None,
357
+ deprecated: bool = False,
358
+ examples: list[dict[str, Any]] | None = None,
359
+ required_roles: list[str] | None = None,
360
+ require: Require | None = None,
361
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
362
+ """Declaratively register a JSON-RPC method.
363
+
364
+ Example::
365
+
366
+ from pydantic import BaseModel
367
+
368
+ class User(BaseModel):
369
+ id: str
370
+ name: str
371
+
372
+ @method(name="getUser", result_model=User)
373
+ async def get_user(ctx: RpcContext, user_id: str) -> User:
374
+ '''Get user information.'''
375
+ return User(id=user_id, name="Alice")
376
+
377
+ Args:
378
+ name: RPC method name. Defaults to function __name__.
379
+ tags: OpenRPC method tag grouping.
380
+ params_schema: Explicit parameter schema, overrides automatic inference.
381
+ result_schema: Explicit return value schema, overrides automatic inference.
382
+ result_model: Pydantic BaseModel subclass; automatically infers result_schema and
383
+ calls .model_dump() to serialize the return value.
384
+ summary: Method short description (for documentation).
385
+ description: Method detailed description. Inferred from docstring by default.
386
+ deprecated: Whether to mark as deprecated.
387
+ examples: List of parameter and return value examples.
388
+ required_roles: List of roles required by the method; None means no permission check.
389
+ require: Role combination semantics, Require.ANY (default) or Require.ALL.
390
+ """
391
+ # rpc.discover is a reserved method name
392
+ _name = name or ""
393
+ if _name == "rpc.discover":
394
+ raise ValueError("'rpc.discover' is a reserved method name and cannot be registered.")
395
+
396
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
397
+ # Infer schema (pass func directly; let get_type_hints resolve PEP 563 annotations)
398
+ inferred_params = infer_params_schema(func)
399
+ inferred_result = infer_result_schema(func)
400
+
401
+ # Infer description from docstring
402
+ inferred_description = inspect.getdoc(func)
403
+
404
+ final_name = name or func.__name__
405
+ final_description = description if description is not None else inferred_description
406
+
407
+ # result_model handling: generate schema + mark serialization type
408
+ final_result_schema = result_schema
409
+ resolved_result_model: type | None = None
410
+
411
+ # Priority: explicit argument > automatic inference
412
+ if resolved_result_model is None:
413
+ # Infer result_model from return type annotation
414
+ # (supports -> UnifiedSchemaResponse syntax)
415
+ try:
416
+ import typing
417
+
418
+ type_hints = typing.get_type_hints(func)
419
+ return_type = type_hints.get("return")
420
+ if return_type is not None:
421
+ # Handle Union types like Optional[User], User | None
422
+ unwrapped = _unwrap_union(return_type)
423
+ if _is_pydantic_model(return_type):
424
+ resolved_result_model = cast(type[Any], return_type)
425
+ if result_schema is None:
426
+ final_result_schema = _pydantic_model_schema(
427
+ cast(type[Any], return_type)
428
+ )
429
+ elif unwrapped is not None and _is_pydantic_model(unwrapped):
430
+ # Union type contains Pydantic model (e.g., User | None)
431
+ resolved_result_model = unwrapped
432
+ if result_schema is None:
433
+ final_result_schema = _pydantic_model_schema(unwrapped)
434
+ except Exception:
435
+ pass # Silent fallback on parse failure; maintain backward compatibility
436
+
437
+ # Explicit result_model passed but result_schema not passed -> generate schema
438
+ if result_model is not None and result_schema is None:
439
+ final_result_schema = _pydantic_model_schema(result_model)
440
+
441
+ # params_model inference: detect first Pydantic BaseModel parameter in handler signature
442
+ resolved_params_model: type | None = None
443
+ if params_model is None:
444
+ # params_model not explicitly passed; infer from function signature
445
+ import typing as _typing
446
+
447
+ try:
448
+ _hints = _typing.get_type_hints(func, include_extras=True)
449
+ except Exception:
450
+ _hints = {}
451
+ sig = inspect.signature(func)
452
+
453
+ for _param_name, _param in sig.parameters.items():
454
+ _annotation = _hints.get(_param_name)
455
+ # Try to resolve string annotations
456
+ if _annotation is None or isinstance(_annotation, str):
457
+ _raw = sig.parameters[_param_name].annotation
458
+ if not isinstance(_raw, str):
459
+ _raw = str(_raw)
460
+ try:
461
+ _resolved = eval(_raw, func.__globals__)
462
+ if _annotation is None:
463
+ _annotation = _resolved
464
+ except Exception:
465
+ pass
466
+ if _is_rpc_context_param(_annotation):
467
+ continue
468
+ from fastapi.params import Depends as _FastAPIDepends
469
+
470
+ if isinstance(_param.default, _FastAPIDepends):
471
+ continue
472
+ if _annotation is not None and _is_pydantic_model(cast(type[Any], _annotation)):
473
+ if resolved_params_model is None:
474
+ # Only take the first Pydantic parameter;
475
+ # subsequent params_model remain None
476
+ resolved_params_model = cast(type, _annotation)
477
+ else:
478
+ # Multiple Pydantic parameters: degrade to inline behavior (no params_model)
479
+ resolved_params_model = None
480
+ break
481
+ else:
482
+ resolved_params_model = params_model
483
+
484
+ # Handle required_roles: deduplicate + lowercase
485
+ final_required_roles: list[str] = []
486
+ if required_roles is not None:
487
+ final_required_roles = list(dict.fromkeys(r.lower() for r in required_roles))
488
+
489
+ # Handle require: default Require.ANY
490
+ final_require: Require = Require.ANY
491
+ if require is not None:
492
+ final_require = require
493
+
494
+ meta = MethodMeta(
495
+ name=final_name,
496
+ func=func,
497
+ params_schema=(params_schema if params_schema is not None else inferred_params),
498
+ result_schema=(
499
+ final_result_schema if final_result_schema is not None else inferred_result
500
+ ),
501
+ result_model=resolved_result_model,
502
+ params_model=resolved_params_model,
503
+ tags=list(tags) if tags else [],
504
+ summary=summary,
505
+ description=final_description,
506
+ deprecated=deprecated,
507
+ examples=list(examples) if examples else [],
508
+ required_roles=final_required_roles,
509
+ require=final_require,
510
+ )
511
+ add(meta)
512
+ return func
513
+
514
+ return decorator
515
+
516
+
517
+ __all__ = ["method", "infer_params_schema", "infer_result_schema"]