python-jsonrpc-lib 0.3.1__tar.gz → 0.3.2__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.
Files changed (46) hide show
  1. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/.gitignore +2 -1
  2. python_jsonrpc_lib-0.3.2/CHANGELOG.md +26 -0
  3. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/LICENSE +1 -1
  4. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/PKG-INFO +1 -1
  5. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/api-reference.md +16 -4
  6. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/tutorial/07-openapi.md +55 -26
  7. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/pyproject.toml +1 -1
  8. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/__init__.py +1 -1
  9. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/jsonrpc.py +8 -6
  10. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/method.py +19 -2
  11. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/openapi.py +18 -12
  12. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/validation.py +1 -1
  13. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/test_internal_api.py +54 -0
  14. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/test_jsonrpc_v2.py +7 -18
  15. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/test_openapi.py +58 -7
  16. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/test_utils.py +20 -0
  17. {python_jsonrpc_lib-0.3.1/.claude/skills/jsonrpc-lib → python_jsonrpc_lib-0.3.2/.claude/skills/python-jsonrpc-lib}/SKILL.md +0 -0
  18. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/.github/workflows/docs.yml +0 -0
  19. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/MANIFEST.in +0 -0
  20. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/README.md +0 -0
  21. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/advanced/async.md +0 -0
  22. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/advanced/batch.md +0 -0
  23. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/advanced/middleware.md +0 -0
  24. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/advanced/protocols.md +0 -0
  25. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/index.md +0 -0
  26. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/integrations/custom.md +0 -0
  27. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/integrations/fastapi.md +0 -0
  28. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/integrations/flask.md +0 -0
  29. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/philosophy.md +0 -0
  30. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/tutorial/01-hello-world.md +0 -0
  31. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/tutorial/02-method-classes.md +0 -0
  32. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/tutorial/03-parameters.md +0 -0
  33. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/tutorial/04-nested-types.md +0 -0
  34. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/tutorial/05-context.md +0 -0
  35. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/docs/tutorial/06-groups.md +0 -0
  36. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/mkdocs.yml +0 -0
  37. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/errors.py +0 -0
  38. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/py.typed +0 -0
  39. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/request.py +0 -0
  40. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/response.py +0 -0
  41. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/src/jsonrpc/types.py +0 -0
  42. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/__init__.py +0 -0
  43. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/fixtures.py +0 -0
  44. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/test_context.py +0 -0
  45. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/test_decorator.py +0 -0
  46. {python_jsonrpc_lib-0.3.1 → python_jsonrpc_lib-0.3.2}/tests/test_jsonrpc_v1.py +0 -0
@@ -5,4 +5,5 @@ htmlcov
5
5
  .ruff_cache
6
6
  .idea
7
7
  .vscode
8
- __pycache__
8
+ __pycache__
9
+ dist
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.3.2
4
+
5
+ **Bug fixes:**
6
+
7
+ - `add_security_scheme`: replaced `**kwargs` with `options: dict` parameter, fixing inability to create `apiKey` schemes (conflicting `name` parameter, `in` as Python reserved word)
8
+ - `_convert_value`: Union types containing multiple dataclasses now correctly try all variants instead of crashing on the first mismatch
9
+ - `simplify_id` flag now consistently applies to JSONRPCError schema in OpenAPI output
10
+ - `unregister()` now clears `.rpc` attribute, allowing re-registration of the same Method instance
11
+ - `max_concurrent` parameter is now validated (`-1` or `>= 1`); previously `0` caused a silent deadlock
12
+ - `version` parameter is now validated at init; invalid values like `'3.0'` raise `ValueError`
13
+ - Fixed `bearer_format` typo in tests (should be `bearerFormat` per OpenAPI spec)
14
+ - Fixed OpenAPI tutorial example to match actual generated output
15
+
16
+ ## 0.3.1 (First Public Release)
17
+
18
+ - JSON-RPC 1.0 and 2.0 support
19
+ - Dataclass-based parameter validation
20
+ - Built-in OpenAPI generation
21
+ - Hierarchical context support
22
+ - Decorator API for prototyping
23
+ - Async/sync methods
24
+ - Batch request handling
25
+ - Strict mode by default
26
+ - Zero external dependencies
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Author
3
+ Copyright (c) 2026 Andy Smith
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jsonrpc-lib
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Simple, yet solid - Type-safe JSON-RPC 1.0/2.0 with OpenAPI support
5
5
  Project-URL: Homepage, https://github.com/uandysmith/python-jsonrpc-lib
6
6
  Project-URL: Documentation, https://uandysmith.github.io/python-jsonrpc-lib/
@@ -279,7 +279,7 @@ yaml_str: str = generator.generate_yaml()
279
279
  generator.add_security_scheme(
280
280
  name: str,
281
281
  scheme_type: Literal["apiKey", "http", "oauth2", "openIdConnect"],
282
- **kwargs,
282
+ options: dict[str, Any] | None = None,
283
283
  )
284
284
 
285
285
  # Add global header parameter
@@ -309,8 +309,7 @@ generator = OpenAPIGenerator(
309
309
  generator.add_security_scheme(
310
310
  "BearerAuth",
311
311
  scheme_type="http",
312
- scheme="bearer",
313
- bearerFormat="JWT",
312
+ options={"scheme": "bearer", "bearerFormat": "JWT"},
314
313
  )
315
314
  generator.add_security_requirement("BearerAuth")
316
315
  spec = generator.generate()
@@ -504,7 +503,20 @@ response = rpc.handle(
504
503
 
505
504
  ## Changelog
506
505
 
507
- ### 1.0.0 (First Release)
506
+ ### 0.3.2
507
+
508
+ **Bug fixes:**
509
+
510
+ - `add_security_scheme`: replaced `**kwargs` with `options: dict` parameter, fixing inability to create `apiKey` schemes (conflicting `name` parameter, `in` as Python reserved word)
511
+ - `_convert_value`: Union types containing multiple dataclasses now correctly try all variants instead of crashing on the first mismatch
512
+ - `simplify_id` flag now consistently applies to JSONRPCError schema in OpenAPI output
513
+ - `unregister()` now clears `.rpc` attribute, allowing re-registration of the same Method instance
514
+ - `max_concurrent` parameter is now validated (`-1` or `>= 1`); previously `0` caused a silent deadlock
515
+ - `version` parameter is now validated at init; invalid values like `'3.0'` raise `ValueError`
516
+ - Fixed `bearer_format` typo in tests (should be `bearerFormat` per OpenAPI spec)
517
+ - Fixed OpenAPI tutorial example to match actual generated output
518
+
519
+ ### 0.3.1 (First Public Release)
508
520
 
509
521
  - JSON-RPC 1.0 and 2.0 support
510
522
  - Dataclass-based parameter validation
@@ -67,28 +67,47 @@ import json
67
67
  print(json.dumps(spec, indent=2))
68
68
  ```
69
69
 
70
- **Generated OpenAPI Schema:**
70
+ **Generated OpenAPI Schema (abbreviated):**
71
+
72
+ Each method gets its own path using fragment syntax (`#method.name`), with separate
73
+ request and response schemas in `components/schemas`:
71
74
 
72
75
  ```json title="openapi_output.json"
73
76
  {
74
- "openapi": "3.0.0",
77
+ "openapi": "3.0.3",
75
78
  "info": {
76
79
  "title": "Calculator API",
77
80
  "version": "1.0.0",
78
81
  "description": "Simple calculator with JSON-RPC 2.0"
79
82
  },
80
83
  "paths": {
81
- "/": {
84
+ "/jsonrpc#math.calculate": {
82
85
  "post": {
83
- "summary": "JSON-RPC endpoint",
86
+ "operationId": "math_calculate",
87
+ "summary": "Perform a math operation.",
88
+ "tags": ["math"],
84
89
  "requestBody": {
90
+ "required": true,
85
91
  "content": {
86
92
  "application/json": {
87
- "schema": {
88
- "oneOf": [
89
- {"$ref": "#/components/schemas/CalculateRequest"},
90
- {"$ref": "#/components/schemas/GreetRequest"}
91
- ]
93
+ "schema": {"$ref": "#/components/schemas/math.calculate_request"}
94
+ }
95
+ }
96
+ },
97
+ "responses": {
98
+ "200": {
99
+ "description": "Successful response",
100
+ "content": {
101
+ "application/json": {
102
+ "schema": {"$ref": "#/components/schemas/math.calculate_response"}
103
+ }
104
+ }
105
+ },
106
+ "default": {
107
+ "description": "JSON-RPC Error",
108
+ "content": {
109
+ "application/json": {
110
+ "schema": {"$ref": "#/components/schemas/JSONRPCError"}
92
111
  }
93
112
  }
94
113
  }
@@ -98,11 +117,12 @@ print(json.dumps(spec, indent=2))
98
117
  },
99
118
  "components": {
100
119
  "schemas": {
101
- "CalculateRequest": {
120
+ "math.calculate_request": {
102
121
  "type": "object",
103
122
  "properties": {
104
- "jsonrpc": {"type": "string", "enum": ["2.0"]},
105
- "method": {"type": "string", "enum": ["calculate"]},
123
+ "jsonrpc": {"const": "2.0"},
124
+ "method": {"const": "math.calculate"},
125
+ "id": {"type": "integer"},
106
126
  "params": {
107
127
  "type": "object",
108
128
  "properties": {
@@ -111,10 +131,9 @@ print(json.dumps(spec, indent=2))
111
131
  "operation": {"type": "string"}
112
132
  },
113
133
  "required": ["x", "y", "operation"]
114
- },
115
- "id": {"type": "integer"}
134
+ }
116
135
  },
117
- "required": ["jsonrpc", "method", "params", "id"]
136
+ "required": ["jsonrpc", "method", "id", "params"]
118
137
  }
119
138
  }
120
139
  }
@@ -412,25 +431,35 @@ generator = OpenAPIGenerator(rpc, title="Secure API", version="1.0.0")
412
431
  generator.add_security_scheme(
413
432
  "BearerAuth",
414
433
  scheme_type="http",
415
- scheme="bearer",
416
- bearerFormat="JWT",
434
+ options={"scheme": "bearer", "bearerFormat": "JWT"},
417
435
  )
418
436
  generator.add_security_requirement("BearerAuth")
419
437
 
438
+ # API Key
439
+ generator = OpenAPIGenerator(rpc, title="API Key API", version="1.0.0")
440
+ generator.add_security_scheme(
441
+ "ApiKeyAuth",
442
+ scheme_type="apiKey",
443
+ options={"name": "X-API-Key", "in": "header"},
444
+ )
445
+ generator.add_security_requirement("ApiKeyAuth")
446
+
420
447
  # OAuth2
421
448
  generator = OpenAPIGenerator(rpc, title="OAuth API", version="1.0.0")
422
449
  generator.add_security_scheme(
423
450
  "OAuth2",
424
451
  scheme_type="oauth2",
425
- flows={
426
- "authorizationCode": {
427
- "authorizationUrl": "https://example.com/oauth/authorize",
428
- "tokenUrl": "https://example.com/oauth/token",
429
- "scopes": {
430
- "read": "Read access",
431
- "write": "Write access",
432
- },
433
- }
452
+ options={
453
+ "flows": {
454
+ "authorizationCode": {
455
+ "authorizationUrl": "https://example.com/oauth/authorize",
456
+ "tokenUrl": "https://example.com/oauth/token",
457
+ "scopes": {
458
+ "read": "Read access",
459
+ "write": "Write access",
460
+ },
461
+ }
462
+ },
434
463
  },
435
464
  )
436
465
  generator.add_security_requirement("OAuth2", scopes=["read", "write"])
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-jsonrpc-lib"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Simple, yet solid - Type-safe JSON-RPC 1.0/2.0 with OpenAPI support"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -46,7 +46,7 @@ from .response import build_error_response, build_response, parse_response
46
46
  from .types import ErrorResponse, Request, Response, Version
47
47
  from .validation import validate_params, validate_result_type
48
48
 
49
- __version__ = '0.3.1'
49
+ __version__ = '0.3.2'
50
50
 
51
51
  __all__ = [
52
52
  # Main classes
@@ -190,6 +190,12 @@ class JSONRPC:
190
190
  v1.0: allow_batch=False, allow_dict_params=False, allow_list_params=True
191
191
  v2.0: allow_batch=True, allow_dict_params=True, allow_list_params=True
192
192
  """
193
+ if version not in ('1.0', '2.0'):
194
+ raise ValueError(f"version must be '1.0' or '2.0', got {version!r}")
195
+
196
+ if max_concurrent is not None and max_concurrent != -1 and max_concurrent < 1:
197
+ raise ValueError(f'max_concurrent must be -1 (unlimited) or >= 1, got {max_concurrent}')
198
+
193
199
  self.version = version
194
200
  self.validate_results = validate_results
195
201
  self.context_type = context_type
@@ -615,9 +621,7 @@ class JSONRPC:
615
621
  return self.serialize(response)
616
622
 
617
623
  if self.max_batch != -1 and len(data) > self.max_batch:
618
- error = InvalidRequestError(
619
- f'Batch too large: {len(data)} requests, maximum is {self.max_batch}'
620
- )
624
+ error = InvalidRequestError(f'Batch too large: {len(data)} requests, maximum is {self.max_batch}')
621
625
  response = build_error_response(error, None, self.version)
622
626
  return self.serialize(response)
623
627
 
@@ -646,9 +650,7 @@ class JSONRPC:
646
650
  return self.serialize(response)
647
651
 
648
652
  if self.max_batch != -1 and len(data) > self.max_batch:
649
- error = InvalidRequestError(
650
- f'Batch too large: {len(data)} requests, maximum is {self.max_batch}'
651
- )
653
+ error = InvalidRequestError(f'Batch too large: {len(data)} requests, maximum is {self.max_batch}')
652
654
  response = build_error_response(error, None, self.version)
653
655
  return self.serialize(response)
654
656
 
@@ -303,6 +303,8 @@ class MethodGroup:
303
303
  def unregister(self, name: str) -> None:
304
304
  """Unregister a method or subgroup by name.
305
305
 
306
+ Clears the `.rpc` attribute so the instance can be re-registered.
307
+
306
308
  Args:
307
309
  name: Method or subgroup name (not a path)
308
310
 
@@ -310,9 +312,12 @@ class MethodGroup:
310
312
  KeyError: If name not found in either methods or subgroups
311
313
  """
312
314
  if name in self._methods:
313
- del self._methods[name]
315
+ method = self._methods.pop(name)
316
+ if hasattr(method, 'rpc'):
317
+ del method.rpc
314
318
  elif name in self._subgroups:
315
- del self._subgroups[name]
319
+ subgroup = self._subgroups.pop(name)
320
+ subgroup._clear_rpc()
316
321
  else:
317
322
  raise KeyError(f"'{name}' not found in group '{self._name}'")
318
323
 
@@ -559,3 +564,15 @@ class MethodGroup:
559
564
 
560
565
  for subgroup in self._subgroups.values():
561
566
  subgroup._inject_rpc(rpc)
567
+
568
+ def _clear_rpc(self) -> None:
569
+ """Clear RPC reference from group and all children (recursive)."""
570
+ if hasattr(self, 'rpc'):
571
+ del self.rpc
572
+
573
+ for method in self._methods.values():
574
+ if hasattr(method, 'rpc'):
575
+ del method.rpc
576
+
577
+ for subgroup in self._subgroups.values():
578
+ subgroup._clear_rpc()
@@ -174,25 +174,31 @@ class OpenAPIGenerator:
174
174
  self,
175
175
  name: str,
176
176
  scheme_type: Literal['apiKey', 'http', 'oauth2', 'openIdConnect'],
177
- **kwargs: Any,
177
+ options: dict[str, Any] | None = None,
178
178
  ) -> None:
179
179
  """Add authentication/security scheme.
180
180
 
181
181
  Args:
182
182
  name: Scheme name (used in security requirements)
183
183
  scheme_type: Type of security scheme
184
- **kwargs: Additional scheme properties (scheme, bearerFormat, in, name, etc.)
184
+ options: Additional scheme properties as a dict.
185
+ Keys are passed directly into the OpenAPI security scheme object.
185
186
 
186
187
  Example:
187
188
  >>> openapi.add_security_scheme(
188
189
  ... "bearerAuth",
189
190
  ... scheme_type="http",
190
- ... scheme="bearer",
191
- ... bearerFormat="JWT"
191
+ ... options={"scheme": "bearer", "bearerFormat": "JWT"},
192
+ ... )
193
+ >>> openapi.add_security_scheme(
194
+ ... "apiKeyAuth",
195
+ ... scheme_type="apiKey",
196
+ ... options={"name": "X-API-Key", "in": "header"},
192
197
  ... )
193
198
  """
194
199
  scheme: dict[str, Any] = {'type': scheme_type}
195
- scheme.update(kwargs)
200
+ if options:
201
+ scheme.update(options)
196
202
  self.security_schemes[name] = scheme
197
203
 
198
204
  def add_header(
@@ -390,13 +396,7 @@ class OpenAPIGenerator:
390
396
  },
391
397
  'required': ['code', 'message'],
392
398
  },
393
- 'id': {
394
- 'oneOf': [
395
- {'type': 'string'},
396
- {'type': 'integer'},
397
- {'type': 'null'},
398
- ]
399
- },
399
+ 'id': self._error_id_schema(),
400
400
  },
401
401
  'required': ['jsonrpc', 'error', 'id'],
402
402
  },
@@ -421,6 +421,12 @@ class OpenAPIGenerator:
421
421
  return {'type': 'integer'}
422
422
  return {'oneOf': [{'type': 'string'}, {'type': 'integer'}]}
423
423
 
424
+ def _error_id_schema(self) -> dict[str, Any]:
425
+ """Return JSON Schema for the error response id field (includes null)."""
426
+ if self.simplify_id:
427
+ return {'oneOf': [{'type': 'integer'}, {'type': 'null'}]}
428
+ return {'oneOf': [{'type': 'string'}, {'type': 'integer'}, {'type': 'null'}]}
429
+
424
430
  def _generate_method_request_schema(
425
431
  self,
426
432
  full_name: str,
@@ -226,7 +226,7 @@ def _convert_value(value: Any, expected_type: type, _depth: int = 0) -> Any:
226
226
  if arg is not type(None):
227
227
  try:
228
228
  return _convert_value(value, arg, _depth=_depth)
229
- except (TypeError, ValueError):
229
+ except (TypeError, ValueError, InvalidParamsError):
230
230
  continue
231
231
  return value
232
232
 
@@ -261,6 +261,60 @@ class TestJSONRPCFacade(unittest.TestCase):
261
261
  with self.assertRaises(MethodNotFoundError):
262
262
  rpc.call_method('math.add', {'a': 1, 'b': 2})
263
263
 
264
+ def test_unregister_method_then_reregister_same_instance(self):
265
+ """Test re-registering the same Method instance after unregister."""
266
+ rpc = JSONRPC(version='2.0')
267
+ math = MethodGroup()
268
+ add = AddMethod()
269
+ math.register('add', add)
270
+ rpc.register('math', math)
271
+
272
+ self.assertEqual(rpc.call_method('math.add', {'a': 1, 'b': 2}), 3)
273
+
274
+ rpc.unregister('math.add')
275
+ # Same instance should be re-registerable after unregister
276
+ math.register('add', add)
277
+ self.assertEqual(rpc.call_method('math.add', {'a': 5, 'b': 3}), 8)
278
+
279
+ def test_unregister_subgroup_clears_rpc_on_children(self):
280
+ """Test unregistering a subgroup clears .rpc on all nested methods."""
281
+ rpc = JSONRPC(version='2.0')
282
+ math = MethodGroup()
283
+ add = AddMethod()
284
+ sub = SubtractMethod()
285
+ math.register('add', add)
286
+ math.register('subtract', sub)
287
+ rpc.register('math', math)
288
+
289
+ self.assertTrue(hasattr(add, 'rpc'))
290
+ self.assertTrue(hasattr(sub, 'rpc'))
291
+
292
+ rpc.unregister('math')
293
+ self.assertFalse(hasattr(add, 'rpc'))
294
+ self.assertFalse(hasattr(sub, 'rpc'))
295
+
296
+ def test_invalid_version_raises_value_error(self):
297
+ """Test that invalid version raises ValueError."""
298
+ with self.assertRaises(ValueError) as ctx:
299
+ JSONRPC(version='3.0')
300
+ self.assertIn("'1.0' or '2.0'", str(ctx.exception))
301
+
302
+ def test_max_concurrent_zero_raises_value_error(self):
303
+ """Test that max_concurrent=0 raises ValueError."""
304
+ with self.assertRaises(ValueError) as ctx:
305
+ JSONRPC(max_concurrent=0)
306
+ self.assertIn('max_concurrent', str(ctx.exception))
307
+
308
+ def test_max_concurrent_negative_two_raises_value_error(self):
309
+ """Test that max_concurrent=-2 raises ValueError."""
310
+ with self.assertRaises(ValueError):
311
+ JSONRPC(max_concurrent=-2)
312
+
313
+ def test_max_concurrent_minus_one_is_valid(self):
314
+ """Test that max_concurrent=-1 (unlimited) is accepted."""
315
+ rpc = JSONRPC(max_concurrent=-1)
316
+ self.assertEqual(rpc._effective_max_concurrent, -1)
317
+
264
318
 
265
319
  class TestCallMethod(unittest.TestCase):
266
320
  """Comprehensive tests for call_method() internal API."""
@@ -871,9 +871,7 @@ class TestJSONRPCV2DefensiveExceptionHandling(unittest.TestCase):
871
871
 
872
872
  # Patch _handle_single to raise — bypasses all internal error handling.
873
873
  with patch.object(self.rpc, '_handle_single', side_effect=RuntimeError('internal crash')):
874
- response = self.rpc.handle(
875
- '{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":1}'
876
- )
874
+ response = self.rpc.handle('{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":1}')
877
875
  data = json.loads(response)
878
876
  self.assertEqual(data['error']['code'], -32603)
879
877
  self.assertIn('internal crash', data['error']['message'])
@@ -884,9 +882,7 @@ class TestJSONRPCV2DefensiveExceptionHandling(unittest.TestCase):
884
882
 
885
883
  with patch.object(self.rpc, '_handle_single_async', side_effect=RuntimeError('async crash')):
886
884
  response = asyncio.run(
887
- self.rpc.handle_async(
888
- '{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":1}'
889
- )
885
+ self.rpc.handle_async('{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":1}')
890
886
  )
891
887
  data = json.loads(response)
892
888
  self.assertEqual(data['error']['code'], -32603)
@@ -1136,9 +1132,7 @@ class TestJSONRPCV2SerializationHooks(unittest.TestCase):
1136
1132
 
1137
1133
  rpc = self._make_rpc()
1138
1134
  with patch.object(rpc, 'serialize', side_effect=self._failing_serialize(fail_on_call=1)):
1139
- response = rpc.handle(
1140
- '{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":42}'
1141
- )
1135
+ response = rpc.handle('{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":42}')
1142
1136
  data = json.loads(response)
1143
1137
  self.assertEqual(data['error']['code'], -32603)
1144
1138
  self.assertIn('cannot serialize', data['error']['message'])
@@ -1151,9 +1145,7 @@ class TestJSONRPCV2SerializationHooks(unittest.TestCase):
1151
1145
  rpc = self._make_rpc()
1152
1146
  with patch.object(rpc, 'serialize', side_effect=self._failing_serialize(fail_on_call=1)):
1153
1147
  response = asyncio.run(
1154
- rpc.handle_async(
1155
- '{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":42}'
1156
- )
1148
+ rpc.handle_async('{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":42}')
1157
1149
  )
1158
1150
  data = json.loads(response)
1159
1151
  self.assertEqual(data['error']['code'], -32603)
@@ -1164,9 +1156,7 @@ class TestJSONRPCV2SerializationHooks(unittest.TestCase):
1164
1156
  from unittest.mock import patch
1165
1157
 
1166
1158
  rpc = self._make_rpc()
1167
- batch = json.dumps(
1168
- [{'jsonrpc': '2.0', 'method': 'math.add', 'params': {'a': 1, 'b': 2}, 'id': 1}]
1169
- )
1159
+ batch = json.dumps([{'jsonrpc': '2.0', 'method': 'math.add', 'params': {'a': 1, 'b': 2}, 'id': 1}])
1170
1160
  with patch.object(rpc, 'serialize', side_effect=self._failing_serialize(fail_on_call=1)):
1171
1161
  response = rpc.handle(batch)
1172
1162
  data = json.loads(response)
@@ -1177,9 +1167,7 @@ class TestJSONRPCV2SerializationHooks(unittest.TestCase):
1177
1167
  from unittest.mock import patch
1178
1168
 
1179
1169
  rpc = self._make_rpc()
1180
- batch = json.dumps(
1181
- [{'jsonrpc': '2.0', 'method': 'math.add', 'params': {'a': 1, 'b': 2}, 'id': 1}]
1182
- )
1170
+ batch = json.dumps([{'jsonrpc': '2.0', 'method': 'math.add', 'params': {'a': 1, 'b': 2}, 'id': 1}])
1183
1171
  with patch.object(rpc, 'serialize', side_effect=self._failing_serialize(fail_on_call=1)):
1184
1172
  response = asyncio.run(rpc.handle_async(batch))
1185
1173
  data = json.loads(response)
@@ -1219,6 +1207,7 @@ class TestV2Logging(unittest.TestCase):
1219
1207
 
1220
1208
  def test_async_notification_error_logged_at_debug(self):
1221
1209
  """Suppressed async notification errors are logged at DEBUG level."""
1210
+
1222
1211
  class AsyncBrokenMethod(Method):
1223
1212
  async def execute(self, params: None) -> str:
1224
1213
  raise RuntimeError('Async error!')
@@ -649,6 +649,11 @@ class TestOpenAPISchemaValidation(unittest.TestCase):
649
649
  id_schema = add_request['properties']['id']
650
650
  self.assertEqual(id_schema, {'type': 'integer'})
651
651
 
652
+ # Error schema id should also respect simplify_id
653
+ error_schema = schemas['JSONRPCError']
654
+ error_id = error_schema['properties']['id']
655
+ self.assertEqual(error_id, {'oneOf': [{'type': 'integer'}, {'type': 'null'}]})
656
+
652
657
  def test_simplify_id_false_uses_oneof(self):
653
658
  """simplify_id=False produces oneOf[string, integer] for id fields."""
654
659
  generator = OpenAPIGenerator(self.rpc, simplify_id=False)
@@ -665,6 +670,12 @@ class TestOpenAPISchemaValidation(unittest.TestCase):
665
670
  add_response = schemas['math.add_response']
666
671
  self.assertEqual(add_response['properties']['id'], expected_id)
667
672
 
673
+ # Error schema id should include null
674
+ error_schema = schemas['JSONRPCError']
675
+ error_id = error_schema['properties']['id']
676
+ expected_error_id = {'oneOf': [{'type': 'string'}, {'type': 'integer'}, {'type': 'null'}]}
677
+ self.assertEqual(error_id, expected_error_id)
678
+
668
679
 
669
680
  class TestOpenAPITypeConversionEdgeCases(unittest.TestCase):
670
681
  """Tests for edge cases in type conversion to JSON Schema."""
@@ -817,7 +828,11 @@ class TestOpenAPISecurityAndHeaders(unittest.TestCase):
817
828
  generator = OpenAPIGenerator(rpc)
818
829
 
819
830
  # Add security scheme
820
- generator.add_security_scheme(name='bearerAuth', scheme_type='http', scheme='bearer', bearer_format='JWT')
831
+ generator.add_security_scheme(
832
+ name='bearerAuth',
833
+ scheme_type='http',
834
+ options={'scheme': 'bearer', 'bearerFormat': 'JWT'},
835
+ )
821
836
 
822
837
  # Add security requirement
823
838
  generator.add_security_requirement('bearerAuth')
@@ -828,10 +843,38 @@ class TestOpenAPISecurityAndHeaders(unittest.TestCase):
828
843
  self.assertIn('securitySchemes', spec['components'])
829
844
  self.assertIn('bearerAuth', spec['components']['securitySchemes'])
830
845
 
846
+ # Check security scheme content
847
+ scheme = spec['components']['securitySchemes']['bearerAuth']
848
+ self.assertEqual(scheme['type'], 'http')
849
+ self.assertEqual(scheme['scheme'], 'bearer')
850
+ self.assertEqual(scheme['bearerFormat'], 'JWT')
851
+
831
852
  # Check security requirement is applied globally
832
853
  self.assertIn('security', spec)
833
854
  self.assertEqual(spec['security'], [{'bearerAuth': []}])
834
855
 
856
+ def test_add_apikey_security_scheme(self):
857
+ """Test adding apiKey security scheme with name and in fields."""
858
+ rpc = JSONRPC()
859
+ math_group = MethodGroup()
860
+ math_group.register('add', AddMethod())
861
+ rpc.register('math', math_group)
862
+
863
+ generator = OpenAPIGenerator(rpc)
864
+ generator.add_security_scheme(
865
+ name='apiKeyAuth',
866
+ scheme_type='apiKey',
867
+ options={'name': 'X-API-Key', 'in': 'header'},
868
+ )
869
+ generator.add_security_requirement('apiKeyAuth')
870
+
871
+ spec = generator.generate()
872
+
873
+ scheme = spec['components']['securitySchemes']['apiKeyAuth']
874
+ self.assertEqual(scheme['type'], 'apiKey')
875
+ self.assertEqual(scheme['name'], 'X-API-Key')
876
+ self.assertEqual(scheme['in'], 'header')
877
+
835
878
  def test_add_oauth2_security_with_scopes(self):
836
879
  """Test adding OAuth2 security with scopes."""
837
880
  rpc = JSONRPC()
@@ -845,12 +888,14 @@ class TestOpenAPISecurityAndHeaders(unittest.TestCase):
845
888
  generator.add_security_scheme(
846
889
  name='oauth2',
847
890
  scheme_type='oauth2',
848
- flows={
849
- 'authorizationCode': {
850
- 'authorizationUrl': 'https://example.com/oauth/authorize',
851
- 'tokenUrl': 'https://example.com/oauth/token',
852
- 'scopes': {'read': 'Read access', 'write': 'Write access'},
853
- }
891
+ options={
892
+ 'flows': {
893
+ 'authorizationCode': {
894
+ 'authorizationUrl': 'https://example.com/oauth/authorize',
895
+ 'tokenUrl': 'https://example.com/oauth/token',
896
+ 'scopes': {'read': 'Read access', 'write': 'Write access'},
897
+ }
898
+ },
854
899
  },
855
900
  )
856
901
 
@@ -859,6 +904,12 @@ class TestOpenAPISecurityAndHeaders(unittest.TestCase):
859
904
 
860
905
  spec = generator.generate()
861
906
 
907
+ # Check security scheme content
908
+ scheme = spec['components']['securitySchemes']['oauth2']
909
+ self.assertEqual(scheme['type'], 'oauth2')
910
+ self.assertIn('flows', scheme)
911
+ self.assertIn('authorizationCode', scheme['flows'])
912
+
862
913
  # Check security requirement includes scopes
863
914
  self.assertIn('security', spec)
864
915
  self.assertEqual(spec['security'], [{'oauth2': ['read', 'write']}])
@@ -843,6 +843,26 @@ class TestAdvancedTypeEdgeCases(unittest.TestCase):
843
843
  result = _convert_value(value, TypeA | TypeB)
844
844
  self.assertIs(result, value)
845
845
 
846
+ def test_convert_value_union_dataclass_field_mismatch(self):
847
+ """Union fallback: if first dataclass has wrong fields (InvalidParamsError), try next."""
848
+ from dataclasses import dataclass
849
+
850
+ @dataclass
851
+ class TypeA:
852
+ name: str
853
+ age: int
854
+
855
+ @dataclass
856
+ class TypeB:
857
+ x: int
858
+ y: int
859
+
860
+ # Value matches TypeB fields but not TypeA → should fall through to TypeB
861
+ result = _convert_value({'x': 1, 'y': 2}, TypeA | TypeB)
862
+ self.assertIsInstance(result, TypeB)
863
+ self.assertEqual(result.x, 1)
864
+ self.assertEqual(result.y, 2)
865
+
846
866
  def test_convert_value_plain_list_returns_as_is(self):
847
867
  """Test _convert_value with plain list (no args) - line 246."""
848
868
  from jsonrpc.validation import _convert_value