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/__init__.py +83 -0
- jsonrpc/errors.py +131 -0
- jsonrpc/jsonrpc.py +881 -0
- jsonrpc/method.py +561 -0
- jsonrpc/openapi.py +505 -0
- jsonrpc/py.typed +0 -0
- jsonrpc/request.py +164 -0
- jsonrpc/response.py +169 -0
- jsonrpc/types.py +53 -0
- jsonrpc/validation.py +297 -0
- python_jsonrpc_lib-0.3.1.dist-info/METADATA +141 -0
- python_jsonrpc_lib-0.3.1.dist-info/RECORD +14 -0
- python_jsonrpc_lib-0.3.1.dist-info/WHEEL +4 -0
- python_jsonrpc_lib-0.3.1.dist-info/licenses/LICENSE +21 -0
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
|