python-jsonrpc-lib 0.3.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.
jsonrpc/openapi.py ADDED
@@ -0,0 +1,505 @@
1
+ """OpenAPI documentation generator for JSON-RPC methods."""
2
+
3
+ import types
4
+ from dataclasses import MISSING, fields, is_dataclass
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Literal,
9
+ Union,
10
+ get_args,
11
+ get_origin,
12
+ get_type_hints,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from .jsonrpc import JSONRPC
17
+
18
+
19
+ def _get_docstring(obj: Any) -> str | None:
20
+ doc: str | None = getattr(obj, '__doc__', None)
21
+ if doc:
22
+ return doc.strip()
23
+ return None
24
+
25
+
26
+ def _type_to_jsonschema(t: type, schemas: dict[str, Any]) -> dict[str, Any]:
27
+ """Convert Python type annotation to JSON Schema.
28
+
29
+ Args:
30
+ t: Type annotation
31
+ schemas: Dict to add nested schemas to
32
+
33
+ Returns:
34
+ JSON Schema dict
35
+ """
36
+ origin = get_origin(t)
37
+
38
+ if t is type(None):
39
+ return {'type': 'null'}
40
+
41
+ if origin is Union or isinstance(t, types.UnionType):
42
+ args = get_args(t)
43
+ # Check for Optional (T | None)
44
+ if len(args) == 2 and type(None) in args:
45
+ other = args[0] if args[1] is type(None) else args[1]
46
+ other_schema = _type_to_jsonschema(other, schemas)
47
+ return {'oneOf': [other_schema, {'type': 'null'}]}
48
+ return {'oneOf': [_type_to_jsonschema(a, schemas) for a in args]}
49
+
50
+ if origin is Literal:
51
+ args = get_args(t)
52
+ return {'enum': list(args)}
53
+
54
+ if origin is list:
55
+ args = get_args(t)
56
+ if args:
57
+ return {
58
+ 'type': 'array',
59
+ 'items': _type_to_jsonschema(args[0], schemas),
60
+ }
61
+ return {'type': 'array'}
62
+
63
+ if origin is dict:
64
+ args = get_args(t)
65
+ if args and len(args) == 2:
66
+ return {
67
+ 'type': 'object',
68
+ 'additionalProperties': _type_to_jsonschema(args[1], schemas),
69
+ }
70
+ return {'type': 'object'}
71
+
72
+ if t is int:
73
+ return {'type': 'integer'}
74
+ if t is float:
75
+ return {'type': 'number'}
76
+ if t is str:
77
+ return {'type': 'string'}
78
+ if t is bool:
79
+ return {'type': 'boolean'}
80
+ if t is Any:
81
+ return {}
82
+
83
+ if is_dataclass(t) and isinstance(t, type):
84
+ schema_name = t.__name__
85
+ if schema_name not in schemas:
86
+ schemas[schema_name] = _dataclass_to_jsonschema(t, schemas)
87
+ return {'$ref': f'#/components/schemas/{schema_name}'}
88
+
89
+ return {}
90
+
91
+
92
+ def _dataclass_to_jsonschema(dc: type, schemas: dict[str, Any]) -> dict[str, Any]:
93
+ """Convert dataclass to JSON Schema.
94
+
95
+ Args:
96
+ dc: Dataclass type
97
+ schemas: Dict to add nested schemas to
98
+
99
+ Returns:
100
+ JSON Schema dict
101
+ """
102
+ if not is_dataclass(dc):
103
+ raise ValueError(f'Expected dataclass, got {type(dc).__name__}')
104
+
105
+ type_hints = get_type_hints(dc)
106
+ field_list = fields(dc)
107
+
108
+ properties: dict[str, Any] = {}
109
+ required: list[str] = []
110
+
111
+ for f in field_list:
112
+ field_type = type_hints.get(f.name, Any)
113
+ field_schema = _type_to_jsonschema(field_type, schemas)
114
+
115
+ if f.metadata and 'description' in f.metadata:
116
+ field_schema['description'] = f.metadata['description']
117
+
118
+ properties[f.name] = field_schema
119
+
120
+ if f.default is MISSING and f.default_factory is MISSING:
121
+ required.append(f.name)
122
+
123
+ schema: dict[str, Any] = {
124
+ 'type': 'object',
125
+ 'properties': properties,
126
+ }
127
+
128
+ if required:
129
+ schema['required'] = required
130
+
131
+ doc = _get_docstring(dc)
132
+ if doc:
133
+ schema['description'] = doc
134
+
135
+ return schema
136
+
137
+
138
+ class OpenAPIGenerator:
139
+ """Generates OpenAPI 3.0 specification from registered JSON-RPC methods."""
140
+
141
+ def __init__(
142
+ self,
143
+ rpc: 'JSONRPC',
144
+ base_url: str = '/jsonrpc',
145
+ title: str = 'JSON-RPC API',
146
+ version: str = '1.0.0',
147
+ description: str | None = None,
148
+ simplify_id: bool = True,
149
+ ) -> None:
150
+ """Initialize OpenAPI generator.
151
+
152
+ Args:
153
+ rpc: JSONRPC instance with registered methods
154
+ base_url: Base URL path for the JSON-RPC endpoint
155
+ title: API title
156
+ version: API version
157
+ description: API description
158
+ simplify_id: If True (default), represent id as {"type": "integer"} in schemas.
159
+ If False, use {"oneOf": [{"type": "string"}, {"type": "integer"}]} per spec.
160
+ Simplification reduces duplicate examples in documentation viewers (e.g. RapiDoc),
161
+ where each oneOf variant generates a separate example differing only by id type.
162
+ """
163
+ self.rpc = rpc
164
+ self.base_url = base_url.rstrip('/')
165
+ self.title = title
166
+ self.version = version
167
+ self.description = description
168
+ self.simplify_id = simplify_id
169
+ self.security_schemes: dict[str, dict[str, Any]] = {}
170
+ self.global_headers: list[dict[str, Any]] = []
171
+ self.global_security: list[dict[str, list[str]]] = []
172
+
173
+ def add_security_scheme(
174
+ self,
175
+ name: str,
176
+ scheme_type: Literal['apiKey', 'http', 'oauth2', 'openIdConnect'],
177
+ **kwargs: Any,
178
+ ) -> None:
179
+ """Add authentication/security scheme.
180
+
181
+ Args:
182
+ name: Scheme name (used in security requirements)
183
+ scheme_type: Type of security scheme
184
+ **kwargs: Additional scheme properties (scheme, bearerFormat, in, name, etc.)
185
+
186
+ Example:
187
+ >>> openapi.add_security_scheme(
188
+ ... "bearerAuth",
189
+ ... scheme_type="http",
190
+ ... scheme="bearer",
191
+ ... bearerFormat="JWT"
192
+ ... )
193
+ """
194
+ scheme: dict[str, Any] = {'type': scheme_type}
195
+ scheme.update(kwargs)
196
+ self.security_schemes[name] = scheme
197
+
198
+ def add_header(
199
+ self,
200
+ name: str,
201
+ description: str,
202
+ required: bool = False,
203
+ schema: dict[str, Any] | None = None,
204
+ ) -> None:
205
+ """Add global header parameter.
206
+
207
+ Args:
208
+ name: Header name (e.g., "Authorization")
209
+ description: Header description
210
+ required: Whether header is required
211
+ schema: JSON Schema for header value
212
+
213
+ Example:
214
+ >>> openapi.add_header(
215
+ ... name="Authorization",
216
+ ... description="Bearer token for authentication",
217
+ ... required=True,
218
+ ... schema={"type": "string", "pattern": "^Bearer .+$"}
219
+ ... )
220
+ """
221
+ header: dict[str, Any] = {
222
+ 'name': name,
223
+ 'in': 'header',
224
+ 'description': description,
225
+ 'required': required,
226
+ 'schema': schema or {'type': 'string'},
227
+ }
228
+ self.global_headers.append(header)
229
+
230
+ def add_security_requirement(
231
+ self,
232
+ scheme_name: str,
233
+ scopes: list[str] | None = None,
234
+ ) -> None:
235
+ """Add global security requirement.
236
+
237
+ Args:
238
+ scheme_name: Name of security scheme to require
239
+ scopes: OAuth2 scopes (if applicable)
240
+ """
241
+ self.global_security.append({scheme_name: scopes or []})
242
+
243
+ def _construct_method_path(self, prefix: str, method_name: str) -> str:
244
+ """Construct path for method.
245
+
246
+ Args:
247
+ prefix: Method group prefix (e.g., "math")
248
+ method_name: Method name (e.g., "add")
249
+
250
+ Returns:
251
+ Path string (e.g., "/jsonrpc#math.add")
252
+ """
253
+ full_name = f'{prefix}.{method_name}' if prefix else method_name
254
+ return f'{self.base_url}#{full_name}'
255
+
256
+ def _get_method_summary(self, method: Any) -> str:
257
+ """Extract first line of docstring as summary.
258
+
259
+ Args:
260
+ method: Method instance
261
+
262
+ Returns:
263
+ Summary string
264
+ """
265
+ doc = _get_docstring(method.__class__)
266
+ if doc:
267
+ return doc.split('\n')[0].strip()
268
+ # Fallback to class name if no docstring
269
+ name: str = method.__class__.__name__
270
+ return name
271
+
272
+ def _generate_from_group(
273
+ self,
274
+ group: Any,
275
+ prefix: str,
276
+ schemas: dict[str, Any],
277
+ paths: dict[str, Any],
278
+ tags: list[dict[str, str]],
279
+ seen_tags: set[str],
280
+ ) -> None:
281
+ """Recursively generate OpenAPI entries from group tree.
282
+
283
+ This method traverses the MethodGroup hierarchy and generates
284
+ OpenAPI paths for all methods in the tree.
285
+
286
+ Args:
287
+ group: Current MethodGroup
288
+ prefix: Accumulated path prefix (e.g., "math", "sudo.user")
289
+ schemas: Schemas dict to populate
290
+ paths: Paths dict to populate
291
+ tags: Tags list to populate
292
+ seen_tags: Set of seen tag names
293
+ """
294
+ tag_name = prefix if prefix else 'default'
295
+ if tag_name not in seen_tags:
296
+ tag_desc = f"Methods in '{prefix}' group" if prefix else 'Root-level methods'
297
+ tags.append({'name': tag_name, 'description': tag_desc})
298
+ seen_tags.add(tag_name)
299
+
300
+ # Generate for local methods (not recursive)
301
+ for method_name in group.list_methods(recursive=False):
302
+ method = group.get_method(method_name)
303
+ full_name = f'{prefix}.{method_name}' if prefix else method_name
304
+ method_path = self._construct_method_path(prefix, method_name)
305
+
306
+ # Generate request schema
307
+ request_schema = self._generate_method_request_schema(full_name, method, schemas)
308
+ schemas[f'{full_name}_request'] = request_schema
309
+
310
+ # Generate response schema
311
+ response_schema = self._generate_method_response_schema(full_name, method, schemas)
312
+ schemas[f'{full_name}_response'] = response_schema
313
+
314
+ operation: dict[str, Any] = {
315
+ 'operationId': full_name.replace('.', '_'),
316
+ 'summary': self._get_method_summary(method),
317
+ 'tags': [tag_name],
318
+ 'requestBody': {
319
+ 'required': True,
320
+ 'content': {'application/json': {'schema': {'$ref': f'#/components/schemas/{full_name}_request'}}},
321
+ },
322
+ 'responses': {
323
+ '200': {
324
+ 'description': 'Successful response',
325
+ 'content': {
326
+ 'application/json': {'schema': {'$ref': f'#/components/schemas/{full_name}_response'}}
327
+ },
328
+ },
329
+ 'default': {
330
+ 'description': 'JSON-RPC Error',
331
+ 'content': {'application/json': {'schema': {'$ref': '#/components/schemas/JSONRPCError'}}},
332
+ },
333
+ },
334
+ }
335
+
336
+ doc = _get_docstring(method.__class__)
337
+ if doc:
338
+ operation['description'] = doc
339
+
340
+ if self.global_headers:
341
+ operation['parameters'] = self.global_headers.copy()
342
+
343
+ paths[method_path] = {'post': operation}
344
+
345
+ # Recurse into subgroups
346
+ for subgroup_name, subgroup in group.get_all_groups().items():
347
+ new_prefix = f'{prefix}.{subgroup_name}' if prefix else subgroup_name
348
+ self._generate_from_group(subgroup, new_prefix, schemas, paths, tags, seen_tags)
349
+
350
+ def generate(self) -> dict[str, Any]:
351
+ """Generate OpenAPI 3.0 specification with per-method paths.
352
+
353
+ Each JSON-RPC method gets its own path entry for better
354
+ documentation viewer experience.
355
+
356
+ Returns:
357
+ OpenAPI spec as dict
358
+ """
359
+ schemas: dict[str, Any] = {}
360
+ paths: dict[str, Any] = {}
361
+ tags: list[dict[str, str]] = []
362
+ seen_tags: set[str] = set()
363
+
364
+ # Generate paths and schemas by traversing group tree
365
+ root_group = self.rpc.get_root_group()
366
+ self._generate_from_group(root_group, '', schemas, paths, tags, seen_tags)
367
+
368
+ # Build OpenAPI spec
369
+ spec: dict[str, Any] = {
370
+ 'openapi': '3.0.3',
371
+ 'info': {
372
+ 'title': self.title,
373
+ 'version': self.version,
374
+ },
375
+ 'tags': tags,
376
+ 'paths': paths,
377
+ 'components': {
378
+ 'schemas': {
379
+ **schemas,
380
+ 'JSONRPCError': {
381
+ 'type': 'object',
382
+ 'properties': {
383
+ 'jsonrpc': {'const': '2.0'},
384
+ 'error': {
385
+ 'type': 'object',
386
+ 'properties': {
387
+ 'code': {'type': 'integer'},
388
+ 'message': {'type': 'string'},
389
+ 'data': {},
390
+ },
391
+ 'required': ['code', 'message'],
392
+ },
393
+ 'id': {
394
+ 'oneOf': [
395
+ {'type': 'string'},
396
+ {'type': 'integer'},
397
+ {'type': 'null'},
398
+ ]
399
+ },
400
+ },
401
+ 'required': ['jsonrpc', 'error', 'id'],
402
+ },
403
+ }
404
+ },
405
+ }
406
+
407
+ if self.description:
408
+ spec['info']['description'] = self.description
409
+
410
+ if self.security_schemes:
411
+ spec['components']['securitySchemes'] = self.security_schemes
412
+
413
+ if self.global_security:
414
+ spec['security'] = self.global_security
415
+
416
+ return spec
417
+
418
+ def _id_schema(self) -> dict[str, Any]:
419
+ """Return JSON Schema for the request/response id field."""
420
+ if self.simplify_id:
421
+ return {'type': 'integer'}
422
+ return {'oneOf': [{'type': 'string'}, {'type': 'integer'}]}
423
+
424
+ def _generate_method_request_schema(
425
+ self,
426
+ full_name: str,
427
+ method: Any,
428
+ schemas: dict[str, Any],
429
+ ) -> dict[str, Any]:
430
+ """Generate JSON Schema for method request."""
431
+ schema: dict[str, Any] = {
432
+ 'type': 'object',
433
+ 'properties': {
434
+ 'jsonrpc': {'const': '2.0'},
435
+ 'method': {'const': full_name},
436
+ 'id': self._id_schema(),
437
+ },
438
+ 'required': ['jsonrpc', 'method', 'id'],
439
+ }
440
+
441
+ doc = _get_docstring(method.__class__)
442
+ if doc:
443
+ schema['description'] = doc
444
+
445
+ if method.params_type is not None and method.params_type is not type(None):
446
+ params_schema = _dataclass_to_jsonschema(method.params_type, schemas)
447
+ schema['properties']['params'] = params_schema
448
+ schema['required'].append('params')
449
+
450
+ return schema
451
+
452
+ def _generate_method_response_schema(
453
+ self,
454
+ full_name: str,
455
+ method: Any,
456
+ schemas: dict[str, Any],
457
+ ) -> dict[str, Any]:
458
+ """Generate JSON Schema for method response."""
459
+ schema: dict[str, Any] = {
460
+ 'type': 'object',
461
+ 'properties': {
462
+ 'jsonrpc': {'const': '2.0'},
463
+ 'id': self._id_schema(),
464
+ },
465
+ 'required': ['jsonrpc', 'result', 'id'],
466
+ 'description': f'Response for {full_name}',
467
+ }
468
+
469
+ if method.result_type is not None:
470
+ result_schema = _type_to_jsonschema(method.result_type, schemas)
471
+ schema['properties']['result'] = result_schema
472
+ else:
473
+ schema['properties']['result'] = {} # Any type
474
+
475
+ return schema
476
+
477
+ def generate_json(self, indent: int = 2) -> str:
478
+ """Generate OpenAPI spec as JSON string.
479
+
480
+ Args:
481
+ indent: JSON indentation level
482
+
483
+ Returns:
484
+ JSON string
485
+ """
486
+ return self.rpc.serialize(self.generate(), indent=indent)
487
+
488
+ def generate_yaml(self) -> str:
489
+ """Generate OpenAPI spec as YAML string.
490
+
491
+ Requires PyYAML to be installed.
492
+
493
+ Returns:
494
+ YAML string
495
+
496
+ Raises:
497
+ ImportError: If PyYAML is not installed
498
+ """
499
+ try:
500
+ import yaml # type: ignore[import-untyped]
501
+ except ImportError:
502
+ raise ImportError('PyYAML is required for YAML output. Install with: pip install pyyaml')
503
+
504
+ result: str = yaml.dump(self.generate(), default_flow_style=False, sort_keys=False)
505
+ return result
jsonrpc/py.typed ADDED
File without changes
jsonrpc/request.py ADDED
@@ -0,0 +1,164 @@
1
+ """Request parsing and building for JSON-RPC protocol."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .errors import InvalidParamsError, InvalidRequestError, ParseError
7
+ from .types import Request, Version
8
+
9
+
10
+ def parse_request(
11
+ data: str | bytes | dict[str, Any],
12
+ allow_dict_params: bool = True,
13
+ allow_list_params: bool = True,
14
+ ) -> Request | list[Request]:
15
+ """Parse JSON-RPC request from raw data.
16
+
17
+ Args:
18
+ data: JSON string, bytes, or already parsed dict
19
+ allow_dict_params: Whether to accept object params (default: True)
20
+ allow_list_params: Whether to accept array params (default: True)
21
+
22
+ Returns:
23
+ Single Request or list of Requests (for batch)
24
+
25
+ Raises:
26
+ ParseError: If JSON is invalid
27
+ InvalidRequestError: If request structure is invalid
28
+ InvalidParamsError: If params format violates flags
29
+ """
30
+ if isinstance(data, (str, bytes)):
31
+ try:
32
+ parsed = json.loads(data)
33
+ except json.JSONDecodeError as e:
34
+ raise ParseError(f'Invalid JSON: {e}') from e
35
+ else:
36
+ parsed = data
37
+
38
+ if isinstance(parsed, list):
39
+ if not parsed:
40
+ raise InvalidRequestError('Empty batch request')
41
+ return [_parse_single_request(item, allow_dict_params, allow_list_params) for item in parsed]
42
+ elif isinstance(parsed, dict):
43
+ return _parse_single_request(parsed, allow_dict_params, allow_list_params)
44
+ else:
45
+ raise InvalidRequestError(f'Request must be object or array, got {type(parsed).__name__}')
46
+
47
+
48
+ def _parse_single_request(
49
+ data: dict[str, Any],
50
+ allow_dict_params: bool = True,
51
+ allow_list_params: bool = True,
52
+ ) -> Request:
53
+ """Parse a single JSON-RPC request object."""
54
+ if not isinstance(data, dict):
55
+ raise InvalidRequestError(f'Request must be object, got {type(data).__name__}')
56
+
57
+ if 'jsonrpc' in data:
58
+ if data['jsonrpc'] != '2.0':
59
+ raise InvalidRequestError(f"Invalid jsonrpc version: {data['jsonrpc']!r}, expected '2.0'")
60
+ version: Version = '2.0'
61
+ else:
62
+ version = '1.0'
63
+
64
+ if 'method' not in data:
65
+ raise InvalidRequestError("Missing required field: 'method'")
66
+
67
+ method = data['method']
68
+ if not isinstance(method, str):
69
+ raise InvalidRequestError(f"Field 'method' must be string, got {type(method).__name__}")
70
+
71
+ params = data.get('params')
72
+ if params is not None:
73
+ if isinstance(params, dict):
74
+ if not allow_dict_params:
75
+ raise InvalidParamsError('Object params not allowed. Use array params: ["value1", "value2"]')
76
+ elif isinstance(params, list):
77
+ if not allow_list_params:
78
+ raise InvalidParamsError(
79
+ 'Array params not allowed. Use object params: {"param1": "value1", "param2": "value2"}'
80
+ )
81
+ else:
82
+ raise InvalidRequestError(f"Field 'params' must be array or object, got {type(params).__name__}")
83
+
84
+ id_was_present = 'id' in data
85
+ request_id = data.get('id')
86
+
87
+ if request_id is not None and (isinstance(request_id, bool) or not isinstance(request_id, (str, int))):
88
+ raise InvalidRequestError(f"Field 'id' must be string or integer, got {type(request_id).__name__}")
89
+
90
+ return Request(
91
+ method=method,
92
+ params=params,
93
+ id=request_id,
94
+ version=version,
95
+ id_was_present=id_was_present,
96
+ )
97
+
98
+
99
+ def build_request(
100
+ method: str,
101
+ params: list[Any] | dict[str, Any] | None = None,
102
+ id: str | int | None = None,
103
+ version: Version = '2.0',
104
+ ) -> dict[str, Any]:
105
+ """Build a JSON-RPC request dict.
106
+
107
+ Args:
108
+ method: Method name
109
+ params: Method parameters
110
+ id: Request ID
111
+ version: Protocol version
112
+
113
+ Returns:
114
+ Request dict ready for JSON serialization
115
+ """
116
+ if version == '2.0':
117
+ result: dict[str, Any] = {
118
+ 'jsonrpc': '2.0',
119
+ 'method': method,
120
+ }
121
+ if params is not None:
122
+ result['params'] = params
123
+ if id is not None:
124
+ result['id'] = id
125
+ else:
126
+ result = {
127
+ 'method': method,
128
+ 'params': params if params is not None else [],
129
+ 'id': id,
130
+ }
131
+ return result
132
+
133
+
134
+ def build_notification(
135
+ method: str,
136
+ params: list[Any] | dict[str, Any] | None = None,
137
+ version: Version = '2.0',
138
+ ) -> dict[str, Any]:
139
+ """Build a JSON-RPC notification dict (no response expected).
140
+
141
+ Args:
142
+ method: Method name
143
+ params: Method parameters
144
+ version: Protocol version
145
+
146
+ Returns:
147
+ Notification dict ready for JSON serialization
148
+ """
149
+ if version == '2.0':
150
+ # v2: notification has no id field
151
+ result: dict[str, Any] = {
152
+ 'jsonrpc': '2.0',
153
+ 'method': method,
154
+ }
155
+ if params is not None:
156
+ result['params'] = params
157
+ else:
158
+ # v1: notification has id=null
159
+ result = {
160
+ 'method': method,
161
+ 'params': params if params is not None else [],
162
+ 'id': None,
163
+ }
164
+ return result