omnibase_infra 0.2.5__py3-none-any.whl → 0.2.7__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.
- omnibase_infra/constants_topic_patterns.py +26 -0
- omnibase_infra/enums/__init__.py +3 -0
- omnibase_infra/enums/enum_consumer_group_purpose.py +92 -0
- omnibase_infra/enums/enum_handler_source_mode.py +16 -2
- omnibase_infra/errors/__init__.py +4 -0
- omnibase_infra/errors/error_binding_resolution.py +128 -0
- omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +0 -2
- omnibase_infra/event_bus/event_bus_inmemory.py +64 -10
- omnibase_infra/event_bus/event_bus_kafka.py +105 -47
- omnibase_infra/event_bus/mixin_kafka_broadcast.py +3 -7
- omnibase_infra/event_bus/mixin_kafka_dlq.py +12 -6
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +0 -81
- omnibase_infra/event_bus/testing/__init__.py +26 -0
- omnibase_infra/event_bus/testing/adapter_protocol_event_publisher_inmemory.py +418 -0
- omnibase_infra/event_bus/testing/model_publisher_metrics.py +64 -0
- omnibase_infra/handlers/handler_consul.py +2 -0
- omnibase_infra/handlers/mixins/__init__.py +5 -0
- omnibase_infra/handlers/mixins/mixin_consul_service.py +274 -10
- omnibase_infra/handlers/mixins/mixin_consul_topic_index.py +585 -0
- omnibase_infra/handlers/models/model_filesystem_config.py +4 -4
- omnibase_infra/migrations/001_create_event_ledger.sql +166 -0
- omnibase_infra/migrations/001_drop_event_ledger.sql +18 -0
- omnibase_infra/mixins/mixin_node_introspection.py +189 -19
- omnibase_infra/models/__init__.py +8 -0
- omnibase_infra/models/bindings/__init__.py +59 -0
- omnibase_infra/models/bindings/constants.py +144 -0
- omnibase_infra/models/bindings/model_binding_resolution_result.py +103 -0
- omnibase_infra/models/bindings/model_operation_binding.py +44 -0
- omnibase_infra/models/bindings/model_operation_bindings_subcontract.py +152 -0
- omnibase_infra/models/bindings/model_parsed_binding.py +52 -0
- omnibase_infra/models/discovery/model_introspection_config.py +25 -17
- omnibase_infra/models/dispatch/__init__.py +8 -0
- omnibase_infra/models/dispatch/model_debug_trace_snapshot.py +114 -0
- omnibase_infra/models/dispatch/model_materialized_dispatch.py +141 -0
- omnibase_infra/models/handlers/model_handler_source_config.py +1 -1
- omnibase_infra/models/model_node_identity.py +126 -0
- omnibase_infra/models/projection/model_snapshot_topic_config.py +3 -2
- omnibase_infra/models/registration/__init__.py +9 -0
- omnibase_infra/models/registration/model_event_bus_topic_entry.py +59 -0
- omnibase_infra/models/registration/model_node_event_bus_config.py +99 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +11 -0
- omnibase_infra/models/runtime/__init__.py +9 -0
- omnibase_infra/models/validation/model_coverage_metrics.py +2 -2
- omnibase_infra/nodes/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/__init__.py +29 -0
- omnibase_infra/nodes/contract_registry_reducer/contract.yaml +255 -0
- omnibase_infra/nodes/contract_registry_reducer/models/__init__.py +38 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_contract_registry_state.py +266 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_cleanup_topic_references.py +55 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_deactivate_contract.py +58 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_mark_stale.py +49 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_heartbeat.py +71 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_topic.py +66 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_upsert_contract.py +92 -0
- omnibase_infra/nodes/contract_registry_reducer/node.py +121 -0
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +784 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/registry_infra_contract_registry_reducer.py +101 -0
- omnibase_infra/nodes/handlers/consul/contract.yaml +85 -0
- omnibase_infra/nodes/handlers/db/contract.yaml +72 -0
- omnibase_infra/nodes/handlers/graph/contract.yaml +127 -0
- omnibase_infra/nodes/handlers/http/contract.yaml +74 -0
- omnibase_infra/nodes/handlers/intent/contract.yaml +66 -0
- omnibase_infra/nodes/handlers/mcp/contract.yaml +69 -0
- omnibase_infra/nodes/handlers/vault/contract.yaml +91 -0
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +50 -0
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +104 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +284 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/__init__.py +29 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/registry_infra_ledger_projection.py +118 -0
- omnibase_infra/nodes/node_ledger_write_effect/__init__.py +82 -0
- omnibase_infra/nodes/node_ledger_write_effect/contract.yaml +200 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/__init__.py +22 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_append.py +372 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_query.py +597 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/__init__.py +31 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_append_result.py +54 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_entry.py +92 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query.py +53 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query_result.py +41 -0
- omnibase_infra/nodes/node_ledger_write_effect/node.py +89 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/__init__.py +13 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/protocol_ledger_persistence.py +127 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/__init__.py +9 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/registry_infra_ledger_write.py +121 -0
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -5
- omnibase_infra/nodes/reducers/models/__init__.py +7 -2
- omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +11 -0
- omnibase_infra/nodes/reducers/models/model_payload_ledger_append.py +133 -0
- omnibase_infra/nodes/reducers/registration_reducer.py +1 -0
- omnibase_infra/protocols/__init__.py +3 -0
- omnibase_infra/protocols/protocol_dispatch_engine.py +152 -0
- omnibase_infra/runtime/__init__.py +60 -0
- omnibase_infra/runtime/binding_resolver.py +753 -0
- omnibase_infra/runtime/constants_security.py +70 -0
- omnibase_infra/runtime/contract_loaders/__init__.py +9 -0
- omnibase_infra/runtime/contract_loaders/operation_bindings_loader.py +789 -0
- omnibase_infra/runtime/emit_daemon/__init__.py +97 -0
- omnibase_infra/runtime/emit_daemon/cli.py +844 -0
- omnibase_infra/runtime/emit_daemon/client.py +811 -0
- omnibase_infra/runtime/emit_daemon/config.py +535 -0
- omnibase_infra/runtime/emit_daemon/daemon.py +812 -0
- omnibase_infra/runtime/emit_daemon/event_registry.py +477 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_request.py +139 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_response.py +191 -0
- omnibase_infra/runtime/emit_daemon/queue.py +618 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +466 -0
- omnibase_infra/runtime/handler_source_resolver.py +43 -2
- omnibase_infra/runtime/kafka_contract_source.py +984 -0
- omnibase_infra/runtime/models/__init__.py +13 -0
- omnibase_infra/runtime/models/model_contract_load_result.py +224 -0
- omnibase_infra/runtime/models/model_runtime_contract_config.py +268 -0
- omnibase_infra/runtime/models/model_runtime_scheduler_config.py +4 -3
- omnibase_infra/runtime/models/model_security_config.py +109 -0
- omnibase_infra/runtime/publisher_topic_scoped.py +294 -0
- omnibase_infra/runtime/runtime_contract_config_loader.py +406 -0
- omnibase_infra/runtime/service_kernel.py +76 -6
- omnibase_infra/runtime/service_message_dispatch_engine.py +558 -15
- omnibase_infra/runtime/service_runtime_host_process.py +770 -20
- omnibase_infra/runtime/transition_notification_publisher.py +3 -2
- omnibase_infra/runtime/util_wiring.py +206 -62
- omnibase_infra/services/mcp/service_mcp_tool_sync.py +27 -9
- omnibase_infra/services/session/config_consumer.py +25 -8
- omnibase_infra/services/session/config_store.py +2 -2
- omnibase_infra/services/session/consumer.py +1 -1
- omnibase_infra/topics/__init__.py +45 -0
- omnibase_infra/topics/platform_topic_suffixes.py +140 -0
- omnibase_infra/topics/util_topic_composition.py +95 -0
- omnibase_infra/types/typed_dict/__init__.py +9 -1
- omnibase_infra/types/typed_dict/typed_dict_envelope_build_params.py +115 -0
- omnibase_infra/utils/__init__.py +9 -0
- omnibase_infra/utils/util_consumer_group.py +232 -0
- omnibase_infra/validation/infra_validators.py +18 -1
- omnibase_infra/validation/validation_exemptions.yaml +192 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/RECORD +139 -52
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/entry_points.txt +1 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Binding expression parser and resolver for operation bindings.
|
|
4
|
+
|
|
5
|
+
This module provides:
|
|
6
|
+
|
|
7
|
+
- **BindingExpressionParser**: Parses ${source.path} expressions with guardrails
|
|
8
|
+
- **BindingExpressionParseError**: Typed error with error codes for parse failures
|
|
9
|
+
- **OperationBindingResolver**: Resolves bindings from envelope/payload/context
|
|
10
|
+
|
|
11
|
+
Expression Syntax
|
|
12
|
+
-----------------
|
|
13
|
+
Expressions follow the format: ``${source.path.to.field}``
|
|
14
|
+
|
|
15
|
+
Where:
|
|
16
|
+
- **source** is one of: ``payload``, ``envelope``, ``context``
|
|
17
|
+
- **path** is a dot-separated sequence of field names (no array indexing)
|
|
18
|
+
|
|
19
|
+
Examples::
|
|
20
|
+
|
|
21
|
+
${payload.user.id} # Resolve from payload.user.id
|
|
22
|
+
${envelope.correlation_id} # Resolve from envelope.correlation_id
|
|
23
|
+
${context.now_iso} # Resolve from context.now_iso
|
|
24
|
+
|
|
25
|
+
Guardrails
|
|
26
|
+
----------
|
|
27
|
+
The parser enforces the following guardrails:
|
|
28
|
+
|
|
29
|
+
- **Max expression length**: 256 characters
|
|
30
|
+
- **Max path depth**: 20 segments
|
|
31
|
+
- **No array indexing**: Expressions like ``${payload.items[0]}`` are rejected
|
|
32
|
+
- **Valid sources only**: Must be ``payload``, ``envelope``, or ``context``
|
|
33
|
+
- **Context path allowlist**: Context paths must be in ``VALID_CONTEXT_PATHS``
|
|
34
|
+
|
|
35
|
+
Resolution Behavior
|
|
36
|
+
-------------------
|
|
37
|
+
The resolver follows these rules:
|
|
38
|
+
|
|
39
|
+
1. Global bindings are applied first
|
|
40
|
+
2. Operation-specific bindings override globals for the same parameter
|
|
41
|
+
3. Required bindings fail fast if resolved value is None
|
|
42
|
+
4. Optional bindings use defaults when resolved value is None
|
|
43
|
+
|
|
44
|
+
Thread Safety
|
|
45
|
+
-------------
|
|
46
|
+
Both ``BindingExpressionParser`` and ``OperationBindingResolver`` are stateless
|
|
47
|
+
and thread-safe. They can be shared across concurrent requests.
|
|
48
|
+
|
|
49
|
+
.. versionadded:: 0.2.6
|
|
50
|
+
Created as part of OMN-1518 - Declarative operation bindings.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import logging
|
|
56
|
+
from enum import Enum
|
|
57
|
+
from typing import Final, Literal
|
|
58
|
+
from uuid import UUID
|
|
59
|
+
|
|
60
|
+
from pydantic import BaseModel
|
|
61
|
+
|
|
62
|
+
from omnibase_core.types import JsonType
|
|
63
|
+
from omnibase_infra.errors import BindingResolutionError
|
|
64
|
+
from omnibase_infra.models.bindings import (
|
|
65
|
+
DEFAULT_JSON_RECURSION_DEPTH,
|
|
66
|
+
EXPRESSION_PATTERN,
|
|
67
|
+
MAX_EXPRESSION_LENGTH,
|
|
68
|
+
MAX_PATH_SEGMENTS,
|
|
69
|
+
VALID_CONTEXT_PATHS,
|
|
70
|
+
VALID_SOURCES,
|
|
71
|
+
ModelBindingResolutionResult,
|
|
72
|
+
ModelOperationBindingsSubcontract,
|
|
73
|
+
ModelParsedBinding,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# =============================================================================
|
|
80
|
+
# Error Codes for Expression Parsing
|
|
81
|
+
# =============================================================================
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class EnumBindingParseErrorCode(str, Enum):
|
|
85
|
+
"""Error codes for binding expression parse failures.
|
|
86
|
+
|
|
87
|
+
These error codes are used by BindingExpressionParseError to provide
|
|
88
|
+
typed error classification. The codes align with the BINDING_LOADER_0xx
|
|
89
|
+
error codes used by the operation bindings loader.
|
|
90
|
+
|
|
91
|
+
.. versionadded:: 0.2.6
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
EXPRESSION_MALFORMED = "BINDING_LOADER_010"
|
|
95
|
+
"""Expression syntax is invalid (missing ${}, wrong delimiters, array access)."""
|
|
96
|
+
|
|
97
|
+
INVALID_SOURCE = "BINDING_LOADER_011"
|
|
98
|
+
"""Source is not one of: payload, envelope, context."""
|
|
99
|
+
|
|
100
|
+
PATH_TOO_DEEP = "BINDING_LOADER_012"
|
|
101
|
+
"""Path exceeds MAX_PATH_SEGMENTS (20 segments)."""
|
|
102
|
+
|
|
103
|
+
EXPRESSION_TOO_LONG = "BINDING_LOADER_013"
|
|
104
|
+
"""Expression exceeds MAX_EXPRESSION_LENGTH (256 characters)."""
|
|
105
|
+
|
|
106
|
+
EMPTY_PATH_SEGMENT = "BINDING_LOADER_014"
|
|
107
|
+
"""Path contains empty segment (e.g., ${payload..field})."""
|
|
108
|
+
|
|
109
|
+
INVALID_CONTEXT_PATH = "BINDING_LOADER_016"
|
|
110
|
+
"""Context path is not in VALID_CONTEXT_PATHS allowlist."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BindingExpressionParseError(ValueError):
|
|
114
|
+
"""Error raised when binding expression parsing fails.
|
|
115
|
+
|
|
116
|
+
This exception provides typed error codes for different parse failure
|
|
117
|
+
scenarios, enabling callers to handle errors based on error code rather
|
|
118
|
+
than parsing error message strings.
|
|
119
|
+
|
|
120
|
+
Attributes:
|
|
121
|
+
error_code: The specific error code identifying the failure type.
|
|
122
|
+
expression: The expression that failed to parse.
|
|
123
|
+
message: Human-readable error message.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
>>> try:
|
|
127
|
+
... parser.parse("${invalid.path}")
|
|
128
|
+
... except BindingExpressionParseError as e:
|
|
129
|
+
... if e.error_code == EnumBindingParseErrorCode.INVALID_SOURCE:
|
|
130
|
+
... # Handle invalid source
|
|
131
|
+
... pass
|
|
132
|
+
|
|
133
|
+
.. versionadded:: 0.2.6
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
message: str,
|
|
139
|
+
error_code: EnumBindingParseErrorCode,
|
|
140
|
+
expression: str,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Initialize the parse error.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
message: Human-readable error description.
|
|
146
|
+
error_code: Typed error code identifying the failure type.
|
|
147
|
+
expression: The expression that failed to parse.
|
|
148
|
+
"""
|
|
149
|
+
super().__init__(message)
|
|
150
|
+
self.error_code = error_code
|
|
151
|
+
self.expression = expression
|
|
152
|
+
self.message = message
|
|
153
|
+
|
|
154
|
+
def __str__(self) -> str:
|
|
155
|
+
"""Return the error message."""
|
|
156
|
+
return self.message
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# =============================================================================
|
|
160
|
+
# JSON Recursion Depth Limit
|
|
161
|
+
# =============================================================================
|
|
162
|
+
|
|
163
|
+
# NOTE: The default value (DEFAULT_JSON_RECURSION_DEPTH = 100) is now imported
|
|
164
|
+
# from omnibase_infra.models.bindings.constants. This enables per-contract
|
|
165
|
+
# configuration via ModelOperationBindingsSubcontract.max_json_recursion_depth.
|
|
166
|
+
#
|
|
167
|
+
# The configurable range is [10, 1000] with sensible defaults:
|
|
168
|
+
# - MIN_JSON_RECURSION_DEPTH = 10 (lower values too restrictive)
|
|
169
|
+
# - MAX_JSON_RECURSION_DEPTH = 1000 (higher values risk stack overflow)
|
|
170
|
+
# - DEFAULT_JSON_RECURSION_DEPTH = 100 (handles normal JSON structures)
|
|
171
|
+
#
|
|
172
|
+
# .. versionchanged:: 0.2.7
|
|
173
|
+
# Made configurable via contract.yaml. See OMN-1518.
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# =============================================================================
|
|
177
|
+
# JSON Compatibility Validation
|
|
178
|
+
# =============================================================================
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_json_compatible(
|
|
182
|
+
value: object,
|
|
183
|
+
max_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
|
|
184
|
+
) -> bool:
|
|
185
|
+
"""Check if a value is JSON-compatible.
|
|
186
|
+
|
|
187
|
+
JSON-compatible values are those that can be serialized to JSON:
|
|
188
|
+
- None
|
|
189
|
+
- Primitives: str, int, float, bool
|
|
190
|
+
- UUID (serializes to string)
|
|
191
|
+
- list (with recursively JSON-compatible elements)
|
|
192
|
+
- dict with str keys (with recursively JSON-compatible values)
|
|
193
|
+
|
|
194
|
+
This function performs recursive validation for nested structures.
|
|
195
|
+
It guards against infinite recursion by limiting depth to
|
|
196
|
+
``max_depth`` levels (default: DEFAULT_JSON_RECURSION_DEPTH = 100).
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
value: The value to check for JSON compatibility.
|
|
200
|
+
max_depth: Maximum recursion depth for validation. Defaults to
|
|
201
|
+
DEFAULT_JSON_RECURSION_DEPTH (100). Can be configured per-contract
|
|
202
|
+
via ModelOperationBindingsSubcontract.max_json_recursion_depth.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if the value is JSON-compatible, False otherwise.
|
|
206
|
+
|
|
207
|
+
Examples:
|
|
208
|
+
>>> _is_json_compatible(None)
|
|
209
|
+
True
|
|
210
|
+
>>> _is_json_compatible("hello")
|
|
211
|
+
True
|
|
212
|
+
>>> _is_json_compatible({"key": [1, 2, 3]})
|
|
213
|
+
True
|
|
214
|
+
>>> from uuid import UUID
|
|
215
|
+
>>> _is_json_compatible(UUID("12345678-1234-5678-1234-567812345678"))
|
|
216
|
+
True
|
|
217
|
+
>>> _is_json_compatible(object())
|
|
218
|
+
False
|
|
219
|
+
>>> _is_json_compatible({"key": lambda x: x})
|
|
220
|
+
False
|
|
221
|
+
|
|
222
|
+
# With custom depth limit
|
|
223
|
+
>>> _is_json_compatible({"a": {"b": {"c": 1}}}, max_depth=50)
|
|
224
|
+
True
|
|
225
|
+
|
|
226
|
+
.. versionadded:: 0.2.6
|
|
227
|
+
.. versionchanged:: 0.2.7
|
|
228
|
+
Added max_depth parameter for per-contract configuration.
|
|
229
|
+
"""
|
|
230
|
+
return _is_json_compatible_recursive(value, depth=0, max_depth=max_depth)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_json_compatible_recursive(
|
|
234
|
+
value: object,
|
|
235
|
+
depth: int,
|
|
236
|
+
max_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
|
|
237
|
+
) -> bool:
|
|
238
|
+
"""Recursive implementation of JSON compatibility check.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
value: The value to check.
|
|
242
|
+
depth: Current recursion depth for overflow protection.
|
|
243
|
+
max_depth: Maximum allowed recursion depth.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
True if the value is JSON-compatible, False otherwise.
|
|
247
|
+
"""
|
|
248
|
+
# Depth guard to prevent stack overflow on pathological inputs
|
|
249
|
+
if depth > max_depth:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
# None is JSON-compatible (maps to JSON null)
|
|
253
|
+
if value is None:
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
# JSON primitives
|
|
257
|
+
if isinstance(value, (str, int, float, bool)):
|
|
258
|
+
# Note: bool must be checked with isinstance since bool is a subclass of int
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
# UUID is JSON-compatible (serializes to string)
|
|
262
|
+
if isinstance(value, UUID):
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
# List: all elements must be JSON-compatible
|
|
266
|
+
if isinstance(value, list):
|
|
267
|
+
return all(
|
|
268
|
+
_is_json_compatible_recursive(item, depth + 1, max_depth) for item in value
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Dict: keys must be str, values must be JSON-compatible
|
|
272
|
+
if isinstance(value, dict):
|
|
273
|
+
return all(
|
|
274
|
+
isinstance(k, str)
|
|
275
|
+
and _is_json_compatible_recursive(v, depth + 1, max_depth)
|
|
276
|
+
for k, v in value.items()
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Anything else (objects, callables, etc.) is not JSON-compatible
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# =============================================================================
|
|
284
|
+
# BindingExpressionParser
|
|
285
|
+
# =============================================================================
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class BindingExpressionParser:
|
|
289
|
+
"""Parse ${source.path} expressions with guardrails.
|
|
290
|
+
|
|
291
|
+
This parser validates and decomposes binding expressions into their
|
|
292
|
+
constituent parts: source and path segments. It enforces all guardrails
|
|
293
|
+
at parse time to fail fast on invalid expressions.
|
|
294
|
+
|
|
295
|
+
Guardrails enforced (default values, can be overridden per-contract):
|
|
296
|
+
- Max expression length: 256 characters (configurable: 32-1024)
|
|
297
|
+
- Max path depth: 20 segments (configurable: 3-50)
|
|
298
|
+
- No array indexing (``[0]``, ``[*]``)
|
|
299
|
+
- Source must be: ``payload`` | ``envelope`` | ``context``
|
|
300
|
+
- Context paths must be in ``VALID_CONTEXT_PATHS`` or additional_context_paths
|
|
301
|
+
|
|
302
|
+
This class is stateless and thread-safe.
|
|
303
|
+
|
|
304
|
+
Example:
|
|
305
|
+
>>> parser = BindingExpressionParser()
|
|
306
|
+
>>> source, path = parser.parse("${payload.user.id}")
|
|
307
|
+
>>> source
|
|
308
|
+
'payload'
|
|
309
|
+
>>> path
|
|
310
|
+
('user', 'id')
|
|
311
|
+
|
|
312
|
+
# With custom limits
|
|
313
|
+
>>> source, path = parser.parse(
|
|
314
|
+
... "${payload.very.deep.nested.path}",
|
|
315
|
+
... max_expression_length=512,
|
|
316
|
+
... max_path_segments=30,
|
|
317
|
+
... )
|
|
318
|
+
|
|
319
|
+
.. versionadded:: 0.2.6
|
|
320
|
+
.. versionchanged:: 0.2.7
|
|
321
|
+
Added max_expression_length, max_path_segments, and additional_context_paths
|
|
322
|
+
parameters for per-contract guardrail overrides.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def parse(
|
|
326
|
+
self,
|
|
327
|
+
expression: str,
|
|
328
|
+
max_expression_length: int | None = None,
|
|
329
|
+
max_path_segments: int | None = None,
|
|
330
|
+
additional_context_paths: frozenset[str] | None = None,
|
|
331
|
+
) -> tuple[Literal["payload", "envelope", "context"], tuple[str, ...]]:
|
|
332
|
+
"""Parse expression into (source, path_segments).
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
expression: Expression in ``${source.path.to.field}`` format.
|
|
336
|
+
max_expression_length: Override default expression length limit.
|
|
337
|
+
If None, uses MAX_EXPRESSION_LENGTH (256).
|
|
338
|
+
max_path_segments: Override default path segment limit.
|
|
339
|
+
If None, uses MAX_PATH_SEGMENTS (20).
|
|
340
|
+
additional_context_paths: Additional valid context paths beyond the
|
|
341
|
+
base VALID_CONTEXT_PATHS set. If provided, these paths will be
|
|
342
|
+
accepted for context source expressions.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Tuple of (source, path_segments) where source is one of
|
|
346
|
+
``"payload"``, ``"envelope"``, ``"context"`` and path_segments
|
|
347
|
+
is a tuple of field names.
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
BindingExpressionParseError: If expression is malformed or violates
|
|
351
|
+
any guardrail. The exception includes a typed ``error_code`` attribute
|
|
352
|
+
for programmatic error handling:
|
|
353
|
+
|
|
354
|
+
- ``EXPRESSION_TOO_LONG``: Expression exceeds max length
|
|
355
|
+
- ``EXPRESSION_MALFORMED``: Array access or invalid syntax
|
|
356
|
+
- ``INVALID_SOURCE``: Source is not valid
|
|
357
|
+
- ``EMPTY_PATH_SEGMENT``: Path contains empty segments
|
|
358
|
+
- ``PATH_TOO_DEEP``: Path exceeds max segments
|
|
359
|
+
- ``INVALID_CONTEXT_PATH``: Context path is not in allowlist
|
|
360
|
+
|
|
361
|
+
Example:
|
|
362
|
+
>>> parser = BindingExpressionParser()
|
|
363
|
+
>>> parser.parse("${payload.user.email}")
|
|
364
|
+
('payload', ('user', 'email'))
|
|
365
|
+
|
|
366
|
+
>>> parser.parse("${context.now_iso}")
|
|
367
|
+
('context', ('now_iso',))
|
|
368
|
+
|
|
369
|
+
>>> parser.parse("${payload.items[0]}") # Raises BindingExpressionParseError
|
|
370
|
+
Traceback (most recent call last):
|
|
371
|
+
...
|
|
372
|
+
BindingExpressionParseError: Array access not allowed in expressions: ...
|
|
373
|
+
|
|
374
|
+
.. versionchanged:: 0.2.7
|
|
375
|
+
Added max_expression_length, max_path_segments, and additional_context_paths
|
|
376
|
+
parameters for per-contract guardrail overrides.
|
|
377
|
+
"""
|
|
378
|
+
# Use configured limits or defaults
|
|
379
|
+
effective_max_length = (
|
|
380
|
+
max_expression_length
|
|
381
|
+
if max_expression_length is not None
|
|
382
|
+
else MAX_EXPRESSION_LENGTH
|
|
383
|
+
)
|
|
384
|
+
effective_max_segments = (
|
|
385
|
+
max_path_segments if max_path_segments is not None else MAX_PATH_SEGMENTS
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Guardrail: max length
|
|
389
|
+
if len(expression) > effective_max_length:
|
|
390
|
+
raise BindingExpressionParseError(
|
|
391
|
+
f"Expression exceeds max length ({len(expression)} > {effective_max_length})",
|
|
392
|
+
error_code=EnumBindingParseErrorCode.EXPRESSION_TOO_LONG,
|
|
393
|
+
expression=expression,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Guardrail: no array access
|
|
397
|
+
if "[" in expression or "]" in expression:
|
|
398
|
+
raise BindingExpressionParseError(
|
|
399
|
+
f"Array access not allowed in expressions: {expression}",
|
|
400
|
+
error_code=EnumBindingParseErrorCode.EXPRESSION_MALFORMED,
|
|
401
|
+
expression=expression,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Parse expression
|
|
405
|
+
match = EXPRESSION_PATTERN.match(expression)
|
|
406
|
+
if not match:
|
|
407
|
+
raise BindingExpressionParseError(
|
|
408
|
+
f"Invalid expression syntax: {expression}. "
|
|
409
|
+
f"Expected format: ${{source.path.to.field}}",
|
|
410
|
+
error_code=EnumBindingParseErrorCode.EXPRESSION_MALFORMED,
|
|
411
|
+
expression=expression,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
source = match.group(1)
|
|
415
|
+
path_str = match.group(2)
|
|
416
|
+
|
|
417
|
+
# Validate source
|
|
418
|
+
if source not in VALID_SOURCES:
|
|
419
|
+
raise BindingExpressionParseError(
|
|
420
|
+
f"Invalid source '{source}'. Must be one of: {sorted(VALID_SOURCES)}",
|
|
421
|
+
error_code=EnumBindingParseErrorCode.INVALID_SOURCE,
|
|
422
|
+
expression=expression,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Parse path segments
|
|
426
|
+
path_segments = tuple(path_str.split("."))
|
|
427
|
+
|
|
428
|
+
# Guardrail: no empty segments
|
|
429
|
+
if any(segment == "" for segment in path_segments):
|
|
430
|
+
raise BindingExpressionParseError(
|
|
431
|
+
f"Empty path segment in expression: {expression}",
|
|
432
|
+
error_code=EnumBindingParseErrorCode.EMPTY_PATH_SEGMENT,
|
|
433
|
+
expression=expression,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Guardrail: max segments
|
|
437
|
+
if len(path_segments) > effective_max_segments:
|
|
438
|
+
raise BindingExpressionParseError(
|
|
439
|
+
f"Path exceeds max segments ({len(path_segments)} > {effective_max_segments})",
|
|
440
|
+
error_code=EnumBindingParseErrorCode.PATH_TOO_DEEP,
|
|
441
|
+
expression=expression,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Validate context paths (allowlist with optional extensions)
|
|
445
|
+
if source == "context":
|
|
446
|
+
# Build effective valid context paths
|
|
447
|
+
effective_context_paths = VALID_CONTEXT_PATHS
|
|
448
|
+
if additional_context_paths:
|
|
449
|
+
effective_context_paths = VALID_CONTEXT_PATHS | additional_context_paths
|
|
450
|
+
|
|
451
|
+
if path_segments[0] not in effective_context_paths:
|
|
452
|
+
raise BindingExpressionParseError(
|
|
453
|
+
f"Invalid context path '{path_segments[0]}'. "
|
|
454
|
+
f"Must be one of: {sorted(effective_context_paths)}",
|
|
455
|
+
error_code=EnumBindingParseErrorCode.INVALID_CONTEXT_PATH,
|
|
456
|
+
expression=expression,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Type narrowing: source is guaranteed to be one of the valid values
|
|
460
|
+
return source, path_segments # type: ignore[return-value]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# =============================================================================
|
|
464
|
+
# OperationBindingResolver
|
|
465
|
+
# =============================================================================
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class OperationBindingResolver:
|
|
469
|
+
"""Resolve operation bindings from envelope and context.
|
|
470
|
+
|
|
471
|
+
This resolver takes pre-parsed bindings (from contract loading) and
|
|
472
|
+
resolves them against an event envelope and optional context. It handles
|
|
473
|
+
global bindings, operation-specific bindings, defaults, and required
|
|
474
|
+
field validation.
|
|
475
|
+
|
|
476
|
+
This class is stateless and thread-safe. A single instance can be
|
|
477
|
+
shared across concurrent requests.
|
|
478
|
+
|
|
479
|
+
Resolution Order:
|
|
480
|
+
1. Apply global_bindings first
|
|
481
|
+
2. Apply operation-specific bindings (overwrite globals)
|
|
482
|
+
3. Validate required fields (fail fast on None)
|
|
483
|
+
4. Apply defaults for optional fields
|
|
484
|
+
5. Return resolved parameters
|
|
485
|
+
|
|
486
|
+
Example:
|
|
487
|
+
>>> resolver = OperationBindingResolver()
|
|
488
|
+
>>> result = resolver.resolve(
|
|
489
|
+
... operation="db.query",
|
|
490
|
+
... bindings_subcontract=subcontract,
|
|
491
|
+
... envelope=envelope,
|
|
492
|
+
... context=context,
|
|
493
|
+
... )
|
|
494
|
+
>>> if result:
|
|
495
|
+
... execute_query(**result.resolved_parameters)
|
|
496
|
+
|
|
497
|
+
.. versionadded:: 0.2.6
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
def __init__(self) -> None:
|
|
501
|
+
"""Initialize resolver with expression parser."""
|
|
502
|
+
self._parser = BindingExpressionParser()
|
|
503
|
+
|
|
504
|
+
def resolve(
|
|
505
|
+
self,
|
|
506
|
+
operation: str,
|
|
507
|
+
bindings_subcontract: ModelOperationBindingsSubcontract,
|
|
508
|
+
envelope: object,
|
|
509
|
+
context: object | None,
|
|
510
|
+
correlation_id: UUID | None = None,
|
|
511
|
+
) -> ModelBindingResolutionResult:
|
|
512
|
+
"""Resolve all bindings for an operation.
|
|
513
|
+
|
|
514
|
+
Resolution order:
|
|
515
|
+
1. Apply global_bindings first
|
|
516
|
+
2. Apply operation-specific bindings (overwrite globals)
|
|
517
|
+
3. Validate required fields
|
|
518
|
+
4. Return resolved parameters or fail fast
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
operation: Operation name (e.g., ``"db.query"``).
|
|
522
|
+
bindings_subcontract: Subcontract with binding definitions
|
|
523
|
+
(pre-parsed from contract.yaml).
|
|
524
|
+
envelope: Event envelope with payload. Can be a dict or
|
|
525
|
+
Pydantic model with ``payload`` attribute.
|
|
526
|
+
context: Dispatch context (may be None). Can be a dict or
|
|
527
|
+
Pydantic model.
|
|
528
|
+
correlation_id: Request correlation for error context and
|
|
529
|
+
distributed tracing.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
``ModelBindingResolutionResult`` with resolved parameters.
|
|
533
|
+
Check ``result.success`` or use ``if result:`` to verify
|
|
534
|
+
resolution succeeded.
|
|
535
|
+
|
|
536
|
+
Raises:
|
|
537
|
+
BindingResolutionError: If a required binding resolves to None
|
|
538
|
+
(fail-fast behavior). The error includes diagnostic context
|
|
539
|
+
including operation name, parameter name, and expression.
|
|
540
|
+
|
|
541
|
+
Example:
|
|
542
|
+
>>> result = resolver.resolve(
|
|
543
|
+
... operation="db.query",
|
|
544
|
+
... bindings_subcontract=subcontract,
|
|
545
|
+
... envelope={"payload": {"sql": "SELECT 1"}},
|
|
546
|
+
... context={"now_iso": "2025-01-01T00:00:00Z"},
|
|
547
|
+
... )
|
|
548
|
+
>>> result.resolved_parameters
|
|
549
|
+
{'sql': 'SELECT 1', 'timestamp': '2025-01-01T00:00:00Z'}
|
|
550
|
+
"""
|
|
551
|
+
resolved_parameters: dict[str, JsonType] = {}
|
|
552
|
+
resolved_from: dict[str, str] = {}
|
|
553
|
+
|
|
554
|
+
# Collect all bindings to process (global first, then operation-specific)
|
|
555
|
+
# Operation-specific bindings override globals for the same parameter
|
|
556
|
+
bindings_to_process: list[ModelParsedBinding] = []
|
|
557
|
+
|
|
558
|
+
if bindings_subcontract.global_bindings:
|
|
559
|
+
bindings_to_process.extend(bindings_subcontract.global_bindings)
|
|
560
|
+
|
|
561
|
+
operation_bindings = bindings_subcontract.bindings.get(operation, [])
|
|
562
|
+
bindings_to_process.extend(operation_bindings)
|
|
563
|
+
|
|
564
|
+
# Get max JSON recursion depth from contract (or use default)
|
|
565
|
+
max_json_depth = bindings_subcontract.max_json_recursion_depth
|
|
566
|
+
|
|
567
|
+
# Process each binding
|
|
568
|
+
for binding in bindings_to_process:
|
|
569
|
+
try:
|
|
570
|
+
value = self._resolve_single_binding(
|
|
571
|
+
binding=binding,
|
|
572
|
+
envelope=envelope,
|
|
573
|
+
context=context,
|
|
574
|
+
max_json_recursion_depth=max_json_depth,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if value is None and binding.required:
|
|
578
|
+
# Fail fast on missing required binding
|
|
579
|
+
raise BindingResolutionError(
|
|
580
|
+
f"Required binding '{binding.parameter_name}' resolved to None",
|
|
581
|
+
operation_name=operation,
|
|
582
|
+
parameter_name=binding.parameter_name,
|
|
583
|
+
expression=binding.original_expression,
|
|
584
|
+
missing_segment=binding.path_segments[-1]
|
|
585
|
+
if binding.path_segments
|
|
586
|
+
else None,
|
|
587
|
+
correlation_id=correlation_id,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Use default if value is None and not required
|
|
591
|
+
if value is None and binding.default is not None:
|
|
592
|
+
value = binding.default
|
|
593
|
+
|
|
594
|
+
resolved_parameters[binding.parameter_name] = value
|
|
595
|
+
resolved_from[binding.parameter_name] = binding.original_expression
|
|
596
|
+
|
|
597
|
+
except BindingResolutionError:
|
|
598
|
+
# Re-raise BindingResolutionError without wrapping
|
|
599
|
+
raise
|
|
600
|
+
except Exception as e:
|
|
601
|
+
# Wrap unexpected errors with diagnostic context
|
|
602
|
+
raise BindingResolutionError(
|
|
603
|
+
f"Failed to resolve binding: {e}",
|
|
604
|
+
operation_name=operation,
|
|
605
|
+
parameter_name=binding.parameter_name,
|
|
606
|
+
expression=binding.original_expression,
|
|
607
|
+
correlation_id=correlation_id,
|
|
608
|
+
) from e
|
|
609
|
+
|
|
610
|
+
return ModelBindingResolutionResult(
|
|
611
|
+
operation_name=operation,
|
|
612
|
+
resolved_parameters=resolved_parameters,
|
|
613
|
+
resolved_from=resolved_from,
|
|
614
|
+
success=True,
|
|
615
|
+
error=None,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def _resolve_single_binding(
|
|
619
|
+
self,
|
|
620
|
+
binding: ModelParsedBinding,
|
|
621
|
+
envelope: object,
|
|
622
|
+
context: object | None,
|
|
623
|
+
max_json_recursion_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
|
|
624
|
+
) -> JsonType:
|
|
625
|
+
"""Resolve a single binding from envelope/context.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
binding: Pre-parsed binding with source and path.
|
|
629
|
+
envelope: Event envelope (dict or Pydantic model).
|
|
630
|
+
context: Dispatch context (dict, Pydantic model, or None).
|
|
631
|
+
max_json_recursion_depth: Maximum depth for JSON compatibility
|
|
632
|
+
validation. Defaults to DEFAULT_JSON_RECURSION_DEPTH (100).
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
Resolved value (may be None if path doesn't exist).
|
|
636
|
+
|
|
637
|
+
.. versionchanged:: 0.2.7
|
|
638
|
+
Added max_json_recursion_depth parameter for per-contract configuration.
|
|
639
|
+
"""
|
|
640
|
+
# Get source object based on binding source
|
|
641
|
+
if binding.source == "payload":
|
|
642
|
+
source_obj = self._get_payload(envelope)
|
|
643
|
+
elif binding.source == "envelope":
|
|
644
|
+
source_obj = envelope
|
|
645
|
+
elif binding.source == "context":
|
|
646
|
+
source_obj = context
|
|
647
|
+
else:
|
|
648
|
+
# Should not happen if bindings are pre-validated
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
if source_obj is None:
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
# Traverse path to get value
|
|
655
|
+
return self._traverse_path(
|
|
656
|
+
source_obj,
|
|
657
|
+
binding.path_segments,
|
|
658
|
+
max_json_recursion_depth=max_json_recursion_depth,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def _get_payload(self, envelope: object) -> object | None:
|
|
662
|
+
"""Extract payload from envelope.
|
|
663
|
+
|
|
664
|
+
Supports both dict-based envelopes and Pydantic model envelopes.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
envelope: Event envelope.
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
Payload object or None if not found.
|
|
671
|
+
"""
|
|
672
|
+
if isinstance(envelope, dict):
|
|
673
|
+
return envelope.get("payload")
|
|
674
|
+
if hasattr(envelope, "payload"):
|
|
675
|
+
return getattr(envelope, "payload", None)
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
def _traverse_path(
|
|
679
|
+
self,
|
|
680
|
+
obj: object,
|
|
681
|
+
path_segments: tuple[str, ...],
|
|
682
|
+
max_json_recursion_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
|
|
683
|
+
) -> JsonType:
|
|
684
|
+
"""Traverse path segments to get value.
|
|
685
|
+
|
|
686
|
+
Uses ``dict.get()`` for dicts and ``getattr()`` for objects.
|
|
687
|
+
Returns None if any segment in the path doesn't exist.
|
|
688
|
+
|
|
689
|
+
Runtime Validation:
|
|
690
|
+
The returned value is validated at runtime to ensure JSON
|
|
691
|
+
compatibility. If the traversed value is not JSON-compatible
|
|
692
|
+
(e.g., a callable, custom object, or other non-serializable type),
|
|
693
|
+
a warning is logged and None is returned for graceful degradation.
|
|
694
|
+
|
|
695
|
+
This validation prevents non-JSON values from propagating through
|
|
696
|
+
the binding resolution pipeline, which would cause serialization
|
|
697
|
+
failures downstream.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
obj: Starting object to traverse from.
|
|
701
|
+
path_segments: Tuple of field names to traverse.
|
|
702
|
+
max_json_recursion_depth: Maximum depth for JSON compatibility
|
|
703
|
+
validation. Defaults to DEFAULT_JSON_RECURSION_DEPTH (100).
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
Value at path or None if:
|
|
707
|
+
- Path doesn't exist
|
|
708
|
+
- Value at path is not JSON-compatible (logs warning)
|
|
709
|
+
|
|
710
|
+
.. versionchanged:: 0.2.7
|
|
711
|
+
Added max_json_recursion_depth parameter for per-contract configuration.
|
|
712
|
+
"""
|
|
713
|
+
current: object = obj
|
|
714
|
+
|
|
715
|
+
for segment in path_segments:
|
|
716
|
+
if current is None:
|
|
717
|
+
return None
|
|
718
|
+
|
|
719
|
+
if isinstance(current, dict):
|
|
720
|
+
current = current.get(segment)
|
|
721
|
+
elif isinstance(current, BaseModel):
|
|
722
|
+
# Pydantic models: use getattr
|
|
723
|
+
current = getattr(current, segment, None)
|
|
724
|
+
elif hasattr(current, segment):
|
|
725
|
+
# Generic objects with attributes
|
|
726
|
+
current = getattr(current, segment, None)
|
|
727
|
+
else:
|
|
728
|
+
# No way to access the segment
|
|
729
|
+
return None
|
|
730
|
+
|
|
731
|
+
# Runtime validation: ensure value is JSON-compatible
|
|
732
|
+
# The type checker sees 'object' but binding paths should only
|
|
733
|
+
# resolve to JSON-compatible values. We validate at runtime to
|
|
734
|
+
# catch cases where non-JSON values (e.g., callables, custom objects)
|
|
735
|
+
# are accidentally exposed through the binding path.
|
|
736
|
+
if not _is_json_compatible(current, max_depth=max_json_recursion_depth):
|
|
737
|
+
logger.warning(
|
|
738
|
+
"Binding path '%s' resolved to non-JSON-compatible value of type '%s'. "
|
|
739
|
+
"Returning None for graceful degradation.",
|
|
740
|
+
".".join(path_segments),
|
|
741
|
+
type(current).__name__,
|
|
742
|
+
)
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
return current # type: ignore[return-value]
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
__all__: list[str] = [
|
|
749
|
+
"BindingExpressionParseError",
|
|
750
|
+
"BindingExpressionParser",
|
|
751
|
+
"EnumBindingParseErrorCode",
|
|
752
|
+
"OperationBindingResolver",
|
|
753
|
+
]
|