ccproxy-api 0.1.0__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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Core transformer abstractions for request/response transformation."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeVar, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from structlog import get_logger
|
|
7
|
+
|
|
8
|
+
from ccproxy.core.types import ProxyRequest, ProxyResponse, TransformContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T", contravariant=True)
|
|
16
|
+
R = TypeVar("R", covariant=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseTransformer(ABC):
|
|
20
|
+
"""Abstract base class for all transformers."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
"""Initialize transformer."""
|
|
24
|
+
self.metrics_collector: Any = None
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def transform(
|
|
28
|
+
self, data: Any, context: TransformContext | None = None
|
|
29
|
+
) -> Any:
|
|
30
|
+
"""Transform the input data.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
data: The data to transform
|
|
34
|
+
context: Optional transformation context
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The transformed data
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
TransformationError: If transformation fails
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
async def _collect_transformation_metrics(
|
|
45
|
+
self,
|
|
46
|
+
transformation_type: str,
|
|
47
|
+
input_data: Any,
|
|
48
|
+
output_data: Any,
|
|
49
|
+
duration_ms: float,
|
|
50
|
+
success: bool = True,
|
|
51
|
+
error: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Collect metrics for transformation operations.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
transformation_type: Type of transformation (request/response)
|
|
57
|
+
input_data: Original input data
|
|
58
|
+
output_data: Transformed output data
|
|
59
|
+
duration_ms: Time taken for transformation in milliseconds
|
|
60
|
+
success: Whether transformation was successful
|
|
61
|
+
error: Error message if transformation failed
|
|
62
|
+
"""
|
|
63
|
+
if not self.metrics_collector:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# Calculate data sizes
|
|
68
|
+
input_size = self._calculate_data_size(input_data)
|
|
69
|
+
output_size = self._calculate_data_size(output_data) if output_data else 0
|
|
70
|
+
|
|
71
|
+
# Create a unique request ID for this transformation
|
|
72
|
+
request_id = (
|
|
73
|
+
f"transformer_{id(self)}_{transformation_type}_{id(input_data)}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Use existing latency collection method with timing data
|
|
77
|
+
await self.metrics_collector.collect_latency(
|
|
78
|
+
request_id=request_id,
|
|
79
|
+
transformation_duration=duration_ms,
|
|
80
|
+
processing_time=duration_ms,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
# Don't let metrics collection fail the transformation
|
|
85
|
+
logger = get_logger(__name__)
|
|
86
|
+
# logger = logging.getLogger(__name__)
|
|
87
|
+
logger.debug(
|
|
88
|
+
"transformation_metrics_failed",
|
|
89
|
+
error=str(e),
|
|
90
|
+
operation="collect_transformation_metrics",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _calculate_data_size(self, data: Any) -> int:
|
|
94
|
+
"""Calculate the size of data in bytes.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
data: The data to measure
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Size in bytes
|
|
101
|
+
"""
|
|
102
|
+
if data is None:
|
|
103
|
+
return 0
|
|
104
|
+
elif isinstance(data, bytes):
|
|
105
|
+
return len(data)
|
|
106
|
+
elif isinstance(data, str):
|
|
107
|
+
return len(data.encode("utf-8"))
|
|
108
|
+
elif hasattr(data, "__len__"):
|
|
109
|
+
return len(str(data))
|
|
110
|
+
else:
|
|
111
|
+
return len(str(data))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RequestTransformer(BaseTransformer):
|
|
115
|
+
"""Base class for request transformers."""
|
|
116
|
+
|
|
117
|
+
async def transform(
|
|
118
|
+
self, request: ProxyRequest, context: TransformContext | None = None
|
|
119
|
+
) -> ProxyRequest:
|
|
120
|
+
"""Transform a proxy request with metrics collection.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
request: The request to transform
|
|
124
|
+
context: Optional transformation context
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The transformed request
|
|
128
|
+
"""
|
|
129
|
+
import time
|
|
130
|
+
|
|
131
|
+
start_time = time.perf_counter()
|
|
132
|
+
error_msg = None
|
|
133
|
+
result = None
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = await self._transform_request(request, context)
|
|
137
|
+
return result
|
|
138
|
+
except Exception as e:
|
|
139
|
+
error_msg = str(e)
|
|
140
|
+
raise
|
|
141
|
+
finally:
|
|
142
|
+
# Collect metrics regardless of success/failure
|
|
143
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
144
|
+
await self._collect_transformation_metrics(
|
|
145
|
+
transformation_type="request",
|
|
146
|
+
input_data=request,
|
|
147
|
+
output_data=result,
|
|
148
|
+
duration_ms=duration_ms,
|
|
149
|
+
success=error_msg is None,
|
|
150
|
+
error=error_msg,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@abstractmethod
|
|
154
|
+
async def _transform_request(
|
|
155
|
+
self, request: ProxyRequest, context: TransformContext | None = None
|
|
156
|
+
) -> ProxyRequest:
|
|
157
|
+
"""Transform a proxy request implementation.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
request: The request to transform
|
|
161
|
+
context: Optional transformation context
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The transformed request
|
|
165
|
+
"""
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ResponseTransformer(BaseTransformer):
|
|
170
|
+
"""Base class for response transformers."""
|
|
171
|
+
|
|
172
|
+
async def transform(
|
|
173
|
+
self, response: ProxyResponse, context: TransformContext | None = None
|
|
174
|
+
) -> ProxyResponse:
|
|
175
|
+
"""Transform a proxy response with metrics collection.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
response: The response to transform
|
|
179
|
+
context: Optional transformation context
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The transformed response
|
|
183
|
+
"""
|
|
184
|
+
import time
|
|
185
|
+
|
|
186
|
+
start_time = time.perf_counter()
|
|
187
|
+
error_msg = None
|
|
188
|
+
result = None
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
result = await self._transform_response(response, context)
|
|
192
|
+
return result
|
|
193
|
+
except Exception as e:
|
|
194
|
+
error_msg = str(e)
|
|
195
|
+
raise
|
|
196
|
+
finally:
|
|
197
|
+
# Collect metrics regardless of success/failure
|
|
198
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
199
|
+
await self._collect_transformation_metrics(
|
|
200
|
+
transformation_type="response",
|
|
201
|
+
input_data=response,
|
|
202
|
+
output_data=result,
|
|
203
|
+
duration_ms=duration_ms,
|
|
204
|
+
success=error_msg is None,
|
|
205
|
+
error=error_msg,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@abstractmethod
|
|
209
|
+
async def _transform_response(
|
|
210
|
+
self, response: ProxyResponse, context: TransformContext | None = None
|
|
211
|
+
) -> ProxyResponse:
|
|
212
|
+
"""Transform a proxy response implementation.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
response: The response to transform
|
|
216
|
+
context: Optional transformation context
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The transformed response
|
|
220
|
+
"""
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@runtime_checkable
|
|
225
|
+
class TransformerProtocol(Protocol[T, R]):
|
|
226
|
+
"""Protocol defining the transformer interface."""
|
|
227
|
+
|
|
228
|
+
async def transform(self, data: T, context: TransformContext | None = None) -> R:
|
|
229
|
+
"""Transform the input data."""
|
|
230
|
+
...
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class ChainedTransformer(BaseTransformer):
|
|
234
|
+
"""Transformer that chains multiple transformers together."""
|
|
235
|
+
|
|
236
|
+
def __init__(self, transformers: list[BaseTransformer]):
|
|
237
|
+
"""Initialize with a list of transformers to chain.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
transformers: List of transformers to apply in sequence
|
|
241
|
+
"""
|
|
242
|
+
self.transformers = transformers
|
|
243
|
+
|
|
244
|
+
async def transform(
|
|
245
|
+
self, data: Any, context: TransformContext | None = None
|
|
246
|
+
) -> Any:
|
|
247
|
+
"""Apply all transformers in sequence.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
data: The data to transform
|
|
251
|
+
context: Optional transformation context
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The result of applying all transformers
|
|
255
|
+
"""
|
|
256
|
+
result = data
|
|
257
|
+
for transformer in self.transformers:
|
|
258
|
+
result = await transformer.transform(result, context)
|
|
259
|
+
return result
|
ccproxy/core/types.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Core type definitions for the proxy system."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProxyMethod(str, Enum):
|
|
11
|
+
"""HTTP methods supported by the proxy."""
|
|
12
|
+
|
|
13
|
+
GET = "GET"
|
|
14
|
+
POST = "POST"
|
|
15
|
+
PUT = "PUT"
|
|
16
|
+
DELETE = "DELETE"
|
|
17
|
+
PATCH = "PATCH"
|
|
18
|
+
HEAD = "HEAD"
|
|
19
|
+
OPTIONS = "OPTIONS"
|
|
20
|
+
CONNECT = "CONNECT"
|
|
21
|
+
TRACE = "TRACE"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProxyProtocol(str, Enum):
|
|
25
|
+
"""Protocols supported by the proxy."""
|
|
26
|
+
|
|
27
|
+
HTTP = "http"
|
|
28
|
+
HTTPS = "https"
|
|
29
|
+
WS = "ws"
|
|
30
|
+
WSS = "wss"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ProxyRequest:
|
|
35
|
+
"""Represents a proxy request."""
|
|
36
|
+
|
|
37
|
+
method: ProxyMethod
|
|
38
|
+
url: str
|
|
39
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
body: str | bytes | dict[str, Any] | None = None
|
|
42
|
+
protocol: ProxyProtocol = ProxyProtocol.HTTPS
|
|
43
|
+
timeout: float | None = None
|
|
44
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
"""Validate and normalize the request."""
|
|
48
|
+
if isinstance(self.method, str):
|
|
49
|
+
self.method = ProxyMethod(self.method.upper())
|
|
50
|
+
if isinstance(self.protocol, str):
|
|
51
|
+
self.protocol = ProxyProtocol(self.protocol.lower())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ProxyResponse:
|
|
56
|
+
"""Represents a proxy response."""
|
|
57
|
+
|
|
58
|
+
status_code: int
|
|
59
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
body: str | bytes | dict[str, Any] | None = None
|
|
61
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_success(self) -> bool:
|
|
65
|
+
"""Check if the response indicates success."""
|
|
66
|
+
return 200 <= self.status_code < 300
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_error(self) -> bool:
|
|
70
|
+
"""Check if the response indicates an error."""
|
|
71
|
+
return self.status_code >= 400
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class TransformContext:
|
|
76
|
+
"""Context passed to transformers during transformation."""
|
|
77
|
+
|
|
78
|
+
request: ProxyRequest | None = None
|
|
79
|
+
response: ProxyResponse | None = None
|
|
80
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
83
|
+
"""Get a value from metadata."""
|
|
84
|
+
return self.metadata.get(key, default)
|
|
85
|
+
|
|
86
|
+
def set(self, key: str, value: Any) -> None:
|
|
87
|
+
"""Set a value in metadata."""
|
|
88
|
+
self.metadata[key] = value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ProxyConfig(BaseModel):
|
|
92
|
+
"""Configuration for proxy behavior."""
|
|
93
|
+
|
|
94
|
+
timeout: float = Field(default=30.0, description="Default timeout in seconds")
|
|
95
|
+
max_retries: int = Field(default=3, description="Maximum number of retries")
|
|
96
|
+
retry_delay: float = Field(
|
|
97
|
+
default=1.0, description="Delay between retries in seconds"
|
|
98
|
+
)
|
|
99
|
+
verify_ssl: bool = Field(
|
|
100
|
+
default=True, description="Whether to verify SSL certificates"
|
|
101
|
+
)
|
|
102
|
+
follow_redirects: bool = Field(
|
|
103
|
+
default=True, description="Whether to follow redirects"
|
|
104
|
+
)
|
|
105
|
+
max_redirects: int = Field(
|
|
106
|
+
default=10, description="Maximum number of redirects to follow"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
class Config:
|
|
110
|
+
"""Pydantic configuration."""
|
|
111
|
+
|
|
112
|
+
extra = "forbid"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class MiddlewareConfig(BaseModel):
|
|
116
|
+
"""Configuration for middleware behavior."""
|
|
117
|
+
|
|
118
|
+
enabled: bool = Field(default=True, description="Whether the middleware is enabled")
|
|
119
|
+
priority: int = Field(
|
|
120
|
+
default=0, description="Middleware execution priority (lower = earlier)"
|
|
121
|
+
)
|
|
122
|
+
metadata: dict[str, Any] = Field(
|
|
123
|
+
default_factory=dict, description="Additional middleware configuration"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
class Config:
|
|
127
|
+
"""Pydantic configuration."""
|
|
128
|
+
|
|
129
|
+
extra = "allow"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Generic validation utilities for the CCProxy API."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from ccproxy.core.constants import EMAIL_PATTERN, URL_PATTERN, UUID_PATTERN
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidationError(Exception):
|
|
12
|
+
"""Base class for validation errors."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_email(email: str) -> str:
|
|
18
|
+
"""Validate email format.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
email: Email address to validate
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The validated email address
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValidationError: If email format is invalid
|
|
28
|
+
"""
|
|
29
|
+
if not isinstance(email, str):
|
|
30
|
+
raise ValidationError("Email must be a string")
|
|
31
|
+
|
|
32
|
+
if not re.match(EMAIL_PATTERN, email):
|
|
33
|
+
raise ValidationError(f"Invalid email format: {email}")
|
|
34
|
+
|
|
35
|
+
return email.strip().lower()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_url(url: str) -> str:
|
|
39
|
+
"""Validate URL format.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
url: URL to validate
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The validated URL
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValidationError: If URL format is invalid
|
|
49
|
+
"""
|
|
50
|
+
if not isinstance(url, str):
|
|
51
|
+
raise ValidationError("URL must be a string")
|
|
52
|
+
|
|
53
|
+
if not re.match(URL_PATTERN, url):
|
|
54
|
+
raise ValidationError(f"Invalid URL format: {url}")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
parsed = urlparse(url)
|
|
58
|
+
if not parsed.scheme or not parsed.netloc:
|
|
59
|
+
raise ValidationError(f"Invalid URL format: {url}")
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise ValidationError(f"Invalid URL format: {url}") from e
|
|
62
|
+
|
|
63
|
+
return url.strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_uuid(uuid_str: str) -> str:
|
|
67
|
+
"""Validate UUID format.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
uuid_str: UUID string to validate
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The validated UUID string
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValidationError: If UUID format is invalid
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(uuid_str, str):
|
|
79
|
+
raise ValidationError("UUID must be a string")
|
|
80
|
+
|
|
81
|
+
if not re.match(UUID_PATTERN, uuid_str.lower()):
|
|
82
|
+
raise ValidationError(f"Invalid UUID format: {uuid_str}")
|
|
83
|
+
|
|
84
|
+
return uuid_str.strip().lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_path(path: str | Path, must_exist: bool = True) -> Path:
|
|
88
|
+
"""Validate file system path.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
path: Path to validate
|
|
92
|
+
must_exist: Whether the path must exist
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The validated Path object
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValidationError: If path is invalid
|
|
99
|
+
"""
|
|
100
|
+
if isinstance(path, str):
|
|
101
|
+
path = Path(path)
|
|
102
|
+
elif not isinstance(path, Path):
|
|
103
|
+
raise ValidationError("Path must be a string or Path object")
|
|
104
|
+
|
|
105
|
+
if must_exist and not path.exists():
|
|
106
|
+
raise ValidationError(f"Path does not exist: {path}")
|
|
107
|
+
|
|
108
|
+
return path.resolve()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_port(port: int | str) -> int:
|
|
112
|
+
"""Validate port number.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
port: Port number to validate
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The validated port number
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValidationError: If port is invalid
|
|
122
|
+
"""
|
|
123
|
+
if isinstance(port, str):
|
|
124
|
+
try:
|
|
125
|
+
port = int(port)
|
|
126
|
+
except ValueError as e:
|
|
127
|
+
raise ValidationError(f"Port must be a valid integer: {port}") from e
|
|
128
|
+
|
|
129
|
+
if not isinstance(port, int):
|
|
130
|
+
raise ValidationError(f"Port must be an integer: {port}")
|
|
131
|
+
|
|
132
|
+
if port < 1 or port > 65535:
|
|
133
|
+
raise ValidationError(f"Port must be between 1 and 65535: {port}")
|
|
134
|
+
|
|
135
|
+
return port
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_timeout(timeout: float | int | str) -> float:
|
|
139
|
+
"""Validate timeout value.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
timeout: Timeout value to validate
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The validated timeout value
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValidationError: If timeout is invalid
|
|
149
|
+
"""
|
|
150
|
+
if isinstance(timeout, str):
|
|
151
|
+
try:
|
|
152
|
+
timeout = float(timeout)
|
|
153
|
+
except ValueError as e:
|
|
154
|
+
raise ValidationError(f"Timeout must be a valid number: {timeout}") from e
|
|
155
|
+
|
|
156
|
+
if not isinstance(timeout, int | float):
|
|
157
|
+
raise ValidationError(f"Timeout must be a number: {timeout}")
|
|
158
|
+
|
|
159
|
+
if timeout < 0:
|
|
160
|
+
raise ValidationError(f"Timeout must be non-negative: {timeout}")
|
|
161
|
+
|
|
162
|
+
return float(timeout)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_non_empty_string(value: str, name: str = "value") -> str:
|
|
166
|
+
"""Validate that a string is not empty.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
value: String value to validate
|
|
170
|
+
name: Name of the field for error messages
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The validated string
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ValidationError: If string is empty or not a string
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(value, str):
|
|
179
|
+
raise ValidationError(f"{name} must be a string")
|
|
180
|
+
|
|
181
|
+
if not value.strip():
|
|
182
|
+
raise ValidationError(f"{name} cannot be empty")
|
|
183
|
+
|
|
184
|
+
return value.strip()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_dict(value: Any, required_keys: list[str] | None = None) -> dict[str, Any]:
|
|
188
|
+
"""Validate dictionary and required keys.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
value: Value to validate as dictionary
|
|
192
|
+
required_keys: List of required keys
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The validated dictionary
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ValidationError: If not a dictionary or missing required keys
|
|
199
|
+
"""
|
|
200
|
+
if not isinstance(value, dict):
|
|
201
|
+
raise ValidationError("Value must be a dictionary")
|
|
202
|
+
|
|
203
|
+
if required_keys:
|
|
204
|
+
missing_keys = [key for key in required_keys if key not in value]
|
|
205
|
+
if missing_keys:
|
|
206
|
+
raise ValidationError(f"Missing required keys: {missing_keys}")
|
|
207
|
+
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_list(
|
|
212
|
+
value: Any, min_length: int = 0, max_length: int | None = None
|
|
213
|
+
) -> list[Any]:
|
|
214
|
+
"""Validate list and length constraints.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
value: Value to validate as list
|
|
218
|
+
min_length: Minimum list length
|
|
219
|
+
max_length: Maximum list length
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The validated list
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ValidationError: If not a list or length constraints are violated
|
|
226
|
+
"""
|
|
227
|
+
if not isinstance(value, list):
|
|
228
|
+
raise ValidationError("Value must be a list")
|
|
229
|
+
|
|
230
|
+
if len(value) < min_length:
|
|
231
|
+
raise ValidationError(f"List must have at least {min_length} items")
|
|
232
|
+
|
|
233
|
+
if max_length is not None and len(value) > max_length:
|
|
234
|
+
raise ValidationError(f"List cannot have more than {max_length} items")
|
|
235
|
+
|
|
236
|
+
return value
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def validate_choice(value: Any, choices: list[Any], name: str = "value") -> Any:
|
|
240
|
+
"""Validate that value is one of the allowed choices.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
value: Value to validate
|
|
244
|
+
choices: List of allowed choices
|
|
245
|
+
name: Name of the field for error messages
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The validated value
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ValidationError: If value is not in choices
|
|
252
|
+
"""
|
|
253
|
+
if value not in choices:
|
|
254
|
+
raise ValidationError(f"{name} must be one of {choices}, got: {value}")
|
|
255
|
+
|
|
256
|
+
return value
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def validate_range(
|
|
260
|
+
value: float | int,
|
|
261
|
+
min_value: float | int | None = None,
|
|
262
|
+
max_value: float | int | None = None,
|
|
263
|
+
name: str = "value",
|
|
264
|
+
) -> float | int:
|
|
265
|
+
"""Validate that a numeric value is within a specified range.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
value: Numeric value to validate
|
|
269
|
+
min_value: Minimum allowed value
|
|
270
|
+
max_value: Maximum allowed value
|
|
271
|
+
name: Name of the field for error messages
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The validated value
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
ValidationError: If value is outside the allowed range
|
|
278
|
+
"""
|
|
279
|
+
if not isinstance(value, int | float):
|
|
280
|
+
raise ValidationError(f"{name} must be a number")
|
|
281
|
+
|
|
282
|
+
if min_value is not None and value < min_value:
|
|
283
|
+
raise ValidationError(f"{name} must be at least {min_value}")
|
|
284
|
+
|
|
285
|
+
if max_value is not None and value > max_value:
|
|
286
|
+
raise ValidationError(f"{name} must be at most {max_value}")
|
|
287
|
+
|
|
288
|
+
return value
|