socialseed-e2e 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.
@@ -0,0 +1,588 @@
1
+ """Validation helpers for socialseed-e2e.
2
+
3
+ This module provides validation functions for common use cases including
4
+ URL validation, configuration validation, data type validation, and
5
+ response validation.
6
+ """
7
+
8
+ import re
9
+ from typing import Any, Callable, Dict, List, Optional, Pattern, Union
10
+ from urllib.parse import urlparse
11
+
12
+
13
+ class ValidationError(Exception):
14
+ """Exception raised when validation fails.
15
+
16
+ Attributes:
17
+ message: Explanation of the validation error
18
+ field: Name of the field that failed validation (if applicable)
19
+ value: The value that failed validation (if applicable)
20
+ """
21
+
22
+ def __init__(self, message: str, field: Optional[str] = None, value: Any = None):
23
+ super().__init__(message)
24
+ self.message = message
25
+ self.field = field
26
+ self.value = value
27
+
28
+ def __str__(self) -> str:
29
+ if self.field:
30
+ return f"Validation error for '{self.field}': {self.message}"
31
+ return f"Validation error: {self.message}"
32
+
33
+
34
+ # URL Validation
35
+
36
+
37
+ def validate_url(
38
+ url: str,
39
+ require_scheme: bool = True,
40
+ allowed_schemes: Optional[List[str]] = None,
41
+ field_name: str = "url",
42
+ ) -> str:
43
+ """Validate a URL string.
44
+
45
+ Args:
46
+ url: The URL to validate
47
+ require_scheme: Whether the URL must have a scheme (http/https)
48
+ allowed_schemes: List of allowed schemes (default: ['http', 'https'])
49
+ field_name: Name of the field for error messages
50
+
51
+ Returns:
52
+ The validated URL string
53
+
54
+ Raises:
55
+ ValidationError: If URL is invalid
56
+
57
+ Example:
58
+ >>> validate_url("https://api.example.com")
59
+ 'https://api.example.com'
60
+ >>> validate_url("not-a-url")
61
+ ValidationError: Invalid URL format
62
+ """
63
+ if not url or not isinstance(url, str):
64
+ raise ValidationError("URL must be a non-empty string", field_name, url)
65
+
66
+ url = url.strip()
67
+
68
+ # Check for spaces
69
+ if " " in url:
70
+ raise ValidationError("URL cannot contain spaces", field_name, url)
71
+
72
+ try:
73
+ parsed = urlparse(url)
74
+ except Exception as e:
75
+ raise ValidationError(f"Invalid URL format: {e}", field_name, url)
76
+
77
+ # Check scheme
78
+ if require_scheme:
79
+ if not parsed.scheme:
80
+ raise ValidationError("URL must include scheme (http/https)", field_name, url)
81
+
82
+ allowed = allowed_schemes or ["http", "https"]
83
+ if parsed.scheme not in allowed:
84
+ raise ValidationError(
85
+ f"URL scheme '{parsed.scheme}' not allowed. Allowed: {', '.join(allowed)}",
86
+ field_name,
87
+ url,
88
+ )
89
+
90
+ # Check netloc (domain)
91
+ # If no netloc and no scheme, try parsing with a dummy scheme
92
+ if not parsed.netloc and not parsed.scheme:
93
+ parsed_with_scheme = urlparse("http://" + url)
94
+ if parsed_with_scheme.netloc:
95
+ parsed = parsed_with_scheme
96
+
97
+ if not parsed.netloc:
98
+ raise ValidationError("URL must include a domain", field_name, url)
99
+
100
+ # Validate domain format
101
+ domain = parsed.netloc.split(":")[0] # Remove port if present
102
+ if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$", domain):
103
+ raise ValidationError(f"Invalid domain format: {domain}", field_name, url)
104
+
105
+ return url
106
+
107
+
108
+ def validate_base_url(base_url: str, field_name: str = "base_url") -> str:
109
+ """Validate a base URL for API services.
110
+
111
+ Similar to validate_url but specifically for API base URLs,
112
+ ensuring no trailing slash for consistent endpoint construction.
113
+
114
+ Args:
115
+ base_url: The base URL to validate
116
+ field_name: Name of the field for error messages
117
+
118
+ Returns:
119
+ The validated base URL (without trailing slash)
120
+
121
+ Raises:
122
+ ValidationError: If base URL is invalid
123
+ """
124
+ url = validate_url(base_url, field_name=field_name)
125
+ # Remove trailing slash for consistent endpoint construction
126
+ return url.rstrip("/")
127
+
128
+
129
+ # Configuration Validation
130
+
131
+
132
+ def validate_port(port: Union[int, str], field_name: str = "port") -> int:
133
+ """Validate a port number.
134
+
135
+ Args:
136
+ port: Port number to validate
137
+ field_name: Name of the field for error messages
138
+
139
+ Returns:
140
+ Validated port number as integer
141
+
142
+ Raises:
143
+ ValidationError: If port is invalid
144
+ """
145
+ try:
146
+ port_int = int(port)
147
+ except (ValueError, TypeError):
148
+ raise ValidationError(f"Port must be a valid integer, got: {port}", field_name, port)
149
+
150
+ if port_int < 1 or port_int > 65535:
151
+ raise ValidationError(
152
+ f"Port must be between 1 and 65535, got: {port_int}", field_name, port
153
+ )
154
+
155
+ return port_int
156
+
157
+
158
+ def validate_timeout(timeout: Union[int, str], field_name: str = "timeout") -> int:
159
+ """Validate a timeout value in milliseconds.
160
+
161
+ Args:
162
+ timeout: Timeout in milliseconds
163
+ field_name: Name of the field for error messages
164
+
165
+ Returns:
166
+ Validated timeout as integer
167
+
168
+ Raises:
169
+ ValidationError: If timeout is invalid
170
+ """
171
+ try:
172
+ timeout_int = int(timeout)
173
+ except (ValueError, TypeError):
174
+ raise ValidationError(
175
+ f"Timeout must be a valid integer, got: {timeout}", field_name, timeout
176
+ )
177
+
178
+ if timeout_int <= 0:
179
+ raise ValidationError(f"Timeout must be positive, got: {timeout_int}", field_name, timeout)
180
+
181
+ if timeout_int < 100:
182
+ # Warning for very short timeouts, but still valid
183
+ pass # Could log warning here
184
+
185
+ return timeout_int
186
+
187
+
188
+ def validate_service_name(name: str, field_name: str = "service_name") -> str:
189
+ """Validate a service name.
190
+
191
+ Service names should be:
192
+ - Alphanumeric with hyphens and underscores
193
+ - Start with letter
194
+ - Between 1 and 50 characters
195
+
196
+ Args:
197
+ name: Service name to validate
198
+ field_name: Name of the field for error messages
199
+
200
+ Returns:
201
+ Validated service name
202
+
203
+ Raises:
204
+ ValidationError: If name is invalid
205
+ """
206
+ if not name or not isinstance(name, str):
207
+ raise ValidationError("Service name must be a non-empty string", field_name, name)
208
+
209
+ name = name.strip()
210
+
211
+ if len(name) < 1 or len(name) > 50:
212
+ raise ValidationError(
213
+ f"Service name must be between 1 and 50 characters, got: {len(name)}", field_name, name
214
+ )
215
+
216
+ if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
217
+ raise ValidationError(
218
+ "Service name must start with letter and contain only letters, numbers, hyphens, and underscores",
219
+ field_name,
220
+ name,
221
+ )
222
+
223
+ return name
224
+
225
+
226
+ # Data Type Validation
227
+
228
+
229
+ def validate_string(
230
+ value: Any,
231
+ field_name: str = "value",
232
+ min_length: Optional[int] = None,
233
+ max_length: Optional[int] = None,
234
+ pattern: Optional[Union[str, Pattern]] = None,
235
+ allow_blank: bool = True,
236
+ ) -> str:
237
+ """Validate a string value.
238
+
239
+ Args:
240
+ value: Value to validate
241
+ field_name: Name of the field for error messages
242
+ min_length: Minimum string length (optional)
243
+ max_length: Maximum string length (optional)
244
+ pattern: Regex pattern to match (optional)
245
+ allow_blank: Whether empty strings are allowed
246
+
247
+ Returns:
248
+ Validated string
249
+
250
+ Raises:
251
+ ValidationError: If value is invalid
252
+ """
253
+ if not isinstance(value, str):
254
+ raise ValidationError(f"Expected string, got {type(value).__name__}", field_name, value)
255
+
256
+ if not allow_blank and not value.strip():
257
+ raise ValidationError("String cannot be blank", field_name, value)
258
+
259
+ if min_length is not None and len(value) < min_length:
260
+ raise ValidationError(
261
+ f"String must be at least {min_length} characters, got: {len(value)}", field_name, value
262
+ )
263
+
264
+ if max_length is not None and len(value) > max_length:
265
+ raise ValidationError(
266
+ f"String must be at most {max_length} characters, got: {len(value)}", field_name, value
267
+ )
268
+
269
+ if pattern is not None:
270
+ if isinstance(pattern, str):
271
+ pattern = re.compile(pattern)
272
+ if not pattern.match(value):
273
+ raise ValidationError(
274
+ f"String does not match required pattern: {pattern.pattern}", field_name, value
275
+ )
276
+
277
+ return value
278
+
279
+
280
+ def validate_integer(
281
+ value: Any,
282
+ field_name: str = "value",
283
+ min_value: Optional[int] = None,
284
+ max_value: Optional[int] = None,
285
+ ) -> int:
286
+ """Validate an integer value.
287
+
288
+ Args:
289
+ value: Value to validate
290
+ field_name: Name of the field for error messages
291
+ min_value: Minimum allowed value (optional)
292
+ max_value: Maximum allowed value (optional)
293
+
294
+ Returns:
295
+ Validated integer
296
+
297
+ Raises:
298
+ ValidationError: If value is invalid
299
+ """
300
+ try:
301
+ int_value = int(value)
302
+ except (ValueError, TypeError):
303
+ raise ValidationError(
304
+ f"Expected integer, got {type(value).__name__}: {value}", field_name, value
305
+ )
306
+
307
+ if min_value is not None and int_value < min_value:
308
+ raise ValidationError(
309
+ f"Value must be at least {min_value}, got: {int_value}", field_name, value
310
+ )
311
+
312
+ if max_value is not None and int_value > max_value:
313
+ raise ValidationError(
314
+ f"Value must be at most {max_value}, got: {int_value}", field_name, value
315
+ )
316
+
317
+ return int_value
318
+
319
+
320
+ def validate_email(email: str, field_name: str = "email") -> str:
321
+ """Validate an email address.
322
+
323
+ Args:
324
+ email: Email address to validate
325
+ field_name: Name of the field for error messages
326
+
327
+ Returns:
328
+ Validated email address
329
+
330
+ Raises:
331
+ ValidationError: If email is invalid
332
+ """
333
+ if not email or not isinstance(email, str):
334
+ raise ValidationError("Email must be a non-empty string", field_name, email)
335
+
336
+ email = email.strip()
337
+
338
+ # Basic email pattern
339
+ pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
340
+ if not re.match(pattern, email):
341
+ raise ValidationError("Invalid email format", field_name, email)
342
+
343
+ return email.lower()
344
+
345
+
346
+ def validate_uuid(uuid_str: str, field_name: str = "uuid") -> str:
347
+ """Validate a UUID string.
348
+
349
+ Args:
350
+ uuid_str: UUID string to validate
351
+ field_name: Name of the field for error messages
352
+
353
+ Returns:
354
+ Validated UUID string
355
+
356
+ Raises:
357
+ ValidationError: If UUID is invalid
358
+ """
359
+ if not uuid_str or not isinstance(uuid_str, str):
360
+ raise ValidationError("UUID must be a non-empty string", field_name, uuid_str)
361
+
362
+ uuid_str = uuid_str.strip()
363
+
364
+ # UUID pattern (accepts both with and without hyphens)
365
+ pattern = r"^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$"
366
+ if not re.match(pattern, uuid_str):
367
+ raise ValidationError("Invalid UUID format", field_name, uuid_str)
368
+
369
+ return uuid_str
370
+
371
+
372
+ # Response Validation
373
+
374
+
375
+ def validate_status_code(
376
+ status: int, expected: Union[int, List[int]], field_name: str = "status_code"
377
+ ) -> int:
378
+ """Validate an HTTP status code.
379
+
380
+ Args:
381
+ status: Actual status code
382
+ expected: Expected status code(s)
383
+ field_name: Name of the field for error messages
384
+
385
+ Returns:
386
+ Validated status code
387
+
388
+ Raises:
389
+ ValidationError: If status doesn't match expected
390
+ """
391
+ if isinstance(expected, int):
392
+ expected = [expected]
393
+
394
+ if status not in expected:
395
+ raise ValidationError(f"Expected status code {expected}, got: {status}", field_name, status)
396
+
397
+ return status
398
+
399
+
400
+ def validate_json_response(
401
+ data: Any,
402
+ required_fields: Optional[List[str]] = None,
403
+ field_types: Optional[Dict[str, type]] = None,
404
+ field_name: str = "response",
405
+ ) -> Dict[str, Any]:
406
+ """Validate a JSON response structure.
407
+
408
+ Args:
409
+ data: JSON data to validate
410
+ required_fields: List of required field names
411
+ field_types: Dictionary of field names to expected types
412
+ field_name: Name of the field for error messages
413
+
414
+ Returns:
415
+ Validated data as dictionary
416
+
417
+ Raises:
418
+ ValidationError: If data structure is invalid
419
+ """
420
+ if not isinstance(data, dict):
421
+ raise ValidationError(
422
+ f"Expected JSON object (dict), got {type(data).__name__}", field_name, data
423
+ )
424
+
425
+ # Check required fields
426
+ if required_fields:
427
+ missing = [f for f in required_fields if f not in data]
428
+ if missing:
429
+ raise ValidationError(
430
+ f"Missing required fields: {', '.join(missing)}", field_name, data
431
+ )
432
+
433
+ # Validate field types
434
+ if field_types:
435
+ for field, expected_type in field_types.items():
436
+ if field in data:
437
+ actual_value = data[field]
438
+ if actual_value is not None and not isinstance(actual_value, expected_type):
439
+ raise ValidationError(
440
+ f"Field '{field}' must be {expected_type.__name__}, "
441
+ f"got {type(actual_value).__name__}",
442
+ field_name,
443
+ data,
444
+ )
445
+
446
+ return data
447
+
448
+
449
+ def validate_pagination_response(
450
+ data: Dict[str, Any],
451
+ items_field: str = "items",
452
+ total_field: str = "total",
453
+ page_field: str = "page",
454
+ field_name: str = "pagination",
455
+ ) -> Dict[str, Any]:
456
+ """Validate a paginated response structure.
457
+
458
+ Args:
459
+ data: Response data to validate
460
+ items_field: Name of the items field
461
+ total_field: Name of the total count field
462
+ page_field: Name of the page field
463
+ field_name: Name of the field for error messages
464
+
465
+ Returns:
466
+ Validated data
467
+
468
+ Raises:
469
+ ValidationError: If pagination structure is invalid
470
+ """
471
+ if not isinstance(data, dict):
472
+ raise ValidationError("Pagination response must be a JSON object", field_name, data)
473
+
474
+ # Validate items field
475
+ if items_field not in data:
476
+ raise ValidationError(
477
+ f"Missing '{items_field}' field in pagination response", field_name, data
478
+ )
479
+
480
+ if not isinstance(data[items_field], list):
481
+ raise ValidationError(f"'{items_field}' must be a list", field_name, data)
482
+
483
+ # Validate total field
484
+ if total_field in data:
485
+ try:
486
+ validate_integer(data[total_field], total_field, min_value=0)
487
+ except ValidationError as e:
488
+ raise ValidationError(str(e), field_name, data)
489
+
490
+ # Validate page field
491
+ if page_field in data:
492
+ try:
493
+ validate_integer(data[page_field], page_field, min_value=1)
494
+ except ValidationError as e:
495
+ raise ValidationError(str(e), field_name, data)
496
+
497
+ return data
498
+
499
+
500
+ # Utility Validators
501
+
502
+
503
+ def validate_list(
504
+ value: Any,
505
+ field_name: str = "value",
506
+ min_length: Optional[int] = None,
507
+ max_length: Optional[int] = None,
508
+ item_validator: Optional[Callable[[Any], None]] = None,
509
+ ) -> List[Any]:
510
+ """Validate a list value.
511
+
512
+ Args:
513
+ value: Value to validate
514
+ field_name: Name of the field for error messages
515
+ min_length: Minimum list length (optional)
516
+ max_length: Maximum list length (optional)
517
+ item_validator: Optional function to validate each item
518
+
519
+ Returns:
520
+ Validated list
521
+
522
+ Raises:
523
+ ValidationError: If value is invalid
524
+ """
525
+ if not isinstance(value, list):
526
+ raise ValidationError(f"Expected list, got {type(value).__name__}", field_name, value)
527
+
528
+ if min_length is not None and len(value) < min_length:
529
+ raise ValidationError(
530
+ f"List must have at least {min_length} items, got: {len(value)}", field_name, value
531
+ )
532
+
533
+ if max_length is not None and len(value) > max_length:
534
+ raise ValidationError(
535
+ f"List must have at most {max_length} items, got: {len(value)}", field_name, value
536
+ )
537
+
538
+ if item_validator:
539
+ for i, item in enumerate(value):
540
+ try:
541
+ item_validator(item)
542
+ except ValidationError as e:
543
+ raise ValidationError(f"Invalid item at index {i}: {e.message}", field_name, value)
544
+
545
+ return value
546
+
547
+
548
+ def validate_dict(
549
+ value: Any,
550
+ field_name: str = "value",
551
+ required_keys: Optional[List[str]] = None,
552
+ value_types: Optional[Dict[str, type]] = None,
553
+ ) -> Dict[str, Any]:
554
+ """Validate a dictionary value.
555
+
556
+ Args:
557
+ value: Value to validate
558
+ field_name: Name of the field for error messages
559
+ required_keys: List of required keys
560
+ value_types: Dictionary of keys to expected value types
561
+
562
+ Returns:
563
+ Validated dictionary
564
+
565
+ Raises:
566
+ ValidationError: If value is invalid
567
+ """
568
+ if not isinstance(value, dict):
569
+ raise ValidationError(f"Expected dict, got {type(value).__name__}", field_name, value)
570
+
571
+ if required_keys:
572
+ missing = [k for k in required_keys if k not in value]
573
+ if missing:
574
+ raise ValidationError(f"Missing required keys: {', '.join(missing)}", field_name, value)
575
+
576
+ if value_types:
577
+ for key, expected_type in value_types.items():
578
+ if key in value:
579
+ actual_value = value[key]
580
+ if actual_value is not None and not isinstance(actual_value, expected_type):
581
+ raise ValidationError(
582
+ f"Value for key '{key}' must be {expected_type.__name__}, "
583
+ f"got {type(actual_value).__name__}",
584
+ field_name,
585
+ value,
586
+ )
587
+
588
+ return value