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.
- socialseed_e2e/__init__.py +51 -0
- socialseed_e2e/__version__.py +21 -0
- socialseed_e2e/cli.py +611 -0
- socialseed_e2e/core/__init__.py +35 -0
- socialseed_e2e/core/base_page.py +839 -0
- socialseed_e2e/core/check_deps.py +43 -0
- socialseed_e2e/core/config.py +119 -0
- socialseed_e2e/core/config_loader.py +604 -0
- socialseed_e2e/core/headers.py +20 -0
- socialseed_e2e/core/interfaces.py +22 -0
- socialseed_e2e/core/loaders.py +51 -0
- socialseed_e2e/core/models.py +24 -0
- socialseed_e2e/core/test_orchestrator.py +84 -0
- socialseed_e2e/services/__init__.py +9 -0
- socialseed_e2e/templates/__init__.py +32 -0
- socialseed_e2e/templates/config.py.template +20 -0
- socialseed_e2e/templates/data_schema.py.template +116 -0
- socialseed_e2e/templates/e2e.conf.template +20 -0
- socialseed_e2e/templates/service_page.py.template +83 -0
- socialseed_e2e/templates/test_module.py.template +46 -0
- socialseed_e2e/utils/__init__.py +44 -0
- socialseed_e2e/utils/template_engine.py +246 -0
- socialseed_e2e/utils/validators.py +588 -0
- socialseed_e2e-0.1.0.dist-info/METADATA +333 -0
- socialseed_e2e-0.1.0.dist-info/RECORD +29 -0
- socialseed_e2e-0.1.0.dist-info/WHEEL +5 -0
- socialseed_e2e-0.1.0.dist-info/entry_points.txt +3 -0
- socialseed_e2e-0.1.0.dist-info/licenses/LICENSE +21 -0
- socialseed_e2e-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ApiConfigLoader - Centralized Configuration Management
|
|
3
|
+
|
|
4
|
+
This module provides a centralized way to load and manage API configuration
|
|
5
|
+
from the api.conf file. It supports environment variable substitution and
|
|
6
|
+
provides easy access to all service configurations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Try to import yaml, fallback to JSON if not available
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Union
|
|
16
|
+
|
|
17
|
+
HAS_YAML = False
|
|
18
|
+
try:
|
|
19
|
+
import yaml # type: ignore
|
|
20
|
+
|
|
21
|
+
HAS_YAML = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
pass # YAML not available, will use JSON
|
|
24
|
+
|
|
25
|
+
# Import TemplateEngine for default config generation
|
|
26
|
+
from socialseed_e2e.utils import TemplateEngine
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ServiceEndpoint:
|
|
31
|
+
"""Represents a single API endpoint configuration."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
path: str
|
|
35
|
+
method: str = "POST"
|
|
36
|
+
requires_auth: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ServiceConfig:
|
|
41
|
+
"""Complete configuration for a microservice."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
base_url: str
|
|
45
|
+
health_endpoint: str = "/actuator/health"
|
|
46
|
+
port: int = 8080
|
|
47
|
+
maven_module: str = ""
|
|
48
|
+
timeout: int = 30000
|
|
49
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
50
|
+
auto_start: bool = True
|
|
51
|
+
required: bool = True
|
|
52
|
+
endpoints: Dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ApiGatewayConfig:
|
|
57
|
+
"""API Gateway configuration."""
|
|
58
|
+
|
|
59
|
+
enabled: bool = False
|
|
60
|
+
url: str = ""
|
|
61
|
+
prefix: str = ""
|
|
62
|
+
auth_type: str = "none"
|
|
63
|
+
auth_token: Optional[str] = None
|
|
64
|
+
api_key_header: Optional[str] = None
|
|
65
|
+
api_key_value: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class DatabaseConfig:
|
|
70
|
+
"""Database connection configuration."""
|
|
71
|
+
|
|
72
|
+
host: str = "localhost"
|
|
73
|
+
port: int = 5432
|
|
74
|
+
database: str = ""
|
|
75
|
+
username: str = ""
|
|
76
|
+
password: str = ""
|
|
77
|
+
enabled: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class TestDataConfig:
|
|
82
|
+
"""Test data generation configuration."""
|
|
83
|
+
|
|
84
|
+
email_domain: str = "test.socialseed.com"
|
|
85
|
+
password: str = "StrongPass123!"
|
|
86
|
+
username_prefix: str = "testuser"
|
|
87
|
+
step_delay: int = 100
|
|
88
|
+
async_timeout: int = 10000
|
|
89
|
+
max_retries: int = 3
|
|
90
|
+
retry_backoff_ms: int = 1000
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class SecurityConfig:
|
|
95
|
+
"""Security and SSL configuration."""
|
|
96
|
+
|
|
97
|
+
verify_ssl: bool = True
|
|
98
|
+
ssl_cert: Optional[str] = None
|
|
99
|
+
ssl_key: Optional[str] = None
|
|
100
|
+
ssl_ca: Optional[str] = None
|
|
101
|
+
test_tokens: Dict[str, str] = field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ReportingConfig:
|
|
106
|
+
"""Test reporting configuration."""
|
|
107
|
+
|
|
108
|
+
format: str = "console"
|
|
109
|
+
save_logs: bool = True
|
|
110
|
+
log_dir: str = "./logs"
|
|
111
|
+
include_payloads: bool = False
|
|
112
|
+
screenshot_on_failure: bool = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class AppConfig:
|
|
117
|
+
"""Main application configuration container."""
|
|
118
|
+
|
|
119
|
+
environment: str = "dev"
|
|
120
|
+
timeout: int = 30000
|
|
121
|
+
user_agent: str = "SocialSeed-E2E-Agent/2.0"
|
|
122
|
+
verification_level: str = "strict"
|
|
123
|
+
verbose: bool = True
|
|
124
|
+
project_name: str = "SocialSeed"
|
|
125
|
+
project_version: str = "0.0.0"
|
|
126
|
+
api_gateway: ApiGatewayConfig = field(default_factory=ApiGatewayConfig)
|
|
127
|
+
services: Dict[str, ServiceConfig] = field(default_factory=dict)
|
|
128
|
+
databases: Dict[str, DatabaseConfig] = field(default_factory=dict)
|
|
129
|
+
test_data: TestDataConfig = field(default_factory=TestDataConfig)
|
|
130
|
+
security: SecurityConfig = field(default_factory=SecurityConfig)
|
|
131
|
+
reporting: ReportingConfig = field(default_factory=ReportingConfig)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ConfigError(Exception):
|
|
135
|
+
"""Custom exception for configuration errors."""
|
|
136
|
+
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ApiConfigLoader:
|
|
141
|
+
"""
|
|
142
|
+
Loads and manages API configuration from api.conf file.
|
|
143
|
+
|
|
144
|
+
Supports:
|
|
145
|
+
- Environment variable substitution: ${VAR_NAME} or ${VAR_NAME:-default}
|
|
146
|
+
- YAML or JSON format
|
|
147
|
+
- Hot-reloading (reload config without restarting)
|
|
148
|
+
- Singleton pattern for global access
|
|
149
|
+
|
|
150
|
+
Usage:
|
|
151
|
+
# Load default configuration
|
|
152
|
+
config = ApiConfigLoader.load()
|
|
153
|
+
|
|
154
|
+
# Get specific service configuration
|
|
155
|
+
auth_config = config.services.get("auth")
|
|
156
|
+
|
|
157
|
+
# Check if using API Gateway
|
|
158
|
+
if config.api_gateway.enabled:
|
|
159
|
+
base_url = config.api_gateway.url
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
_instance: Optional[AppConfig] = None
|
|
163
|
+
_config_path: Optional[Path] = None
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def load(cls, config_path: Optional[str] = None) -> AppConfig:
|
|
167
|
+
"""
|
|
168
|
+
Load configuration from api.conf file.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
config_path: Path to configuration file. If None, searches in:
|
|
172
|
+
1. Environment variable E2E_CONFIG_PATH
|
|
173
|
+
2. verify_services/api.conf (relative to project root)
|
|
174
|
+
3. Current working directory ./api.conf
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
AppConfig: Parsed configuration object
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
FileNotFoundError: If configuration file not found
|
|
181
|
+
ValueError: If configuration file is invalid
|
|
182
|
+
"""
|
|
183
|
+
if cls._instance is not None and config_path is None:
|
|
184
|
+
return cls._instance
|
|
185
|
+
|
|
186
|
+
# Determine config file path
|
|
187
|
+
if config_path is None:
|
|
188
|
+
config_path = cls._find_config_file()
|
|
189
|
+
|
|
190
|
+
config_file = Path(config_path)
|
|
191
|
+
if not config_file.exists():
|
|
192
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
|
193
|
+
|
|
194
|
+
# Read and parse file
|
|
195
|
+
content = config_file.read_text()
|
|
196
|
+
|
|
197
|
+
# Substitute environment variables
|
|
198
|
+
content = cls._substitute_env_vars(content)
|
|
199
|
+
|
|
200
|
+
# Parse YAML or JSON
|
|
201
|
+
if HAS_YAML:
|
|
202
|
+
import yaml as yaml_module
|
|
203
|
+
|
|
204
|
+
data = yaml_module.safe_load(content)
|
|
205
|
+
else:
|
|
206
|
+
import json as json_module
|
|
207
|
+
|
|
208
|
+
data = json_module.loads(content)
|
|
209
|
+
|
|
210
|
+
# Build configuration object
|
|
211
|
+
config = cls._parse_config(data)
|
|
212
|
+
cls._instance = config
|
|
213
|
+
cls._config_path = config_file
|
|
214
|
+
|
|
215
|
+
return config
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def reload(cls) -> AppConfig:
|
|
219
|
+
"""Reload configuration from file (useful for hot-reloading)."""
|
|
220
|
+
cls._instance = None
|
|
221
|
+
if cls._config_path is not None:
|
|
222
|
+
return cls.load(str(cls._config_path))
|
|
223
|
+
return cls.load()
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def get_config_path(cls) -> Optional[Path]:
|
|
227
|
+
"""Get the path of the currently loaded configuration file."""
|
|
228
|
+
return cls._config_path
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def _find_config_file(cls) -> str:
|
|
232
|
+
"""Find configuration file in common locations.
|
|
233
|
+
|
|
234
|
+
Search order (highest priority first):
|
|
235
|
+
1. E2E_CONFIG_PATH environment variable
|
|
236
|
+
2. ./e2e.conf (current directory)
|
|
237
|
+
3. ./config/e2e.conf
|
|
238
|
+
4. ./tests/e2e.conf
|
|
239
|
+
5. ~/.config/socialseed-e2e/default.conf
|
|
240
|
+
6. verify_services/api.conf (legacy, for backward compatibility)
|
|
241
|
+
7. ./api.conf (legacy, for backward compatibility)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
str: Path to the configuration file
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
FileNotFoundError: If no configuration file is found
|
|
248
|
+
"""
|
|
249
|
+
# Priority 1: Environment variable
|
|
250
|
+
if env_path := os.getenv("E2E_CONFIG_PATH"):
|
|
251
|
+
if Path(env_path).exists():
|
|
252
|
+
return env_path
|
|
253
|
+
raise FileNotFoundError(
|
|
254
|
+
f"Configuration file specified in E2E_CONFIG_PATH not found: {env_path}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Priority 2-4: Search in current directory and subdirectories
|
|
258
|
+
search_paths = [
|
|
259
|
+
Path("e2e.conf"), # Current directory
|
|
260
|
+
Path("config") / "e2e.conf", # config subdirectory
|
|
261
|
+
Path("tests") / "e2e.conf", # tests subdirectory
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
for path in search_paths:
|
|
265
|
+
if path.exists():
|
|
266
|
+
return str(path)
|
|
267
|
+
|
|
268
|
+
# Priority 5: Global config in home directory
|
|
269
|
+
home_config = Path.home() / ".config" / "socialseed-e2e" / "default.conf"
|
|
270
|
+
if home_config.exists():
|
|
271
|
+
return str(home_config)
|
|
272
|
+
|
|
273
|
+
# Priority 6-7: Legacy paths (for backward compatibility)
|
|
274
|
+
# Search up the directory tree for verify_services/api.conf
|
|
275
|
+
current_dir = Path.cwd()
|
|
276
|
+
for parent in [current_dir] + list(current_dir.parents):
|
|
277
|
+
verify_services_dir = parent / "verify_services"
|
|
278
|
+
if verify_services_dir.exists():
|
|
279
|
+
legacy_config = verify_services_dir / "api.conf"
|
|
280
|
+
if legacy_config.exists():
|
|
281
|
+
return str(legacy_config)
|
|
282
|
+
|
|
283
|
+
# Legacy: api.conf in current directory
|
|
284
|
+
if Path("api.conf").exists():
|
|
285
|
+
return "api.conf"
|
|
286
|
+
|
|
287
|
+
# Configuration not found
|
|
288
|
+
raise FileNotFoundError(
|
|
289
|
+
"Could not find configuration file. Searched in:\n"
|
|
290
|
+
" 1. E2E_CONFIG_PATH environment variable\n"
|
|
291
|
+
" 2. ./e2e.conf\n"
|
|
292
|
+
" 3. ./config/e2e.conf\n"
|
|
293
|
+
" 4. ./tests/e2e.conf\n"
|
|
294
|
+
" 5. ~/.config/socialseed-e2e/default.conf\n"
|
|
295
|
+
" 6. verify_services/api.conf (legacy)\n"
|
|
296
|
+
" 7. ./api.conf (legacy)\n\n"
|
|
297
|
+
"To create a default configuration, run:\n"
|
|
298
|
+
" e2e init\n\n"
|
|
299
|
+
"Or set the E2E_CONFIG_PATH environment variable:\n"
|
|
300
|
+
" export E2E_CONFIG_PATH=/path/to/your/e2e.conf"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def create_default_config(cls, path: Union[str, Path], overwrite: bool = False) -> str:
|
|
305
|
+
"""Create a default e2e.conf configuration file.
|
|
306
|
+
|
|
307
|
+
Creates a default configuration file at the specified path using
|
|
308
|
+
the e2e.conf.template template. Parent directories are created
|
|
309
|
+
automatically if they don't exist.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
path: Path where to create the configuration file
|
|
313
|
+
overwrite: If True, overwrite existing file
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
str: Path to the created configuration file
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
FileExistsError: If file exists and overwrite=False
|
|
320
|
+
FileNotFoundError: If template file doesn't exist
|
|
321
|
+
"""
|
|
322
|
+
path = Path(path)
|
|
323
|
+
engine = TemplateEngine()
|
|
324
|
+
|
|
325
|
+
rendered_path = engine.render_to_file(
|
|
326
|
+
"e2e.conf",
|
|
327
|
+
variables={
|
|
328
|
+
"environment": "dev",
|
|
329
|
+
"timeout": "30000",
|
|
330
|
+
"user_agent": "SocialSeed-E2E-Agent/2.0",
|
|
331
|
+
"verbose": "true",
|
|
332
|
+
"services_config": "",
|
|
333
|
+
},
|
|
334
|
+
output_path=path,
|
|
335
|
+
overwrite=overwrite,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return str(rendered_path)
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def _substitute_env_vars(cls, content: str) -> str:
|
|
342
|
+
"""Substitute environment variables in configuration content."""
|
|
343
|
+
# Pattern: ${VAR_NAME} or ${VAR_NAME:-default_value}
|
|
344
|
+
pattern = r"\$\{(\w+)(?::-([^}]*))?\}"
|
|
345
|
+
|
|
346
|
+
def replace_var(match):
|
|
347
|
+
var_name = match.group(1)
|
|
348
|
+
default_value = match.group(2)
|
|
349
|
+
|
|
350
|
+
env_value = os.getenv(var_name)
|
|
351
|
+
if env_value is not None:
|
|
352
|
+
return env_value
|
|
353
|
+
elif default_value is not None:
|
|
354
|
+
return default_value
|
|
355
|
+
else:
|
|
356
|
+
# Keep original if no value found
|
|
357
|
+
return match.group(0)
|
|
358
|
+
|
|
359
|
+
return re.sub(pattern, replace_var, content)
|
|
360
|
+
|
|
361
|
+
@classmethod
|
|
362
|
+
def validate_config(cls, data: Dict[str, Any], strict: bool = False) -> None:
|
|
363
|
+
"""Validate minimum configuration requirements.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
data: Raw configuration dictionary
|
|
367
|
+
strict: If True, raises error for missing optional fields
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ConfigError: If configuration is invalid or missing required fields
|
|
371
|
+
"""
|
|
372
|
+
errors = []
|
|
373
|
+
warnings = []
|
|
374
|
+
|
|
375
|
+
# Check for required 'general' section
|
|
376
|
+
if "general" not in data:
|
|
377
|
+
errors.append("Missing required 'general' section")
|
|
378
|
+
else:
|
|
379
|
+
general = data["general"]
|
|
380
|
+
|
|
381
|
+
# Validate environment
|
|
382
|
+
if "environment" in general:
|
|
383
|
+
valid_envs = ["dev", "development", "staging", "prod", "production", "test"]
|
|
384
|
+
if general["environment"] not in valid_envs:
|
|
385
|
+
warnings.append(
|
|
386
|
+
f"Unusual environment value: {general['environment']}. "
|
|
387
|
+
f"Recommended: {', '.join(valid_envs)}"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Validate timeout is positive integer
|
|
391
|
+
if "timeout" in general:
|
|
392
|
+
try:
|
|
393
|
+
timeout = int(general["timeout"])
|
|
394
|
+
if timeout <= 0:
|
|
395
|
+
errors.append("general.timeout must be a positive integer")
|
|
396
|
+
elif timeout < 1000:
|
|
397
|
+
warnings.append(f"general.timeout is very short ({timeout}ms)")
|
|
398
|
+
except (ValueError, TypeError):
|
|
399
|
+
errors.append("general.timeout must be a valid integer")
|
|
400
|
+
|
|
401
|
+
# Check for services section (at least one service recommended)
|
|
402
|
+
services = data.get("services", {})
|
|
403
|
+
if not services:
|
|
404
|
+
warnings.append("No services defined. Add at least one service to test.")
|
|
405
|
+
else:
|
|
406
|
+
# Validate each service has required fields
|
|
407
|
+
for service_name, service_data in services.items():
|
|
408
|
+
if not isinstance(service_data, dict):
|
|
409
|
+
errors.append(f"Service '{service_name}' must be a dictionary")
|
|
410
|
+
continue
|
|
411
|
+
|
|
412
|
+
if "base_url" not in service_data:
|
|
413
|
+
errors.append(f"Service '{service_name}' missing required field: base_url")
|
|
414
|
+
elif not service_data.get("base_url"):
|
|
415
|
+
warnings.append(f"Service '{service_name}' has empty base_url")
|
|
416
|
+
|
|
417
|
+
# Validate port if provided
|
|
418
|
+
if "port" in service_data:
|
|
419
|
+
try:
|
|
420
|
+
port = int(service_data["port"])
|
|
421
|
+
if port < 1 or port > 65535:
|
|
422
|
+
errors.append(
|
|
423
|
+
f"Service '{service_name}' port must be between 1 and 65535"
|
|
424
|
+
)
|
|
425
|
+
except (ValueError, TypeError):
|
|
426
|
+
errors.append(f"Service '{service_name}' port must be a valid integer")
|
|
427
|
+
|
|
428
|
+
# Raise error if there are validation errors
|
|
429
|
+
if errors:
|
|
430
|
+
raise ConfigError("Configuration validation failed:\n - " + "\n - ".join(errors))
|
|
431
|
+
|
|
432
|
+
# Print warnings if strict mode
|
|
433
|
+
if strict and warnings:
|
|
434
|
+
import warnings as warnings_module
|
|
435
|
+
|
|
436
|
+
for warning in warnings:
|
|
437
|
+
warnings_module.warn(f"Config warning: {warning}")
|
|
438
|
+
|
|
439
|
+
@classmethod
|
|
440
|
+
def _parse_config(cls, data: Dict[str, Any]) -> AppConfig:
|
|
441
|
+
"""Parse raw dictionary into AppConfig object."""
|
|
442
|
+
# Validate configuration before parsing
|
|
443
|
+
cls.validate_config(data)
|
|
444
|
+
|
|
445
|
+
# General configuration
|
|
446
|
+
general = data.get("general", {})
|
|
447
|
+
project = general.get("project", {})
|
|
448
|
+
|
|
449
|
+
config = AppConfig(
|
|
450
|
+
environment=general.get("environment", "dev"),
|
|
451
|
+
timeout=general.get("timeout", 30000),
|
|
452
|
+
user_agent=general.get("user_agent", "SocialSeed-E2E-Agent/2.0"),
|
|
453
|
+
verification_level=general.get("verification_level", "strict"),
|
|
454
|
+
verbose=general.get("verbose", True),
|
|
455
|
+
project_name=project.get("name", "SocialSeed"),
|
|
456
|
+
project_version=project.get("version", "0.0.0"),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# API Gateway
|
|
460
|
+
gateway_data = data.get("api_gateway", {})
|
|
461
|
+
auth_data = gateway_data.get("auth", {})
|
|
462
|
+
config.api_gateway = ApiGatewayConfig(
|
|
463
|
+
enabled=gateway_data.get("enabled", False),
|
|
464
|
+
url=gateway_data.get("url", ""),
|
|
465
|
+
prefix=gateway_data.get("prefix", ""),
|
|
466
|
+
auth_type=auth_data.get("type", "none"),
|
|
467
|
+
auth_token=auth_data.get("bearer_token"),
|
|
468
|
+
api_key_header=auth_data.get("api_key_header"),
|
|
469
|
+
api_key_value=auth_data.get("api_key_value"),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Services
|
|
473
|
+
services_data = data.get("services", {})
|
|
474
|
+
for service_name, service_data in services_data.items():
|
|
475
|
+
config.services[service_name] = ServiceConfig(
|
|
476
|
+
name=service_data.get("name", service_name),
|
|
477
|
+
base_url=service_data.get("base_url", ""),
|
|
478
|
+
health_endpoint=service_data.get("health_endpoint", "/actuator/health"),
|
|
479
|
+
port=service_data.get("port", 8080),
|
|
480
|
+
maven_module=service_data.get("maven_module", f"services/{service_name}"),
|
|
481
|
+
timeout=service_data.get("timeout", config.timeout),
|
|
482
|
+
headers=service_data.get("headers", {}),
|
|
483
|
+
auto_start=service_data.get("auto_start", True),
|
|
484
|
+
required=service_data.get("required", True),
|
|
485
|
+
endpoints=service_data.get("endpoints", {}),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Databases
|
|
489
|
+
databases_data = data.get("databases", {})
|
|
490
|
+
for db_name, db_data in databases_data.items():
|
|
491
|
+
config.databases[db_name] = DatabaseConfig(
|
|
492
|
+
host=db_data.get("host", "localhost"),
|
|
493
|
+
port=db_data.get("port", 5432),
|
|
494
|
+
database=db_data.get("database", ""),
|
|
495
|
+
username=db_data.get("username", ""),
|
|
496
|
+
password=db_data.get("password", ""),
|
|
497
|
+
enabled=db_data.get("enabled", False),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Test data
|
|
501
|
+
test_data = data.get("test_data", {})
|
|
502
|
+
user_data = test_data.get("user", {})
|
|
503
|
+
timing_data = test_data.get("timing", {})
|
|
504
|
+
retries_data = test_data.get("retries", {})
|
|
505
|
+
config.test_data = TestDataConfig(
|
|
506
|
+
email_domain=user_data.get("email_domain", "test.socialseed.com"),
|
|
507
|
+
password=user_data.get("password", "StrongPass123!"),
|
|
508
|
+
username_prefix=user_data.get("username_prefix", "testuser"),
|
|
509
|
+
step_delay=timing_data.get("step_delay", 100),
|
|
510
|
+
async_timeout=timing_data.get("async_timeout", 10000),
|
|
511
|
+
max_retries=retries_data.get("max_attempts", 3),
|
|
512
|
+
retry_backoff_ms=retries_data.get("backoff_ms", 1000),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Security
|
|
516
|
+
security_data = data.get("security", {})
|
|
517
|
+
config.security = SecurityConfig(
|
|
518
|
+
verify_ssl=security_data.get("verify_ssl", True),
|
|
519
|
+
ssl_cert=security_data.get("ssl_cert"),
|
|
520
|
+
ssl_key=security_data.get("ssl_key"),
|
|
521
|
+
ssl_ca=security_data.get("ssl_ca"),
|
|
522
|
+
test_tokens=security_data.get("test_tokens", {}),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Reporting
|
|
526
|
+
reporting_data = data.get("reporting", {})
|
|
527
|
+
config.reporting = ReportingConfig(
|
|
528
|
+
format=reporting_data.get("format", "console"),
|
|
529
|
+
save_logs=reporting_data.get("save_logs", True),
|
|
530
|
+
log_dir=reporting_data.get("log_dir", "./logs"),
|
|
531
|
+
include_payloads=reporting_data.get("include_payloads", False),
|
|
532
|
+
screenshot_on_failure=reporting_data.get("screenshot_on_failure", False),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
return config
|
|
536
|
+
|
|
537
|
+
@classmethod
|
|
538
|
+
def get_service_url(cls, service_name: str, use_gateway: Optional[bool] = None) -> str:
|
|
539
|
+
"""
|
|
540
|
+
Get the effective URL for a service.
|
|
541
|
+
|
|
542
|
+
If API Gateway is enabled, returns gateway URL + service path.
|
|
543
|
+
Otherwise, returns the service's base_url.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
service_name: Name of the service
|
|
547
|
+
use_gateway: Force gateway mode (None = use config setting)
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
str: Full URL for the service
|
|
551
|
+
"""
|
|
552
|
+
config = cls.load()
|
|
553
|
+
service = config.services.get(service_name)
|
|
554
|
+
|
|
555
|
+
if not service:
|
|
556
|
+
raise ValueError(f"Service '{service_name}' not found in configuration")
|
|
557
|
+
|
|
558
|
+
# Determine if we should use gateway
|
|
559
|
+
should_use_gateway = use_gateway if use_gateway is not None else config.api_gateway.enabled
|
|
560
|
+
|
|
561
|
+
if should_use_gateway and config.api_gateway.url:
|
|
562
|
+
gateway_url = config.api_gateway.url.rstrip("/")
|
|
563
|
+
prefix = config.api_gateway.prefix.rstrip("/")
|
|
564
|
+
return f"{gateway_url}{prefix}/{service_name}"
|
|
565
|
+
|
|
566
|
+
return service.base_url
|
|
567
|
+
|
|
568
|
+
@classmethod
|
|
569
|
+
def get_all_required_services(cls) -> List[str]:
|
|
570
|
+
"""Get list of services marked as required."""
|
|
571
|
+
config = cls.load()
|
|
572
|
+
return [name for name, service in config.services.items() if service.required]
|
|
573
|
+
|
|
574
|
+
@classmethod
|
|
575
|
+
def get_auto_start_services(cls) -> List[str]:
|
|
576
|
+
"""Get list of services configured for auto-start."""
|
|
577
|
+
config = cls.load()
|
|
578
|
+
return [name for name, service in config.services.items() if service.auto_start]
|
|
579
|
+
|
|
580
|
+
@classmethod
|
|
581
|
+
def get_service_by_maven_module(cls, maven_module: str) -> Optional[ServiceConfig]:
|
|
582
|
+
"""Find service configuration by Maven module name."""
|
|
583
|
+
config = cls.load()
|
|
584
|
+
for service in config.services.values():
|
|
585
|
+
if service.maven_module == maven_module:
|
|
586
|
+
return service
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# Convenience functions for direct import
|
|
591
|
+
def get_config() -> AppConfig:
|
|
592
|
+
"""Get the global configuration instance."""
|
|
593
|
+
return ApiConfigLoader.load()
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def get_service_config(service_name: str) -> Optional[ServiceConfig]:
|
|
597
|
+
"""Get configuration for a specific service."""
|
|
598
|
+
config = ApiConfigLoader.load()
|
|
599
|
+
return config.services.get(service_name)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def get_service_url(service_name: str) -> str:
|
|
603
|
+
"""Get effective URL for a service (considers API Gateway)."""
|
|
604
|
+
return ApiConfigLoader.get_service_url(service_name)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
DEFAULT_JSON_HEADERS: Dict[str, str] = {
|
|
4
|
+
"Content-Type": "application/json",
|
|
5
|
+
"Accept": "application/json, text/plain, */*",
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
DEFAULT_BROWSER_HEADERS: Dict[str, str] = {
|
|
9
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
10
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
11
|
+
"Connection": "keep-alive",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_combined_headers(custom_headers: Dict[str, str] = None) -> Dict[str, str]:
|
|
16
|
+
"""Helper to combine default headers with custom ones."""
|
|
17
|
+
headers = {**DEFAULT_JSON_HEADERS, **DEFAULT_BROWSER_HEADERS}
|
|
18
|
+
if custom_headers:
|
|
19
|
+
headers.update(custom_headers)
|
|
20
|
+
return headers
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Any, Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@runtime_checkable
|
|
5
|
+
class IServicePage(Protocol):
|
|
6
|
+
"""Protocol that every service-specific page object should implement."""
|
|
7
|
+
|
|
8
|
+
base_url: str
|
|
9
|
+
|
|
10
|
+
def setup(self) -> None:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
def teardown(self) -> None:
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class ITestModule(Protocol):
|
|
19
|
+
"""Protocol for test modules."""
|
|
20
|
+
|
|
21
|
+
def run(self, context: Any) -> None:
|
|
22
|
+
...
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModuleLoader:
|
|
8
|
+
"""Handles discovery and dynamic loading of Python modules from file paths."""
|
|
9
|
+
|
|
10
|
+
@staticmethod
|
|
11
|
+
def load_runnable_from_file(file_path: Path, function_name: str = "run") -> Optional[Callable]:
|
|
12
|
+
"""Loads a specific function from a python file."""
|
|
13
|
+
if not file_path.exists() or file_path.suffix != ".py":
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
# Create a unique module name based on path to avoid collisions
|
|
18
|
+
module_name = f"dynamic_mod_{file_path.stem}_{hash(str(file_path)) % 10000}"
|
|
19
|
+
|
|
20
|
+
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
|
|
21
|
+
if spec and spec.loader:
|
|
22
|
+
module = importlib.util.module_from_spec(spec)
|
|
23
|
+
sys.modules[module_name] = module
|
|
24
|
+
spec.loader.exec_module(module)
|
|
25
|
+
|
|
26
|
+
func = getattr(module, function_name, None)
|
|
27
|
+
if callable(func):
|
|
28
|
+
return func
|
|
29
|
+
except Exception as e:
|
|
30
|
+
print(f"Error loading {file_path}: {e}")
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def discover_runnables(self, root_path: Path, pattern: str = "*.py") -> List[Callable]:
|
|
35
|
+
"""Discovers all runnable modules in a directory matching a pattern."""
|
|
36
|
+
runnables = []
|
|
37
|
+
if not root_path.exists() or not root_path.is_dir():
|
|
38
|
+
return runnables
|
|
39
|
+
|
|
40
|
+
# Sort by filename to ensure predictable order
|
|
41
|
+
paths = sorted(list(root_path.glob(pattern)), key=lambda p: p.name)
|
|
42
|
+
|
|
43
|
+
for file_path in paths:
|
|
44
|
+
if file_path.name == "__init__.py":
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
runnable = self.load_runnable_from_file(file_path)
|
|
48
|
+
if runnable:
|
|
49
|
+
runnables.append(runnable)
|
|
50
|
+
|
|
51
|
+
return runnables
|