miso-client 0.1.0__py3-none-any.whl → 3.7.2__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.
- miso_client/__init__.py +523 -130
- miso_client/api/__init__.py +35 -0
- miso_client/api/auth_api.py +367 -0
- miso_client/api/logs_api.py +91 -0
- miso_client/api/permissions_api.py +88 -0
- miso_client/api/roles_api.py +88 -0
- miso_client/api/types/__init__.py +75 -0
- miso_client/api/types/auth_types.py +183 -0
- miso_client/api/types/logs_types.py +71 -0
- miso_client/api/types/permissions_types.py +31 -0
- miso_client/api/types/roles_types.py +31 -0
- miso_client/errors.py +30 -4
- miso_client/models/__init__.py +4 -0
- miso_client/models/config.py +275 -72
- miso_client/models/error_response.py +39 -0
- miso_client/models/filter.py +255 -0
- miso_client/models/pagination.py +44 -0
- miso_client/models/sort.py +25 -0
- miso_client/services/__init__.py +6 -5
- miso_client/services/auth.py +496 -87
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +467 -328
- miso_client/services/logger_chain.py +288 -0
- miso_client/services/permission.py +130 -67
- miso_client/services/redis.py +28 -23
- miso_client/services/role.py +145 -62
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/audit_log_queue.py +222 -0
- miso_client/utils/auth_strategy.py +88 -0
- miso_client/utils/auth_utils.py +65 -0
- miso_client/utils/circuit_breaker.py +125 -0
- miso_client/utils/client_token_manager.py +244 -0
- miso_client/utils/config_loader.py +88 -17
- miso_client/utils/controller_url_resolver.py +80 -0
- miso_client/utils/data_masker.py +104 -33
- miso_client/utils/environment_token.py +126 -0
- miso_client/utils/error_utils.py +216 -0
- miso_client/utils/fastapi_endpoints.py +166 -0
- miso_client/utils/filter.py +364 -0
- miso_client/utils/filter_applier.py +143 -0
- miso_client/utils/filter_parser.py +110 -0
- miso_client/utils/flask_endpoints.py +169 -0
- miso_client/utils/http_client.py +494 -262
- miso_client/utils/http_client_logging.py +352 -0
- miso_client/utils/http_client_logging_helpers.py +197 -0
- miso_client/utils/http_client_query_helpers.py +138 -0
- miso_client/utils/http_error_handler.py +92 -0
- miso_client/utils/http_log_formatter.py +115 -0
- miso_client/utils/http_log_masker.py +203 -0
- miso_client/utils/internal_http_client.py +435 -0
- miso_client/utils/jwt_tools.py +125 -16
- miso_client/utils/logger_helpers.py +206 -0
- miso_client/utils/logging_helpers.py +70 -0
- miso_client/utils/origin_validator.py +128 -0
- miso_client/utils/pagination.py +275 -0
- miso_client/utils/request_context.py +285 -0
- miso_client/utils/sensitive_fields_loader.py +116 -0
- miso_client/utils/sort.py +116 -0
- miso_client/utils/token_utils.py +114 -0
- miso_client/utils/url_validator.py +66 -0
- miso_client/utils/user_token_refresh.py +245 -0
- miso_client-3.7.2.dist-info/METADATA +1021 -0
- miso_client-3.7.2.dist-info/RECORD +68 -0
- miso_client-0.1.0.dist-info/METADATA +0 -551
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI endpoint utilities for client token endpoint.
|
|
3
|
+
|
|
4
|
+
Provides server-side route handlers for creating client token endpoints
|
|
5
|
+
that return client token + DataClient configuration to frontend clients.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from ..errors import AuthenticationError
|
|
11
|
+
from ..models.config import (
|
|
12
|
+
ClientTokenEndpointOptions,
|
|
13
|
+
ClientTokenEndpointResponse,
|
|
14
|
+
DataClientConfigResponse,
|
|
15
|
+
MisoClientConfig,
|
|
16
|
+
)
|
|
17
|
+
from ..utils.environment_token import get_environment_token
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_fastapi_client_token_endpoint(
|
|
21
|
+
miso_client: Any, options: Optional[ClientTokenEndpointOptions] = None
|
|
22
|
+
) -> Callable[[Any], Any]:
|
|
23
|
+
"""
|
|
24
|
+
Create FastAPI route handler for client-token endpoint.
|
|
25
|
+
|
|
26
|
+
Automatically enriches response with DataClient configuration including
|
|
27
|
+
controllerPublicUrl for frontend client initialization.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
miso_client: MisoClient instance (must be initialized)
|
|
31
|
+
options: Optional configuration for endpoint
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
FastAPI route handler function
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> from fastapi import FastAPI
|
|
38
|
+
>>> from miso_client import MisoClient, create_fastapi_client_token_endpoint, load_config
|
|
39
|
+
>>>
|
|
40
|
+
>>> app = FastAPI()
|
|
41
|
+
>>> client = MisoClient(load_config())
|
|
42
|
+
>>> await client.initialize()
|
|
43
|
+
>>>
|
|
44
|
+
>>> app.post('/api/v1/auth/client-token')(create_fastapi_client_token_endpoint(client))
|
|
45
|
+
"""
|
|
46
|
+
opts = ClientTokenEndpointOptions(
|
|
47
|
+
clientTokenUri=options.clientTokenUri if options else "/api/v1/auth/client-token",
|
|
48
|
+
expiresIn=options.expiresIn if options else 1800,
|
|
49
|
+
includeConfig=options.includeConfig if options else True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def handler(request: Any) -> ClientTokenEndpointResponse:
|
|
53
|
+
"""
|
|
54
|
+
FastAPI route handler for client token endpoint.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
request: FastAPI Request object
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
ClientTokenEndpointResponse with token and optional config
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
HTTPException: With appropriate status code on errors
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
# Check if misoClient is initialized
|
|
67
|
+
if not miso_client.is_initialized():
|
|
68
|
+
try:
|
|
69
|
+
from fastapi import HTTPException
|
|
70
|
+
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=503,
|
|
73
|
+
detail={
|
|
74
|
+
"error": "Service Unavailable",
|
|
75
|
+
"message": "MisoClient is not initialized",
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
except ImportError:
|
|
79
|
+
raise RuntimeError("FastAPI is not installed")
|
|
80
|
+
|
|
81
|
+
# Get token with origin validation (raises AuthenticationError if validation fails)
|
|
82
|
+
token = await get_environment_token(miso_client, request.headers)
|
|
83
|
+
|
|
84
|
+
# Build response
|
|
85
|
+
response: ClientTokenEndpointResponse = ClientTokenEndpointResponse(
|
|
86
|
+
token=token, expiresIn=opts.expiresIn or 1800
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Include config if requested
|
|
90
|
+
if opts.includeConfig:
|
|
91
|
+
config: MisoClientConfig = miso_client.config
|
|
92
|
+
|
|
93
|
+
# Derive baseUrl from request
|
|
94
|
+
# request.base_url is a URL object in FastAPI
|
|
95
|
+
base_url = str(request.base_url).rstrip("/")
|
|
96
|
+
|
|
97
|
+
# Get controller URL (prefer controllerPublicUrl for browser, fallback to controller_url)
|
|
98
|
+
controller_url = config.controllerPublicUrl or config.controller_url
|
|
99
|
+
|
|
100
|
+
if not controller_url:
|
|
101
|
+
try:
|
|
102
|
+
from fastapi import HTTPException
|
|
103
|
+
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
status_code=500,
|
|
106
|
+
detail={
|
|
107
|
+
"error": "Internal Server Error",
|
|
108
|
+
"message": "Controller URL not configured",
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
except ImportError:
|
|
112
|
+
raise RuntimeError("FastAPI is not installed")
|
|
113
|
+
|
|
114
|
+
response.config = DataClientConfigResponse(
|
|
115
|
+
baseUrl=base_url,
|
|
116
|
+
controllerUrl=controller_url,
|
|
117
|
+
controllerPublicUrl=config.controllerPublicUrl,
|
|
118
|
+
clientId=config.client_id,
|
|
119
|
+
clientTokenUri=opts.clientTokenUri or "/api/v1/auth/client-token",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
except AuthenticationError as error:
|
|
125
|
+
# Origin validation failed (403)
|
|
126
|
+
error_message = str(error)
|
|
127
|
+
try:
|
|
128
|
+
from fastapi import HTTPException
|
|
129
|
+
|
|
130
|
+
if "Origin validation failed" in error_message:
|
|
131
|
+
raise HTTPException(
|
|
132
|
+
status_code=403,
|
|
133
|
+
detail={
|
|
134
|
+
"error": "Forbidden",
|
|
135
|
+
"message": error_message,
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Other authentication errors (500)
|
|
140
|
+
raise HTTPException(
|
|
141
|
+
status_code=500,
|
|
142
|
+
detail={
|
|
143
|
+
"error": "Internal Server Error",
|
|
144
|
+
"message": error_message,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
except ImportError:
|
|
148
|
+
raise RuntimeError("FastAPI is not installed")
|
|
149
|
+
|
|
150
|
+
except Exception as error:
|
|
151
|
+
# Other errors (500)
|
|
152
|
+
error_message = str(error) if error else "Unknown error"
|
|
153
|
+
try:
|
|
154
|
+
from fastapi import HTTPException
|
|
155
|
+
|
|
156
|
+
raise HTTPException(
|
|
157
|
+
status_code=500,
|
|
158
|
+
detail={
|
|
159
|
+
"error": "Internal Server Error",
|
|
160
|
+
"message": error_message,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
except ImportError:
|
|
164
|
+
raise RuntimeError("FastAPI is not installed")
|
|
165
|
+
|
|
166
|
+
return handler
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filter utilities for MisoClient SDK.
|
|
3
|
+
|
|
4
|
+
This module provides reusable filter utilities for parsing filter parameters,
|
|
5
|
+
building query strings, and applying filters to arrays.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
from urllib.parse import parse_qs, quote, urlparse
|
|
10
|
+
|
|
11
|
+
from ..models.filter import FilterQuery, JsonFilter
|
|
12
|
+
from .filter_applier import apply_filters # noqa: F401
|
|
13
|
+
from .filter_parser import parse_filter_params # noqa: F401
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_query_string(filter_query: FilterQuery) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Convert FilterQuery object to query string.
|
|
19
|
+
|
|
20
|
+
Builds query string with filter, sort, page, pageSize, and fields parameters.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
filter_query: FilterQuery object with filters, sort, pagination, and fields
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Query string (e.g., '?filter=status:eq:active&page=1&pageSize=25&sort=-updated_at')
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> from miso_client.models.filter import FilterQuery, FilterOption
|
|
30
|
+
>>> query = FilterQuery(
|
|
31
|
+
... filters=[FilterOption(field='status', op='eq', value='active')],
|
|
32
|
+
... page=1,
|
|
33
|
+
... pageSize=25
|
|
34
|
+
... )
|
|
35
|
+
>>> build_query_string(query)
|
|
36
|
+
'filter=status:eq:active&page=1&pageSize=25'
|
|
37
|
+
"""
|
|
38
|
+
query_parts: List[str] = []
|
|
39
|
+
|
|
40
|
+
# Add filters
|
|
41
|
+
if filter_query.filters:
|
|
42
|
+
for filter_option in filter_query.filters:
|
|
43
|
+
# URL encode field
|
|
44
|
+
field_encoded = quote(filter_option.field)
|
|
45
|
+
|
|
46
|
+
# Handle null check operators (no value needed)
|
|
47
|
+
if filter_option.op in ("isNull", "isNotNull"):
|
|
48
|
+
query_parts.append(f"filter={field_encoded}:{filter_option.op}")
|
|
49
|
+
else:
|
|
50
|
+
# Format value for query string
|
|
51
|
+
if isinstance(filter_option.value, list):
|
|
52
|
+
# For arrays (in/nin), join with commas (don't encode the comma delimiter)
|
|
53
|
+
# URL encode each value individually, then join with comma
|
|
54
|
+
value_parts = [quote(str(v)) for v in filter_option.value]
|
|
55
|
+
value_str = ",".join(value_parts)
|
|
56
|
+
elif filter_option.value is not None:
|
|
57
|
+
value_str = quote(str(filter_option.value))
|
|
58
|
+
else:
|
|
59
|
+
# Value is None but operator requires value - skip or use empty string
|
|
60
|
+
value_str = ""
|
|
61
|
+
|
|
62
|
+
query_parts.append(f"filter={field_encoded}:{filter_option.op}:{value_str}")
|
|
63
|
+
|
|
64
|
+
# Add sort
|
|
65
|
+
if filter_query.sort:
|
|
66
|
+
for sort_field in filter_query.sort:
|
|
67
|
+
query_parts.append(f"sort={quote(sort_field)}")
|
|
68
|
+
|
|
69
|
+
# Add pagination
|
|
70
|
+
if filter_query.page is not None:
|
|
71
|
+
query_parts.append(f"page={filter_query.page}")
|
|
72
|
+
|
|
73
|
+
if filter_query.pageSize is not None:
|
|
74
|
+
query_parts.append(f"pageSize={filter_query.pageSize}")
|
|
75
|
+
|
|
76
|
+
# Add fields
|
|
77
|
+
if filter_query.fields:
|
|
78
|
+
fields_str = ",".join(quote(f) for f in filter_query.fields)
|
|
79
|
+
query_parts.append(f"fields={fields_str}")
|
|
80
|
+
|
|
81
|
+
return "&".join(query_parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def filter_query_to_json(filter_query: FilterQuery) -> Dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Convert FilterQuery to JSON dict (camelCase).
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
filter_query: FilterQuery instance
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dictionary with filter data in camelCase format
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
>>> from miso_client.models.filter import FilterQuery, FilterOption
|
|
96
|
+
>>> query = FilterQuery(
|
|
97
|
+
... filters=[FilterOption(field='status', op='eq', value='active')],
|
|
98
|
+
... page=1,
|
|
99
|
+
... pageSize=25
|
|
100
|
+
... )
|
|
101
|
+
>>> filter_query_to_json(query)
|
|
102
|
+
{'filters': [...], 'page': 1, 'pageSize': 25}
|
|
103
|
+
"""
|
|
104
|
+
return filter_query.to_json()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def json_to_filter_query(json_data: Dict[str, Any]) -> FilterQuery:
|
|
108
|
+
"""
|
|
109
|
+
Convert JSON dict to FilterQuery.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
json_data: Dictionary with filter data (camelCase or snake_case)
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
FilterQuery instance
|
|
116
|
+
|
|
117
|
+
Examples:
|
|
118
|
+
>>> json_data = {'filters': [{'field': 'status', 'op': 'eq', 'value': 'active'}]}
|
|
119
|
+
>>> json_to_filter_query(json_data)
|
|
120
|
+
FilterQuery(filters=[FilterOption(...)])
|
|
121
|
+
"""
|
|
122
|
+
return FilterQuery.from_json(json_data)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def json_filter_to_query_string(json_filter: JsonFilter) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Convert JsonFilter to query string.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
json_filter: JsonFilter instance
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Query string (e.g., 'filter=status:eq:active&page=1&pageSize=25')
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> from miso_client.models.filter import JsonFilter, FilterOption
|
|
137
|
+
>>> json_filter = JsonFilter(
|
|
138
|
+
... filters=[FilterOption(field='status', op='eq', value='active')],
|
|
139
|
+
... page=1,
|
|
140
|
+
... pageSize=25
|
|
141
|
+
... )
|
|
142
|
+
>>> json_filter_to_query_string(json_filter)
|
|
143
|
+
'filter=status:eq:active&page=1&pageSize=25'
|
|
144
|
+
"""
|
|
145
|
+
# Convert JsonFilter to FilterQuery, then use existing build_query_string
|
|
146
|
+
filter_query = FilterQuery(
|
|
147
|
+
filters=json_filter.filters,
|
|
148
|
+
sort=json_filter.sort,
|
|
149
|
+
page=json_filter.page,
|
|
150
|
+
pageSize=json_filter.pageSize,
|
|
151
|
+
fields=json_filter.fields,
|
|
152
|
+
)
|
|
153
|
+
return build_query_string(filter_query)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def query_string_to_json_filter(query_string: str) -> JsonFilter:
|
|
157
|
+
"""
|
|
158
|
+
Convert query string to JsonFilter.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
query_string: Query string (e.g., '?filter=status:eq:active&page=1&pageSize=25')
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
JsonFilter instance
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
>>> query_string = '?filter=status:eq:active&page=1&pageSize=25'
|
|
168
|
+
>>> json_filter = query_string_to_json_filter(query_string)
|
|
169
|
+
>>> json_filter.filters[0].field
|
|
170
|
+
'status'
|
|
171
|
+
"""
|
|
172
|
+
# Remove leading ? if present
|
|
173
|
+
if query_string.startswith("?"):
|
|
174
|
+
query_string = query_string[1:]
|
|
175
|
+
|
|
176
|
+
# Parse query string
|
|
177
|
+
parsed = urlparse(f"?{query_string}")
|
|
178
|
+
params = parse_qs(parsed.query)
|
|
179
|
+
|
|
180
|
+
# Parse filters
|
|
181
|
+
filters = parse_filter_params(params)
|
|
182
|
+
|
|
183
|
+
# Parse sort
|
|
184
|
+
sort: Optional[List[str]] = None
|
|
185
|
+
if "sort" in params:
|
|
186
|
+
sort_list = params["sort"]
|
|
187
|
+
if isinstance(sort_list, list):
|
|
188
|
+
sort = [s for s in sort_list if isinstance(s, str)]
|
|
189
|
+
elif isinstance(sort_list, str):
|
|
190
|
+
sort = [sort_list]
|
|
191
|
+
|
|
192
|
+
# Parse pagination
|
|
193
|
+
page: Optional[int] = None
|
|
194
|
+
if "page" in params:
|
|
195
|
+
page_str = params["page"][0] if isinstance(params["page"], list) else params["page"]
|
|
196
|
+
try:
|
|
197
|
+
page = int(page_str)
|
|
198
|
+
except (ValueError, TypeError):
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
page_size: Optional[int] = None
|
|
202
|
+
if "pageSize" in params:
|
|
203
|
+
page_size_str = (
|
|
204
|
+
params["pageSize"][0] if isinstance(params["pageSize"], list) else params["pageSize"]
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
page_size = int(page_size_str)
|
|
208
|
+
except (ValueError, TypeError):
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
# Parse fields
|
|
212
|
+
fields: Optional[List[str]] = None
|
|
213
|
+
if "fields" in params:
|
|
214
|
+
fields_str = params["fields"][0] if isinstance(params["fields"], list) else params["fields"]
|
|
215
|
+
if isinstance(fields_str, str):
|
|
216
|
+
fields = [f.strip() for f in fields_str.split(",") if f.strip()]
|
|
217
|
+
|
|
218
|
+
return JsonFilter(
|
|
219
|
+
filters=filters if filters else None,
|
|
220
|
+
sort=sort,
|
|
221
|
+
page=page,
|
|
222
|
+
pageSize=page_size,
|
|
223
|
+
fields=fields,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def validate_filter_option(option: Dict[str, Any]) -> bool:
|
|
228
|
+
"""
|
|
229
|
+
Validate single filter option structure.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
option: Dictionary with filter option data
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
True if valid, False otherwise
|
|
236
|
+
|
|
237
|
+
Examples:
|
|
238
|
+
>>> validate_filter_option({'field': 'status', 'op': 'eq', 'value': 'active'})
|
|
239
|
+
True
|
|
240
|
+
>>> validate_filter_option({'field': 'status'}) # Missing op/value
|
|
241
|
+
False
|
|
242
|
+
"""
|
|
243
|
+
if not isinstance(option, dict):
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
# Check required fields
|
|
247
|
+
if "field" not in option or "op" not in option:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
# Validate operator
|
|
251
|
+
valid_operators = [
|
|
252
|
+
"eq",
|
|
253
|
+
"neq",
|
|
254
|
+
"in",
|
|
255
|
+
"nin",
|
|
256
|
+
"gt",
|
|
257
|
+
"lt",
|
|
258
|
+
"gte",
|
|
259
|
+
"lte",
|
|
260
|
+
"contains",
|
|
261
|
+
"like",
|
|
262
|
+
"isNull",
|
|
263
|
+
"isNotNull",
|
|
264
|
+
]
|
|
265
|
+
if option["op"] not in valid_operators:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
# Value is optional for null check operators
|
|
269
|
+
if option["op"] not in ("isNull", "isNotNull") and "value" not in option:
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
# Validate field is string
|
|
273
|
+
if not isinstance(option["field"], str):
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def validate_json_filter(json_data: Dict[str, Any]) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Validate JSON filter structure.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
json_data: Dictionary with filter data
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True if valid, False otherwise
|
|
288
|
+
|
|
289
|
+
Examples:
|
|
290
|
+
>>> json_data = {
|
|
291
|
+
... 'filters': [{'field': 'status', 'op': 'eq', 'value': 'active'}],
|
|
292
|
+
... 'page': 1,
|
|
293
|
+
... 'pageSize': 25
|
|
294
|
+
... }
|
|
295
|
+
>>> validate_json_filter(json_data)
|
|
296
|
+
True
|
|
297
|
+
"""
|
|
298
|
+
if not isinstance(json_data, dict):
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
# Validate filters if present
|
|
302
|
+
if "filters" in json_data and json_data["filters"] is not None:
|
|
303
|
+
if not isinstance(json_data["filters"], list):
|
|
304
|
+
return False
|
|
305
|
+
for filter_option in json_data["filters"]:
|
|
306
|
+
if not validate_filter_option(filter_option):
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
# Validate groups if present
|
|
310
|
+
if "groups" in json_data and json_data["groups"] is not None:
|
|
311
|
+
if not isinstance(json_data["groups"], list):
|
|
312
|
+
return False
|
|
313
|
+
for group in json_data["groups"]:
|
|
314
|
+
if not isinstance(group, dict):
|
|
315
|
+
return False
|
|
316
|
+
if "operator" not in group:
|
|
317
|
+
return False
|
|
318
|
+
if group["operator"] not in ["and", "or"]:
|
|
319
|
+
return False
|
|
320
|
+
# Validate filters in group
|
|
321
|
+
if "filters" in group and group["filters"] is not None:
|
|
322
|
+
if not isinstance(group["filters"], list):
|
|
323
|
+
return False
|
|
324
|
+
for filter_option in group["filters"]:
|
|
325
|
+
if not validate_filter_option(filter_option):
|
|
326
|
+
return False
|
|
327
|
+
# Validate nested groups recursively
|
|
328
|
+
if "groups" in group and group["groups"] is not None:
|
|
329
|
+
if not isinstance(group["groups"], list):
|
|
330
|
+
return False
|
|
331
|
+
for nested_group in group["groups"]:
|
|
332
|
+
if not isinstance(nested_group, dict):
|
|
333
|
+
return False
|
|
334
|
+
# Recursive validation (simplified - just check structure)
|
|
335
|
+
if "operator" not in nested_group:
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
# Validate sort if present
|
|
339
|
+
if "sort" in json_data and json_data["sort"] is not None:
|
|
340
|
+
if not isinstance(json_data["sort"], list):
|
|
341
|
+
return False
|
|
342
|
+
for sort_item in json_data["sort"]:
|
|
343
|
+
if not isinstance(sort_item, str):
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
# Validate page if present
|
|
347
|
+
if "page" in json_data and json_data["page"] is not None:
|
|
348
|
+
if not isinstance(json_data["page"], int):
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
# Validate pageSize if present
|
|
352
|
+
if "pageSize" in json_data and json_data["pageSize"] is not None:
|
|
353
|
+
if not isinstance(json_data["pageSize"], int):
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
# Validate fields if present
|
|
357
|
+
if "fields" in json_data and json_data["fields"] is not None:
|
|
358
|
+
if not isinstance(json_data["fields"], list):
|
|
359
|
+
return False
|
|
360
|
+
for field in json_data["fields"]:
|
|
361
|
+
if not isinstance(field, str):
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
return True
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filter application utilities for MisoClient SDK.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for applying filters to arrays locally,
|
|
5
|
+
useful for testing and mocking scenarios.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
from ..models.filter import FilterOption
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def apply_filters(items: List[Dict[str, Any]], filters: List[FilterOption]) -> List[Dict[str, Any]]:
|
|
14
|
+
"""
|
|
15
|
+
Apply filters to array locally (for testing/mocks).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
items: Array of dictionaries to filter
|
|
19
|
+
filters: List of FilterOption objects to apply
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Filtered array of items
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
>>> items = [{'status': 'active', 'region': 'eu'}, {'status': 'inactive', 'region': 'us'}]
|
|
26
|
+
>>> filters = [FilterOption(field='status', op='eq', value='active')]
|
|
27
|
+
>>> apply_filters(items, filters)
|
|
28
|
+
[{'status': 'active', 'region': 'eu'}]
|
|
29
|
+
"""
|
|
30
|
+
if not filters:
|
|
31
|
+
return items
|
|
32
|
+
|
|
33
|
+
filtered_items = items.copy()
|
|
34
|
+
|
|
35
|
+
for filter_option in filters:
|
|
36
|
+
field = filter_option.field
|
|
37
|
+
op = filter_option.op
|
|
38
|
+
value = filter_option.value
|
|
39
|
+
|
|
40
|
+
# Apply filter based on operator
|
|
41
|
+
if op == "eq":
|
|
42
|
+
filtered_items = [
|
|
43
|
+
item for item in filtered_items if field in item and item[field] == value
|
|
44
|
+
]
|
|
45
|
+
elif op == "neq":
|
|
46
|
+
filtered_items = [
|
|
47
|
+
item for item in filtered_items if field not in item or item[field] != value
|
|
48
|
+
]
|
|
49
|
+
elif op == "in":
|
|
50
|
+
if isinstance(value, list):
|
|
51
|
+
filtered_items = [
|
|
52
|
+
item for item in filtered_items if field in item and item[field] in value
|
|
53
|
+
]
|
|
54
|
+
else:
|
|
55
|
+
filtered_items = [
|
|
56
|
+
item for item in filtered_items if field in item and item[field] == value
|
|
57
|
+
]
|
|
58
|
+
elif op == "nin":
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
filtered_items = [
|
|
61
|
+
item for item in filtered_items if field not in item or item[field] not in value
|
|
62
|
+
]
|
|
63
|
+
else:
|
|
64
|
+
filtered_items = [
|
|
65
|
+
item for item in filtered_items if field not in item or item[field] != value
|
|
66
|
+
]
|
|
67
|
+
elif op == "gt":
|
|
68
|
+
filtered_items = [
|
|
69
|
+
item
|
|
70
|
+
for item in filtered_items
|
|
71
|
+
if field in item
|
|
72
|
+
and isinstance(item[field], (int, float))
|
|
73
|
+
and isinstance(value, (int, float))
|
|
74
|
+
and item[field] > value
|
|
75
|
+
]
|
|
76
|
+
elif op == "lt":
|
|
77
|
+
filtered_items = [
|
|
78
|
+
item
|
|
79
|
+
for item in filtered_items
|
|
80
|
+
if field in item
|
|
81
|
+
and isinstance(item[field], (int, float))
|
|
82
|
+
and isinstance(value, (int, float))
|
|
83
|
+
and item[field] < value
|
|
84
|
+
]
|
|
85
|
+
elif op == "gte":
|
|
86
|
+
filtered_items = [
|
|
87
|
+
item
|
|
88
|
+
for item in filtered_items
|
|
89
|
+
if field in item
|
|
90
|
+
and isinstance(item[field], (int, float))
|
|
91
|
+
and isinstance(value, (int, float))
|
|
92
|
+
and item[field] >= value
|
|
93
|
+
]
|
|
94
|
+
elif op == "lte":
|
|
95
|
+
filtered_items = [
|
|
96
|
+
item
|
|
97
|
+
for item in filtered_items
|
|
98
|
+
if field in item
|
|
99
|
+
and isinstance(item[field], (int, float))
|
|
100
|
+
and isinstance(value, (int, float))
|
|
101
|
+
and item[field] <= value
|
|
102
|
+
]
|
|
103
|
+
elif op == "contains":
|
|
104
|
+
if isinstance(value, str):
|
|
105
|
+
# For string values, check both string fields (substring) and list fields (membership)
|
|
106
|
+
filtered_items = [
|
|
107
|
+
item
|
|
108
|
+
for item in filtered_items
|
|
109
|
+
if field in item
|
|
110
|
+
and (
|
|
111
|
+
(isinstance(item[field], str) and value in item[field])
|
|
112
|
+
or (isinstance(item[field], list) and value in item[field])
|
|
113
|
+
)
|
|
114
|
+
]
|
|
115
|
+
else:
|
|
116
|
+
# For non-string values, check if value is in list/array field
|
|
117
|
+
filtered_items = [
|
|
118
|
+
item
|
|
119
|
+
for item in filtered_items
|
|
120
|
+
if field in item and isinstance(item[field], list) and value in item[field]
|
|
121
|
+
]
|
|
122
|
+
elif op == "like":
|
|
123
|
+
if isinstance(value, str):
|
|
124
|
+
# Simple like matching (contains)
|
|
125
|
+
filtered_items = [
|
|
126
|
+
item
|
|
127
|
+
for item in filtered_items
|
|
128
|
+
if field in item
|
|
129
|
+
and isinstance(item[field], str)
|
|
130
|
+
and value.lower() in item[field].lower()
|
|
131
|
+
]
|
|
132
|
+
elif op == "isNull":
|
|
133
|
+
# Field is missing or value is None
|
|
134
|
+
filtered_items = [
|
|
135
|
+
item for item in filtered_items if field not in item or item[field] is None
|
|
136
|
+
]
|
|
137
|
+
elif op == "isNotNull":
|
|
138
|
+
# Field exists and value is not None
|
|
139
|
+
filtered_items = [
|
|
140
|
+
item for item in filtered_items if field in item and item[field] is not None
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
return filtered_items
|