devdox-ai-locust 0.1.1__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.
Potentially problematic release.
This version of devdox-ai-locust might be problematic. Click here for more details.
- devdox_ai_locust/__init__.py +9 -0
- devdox_ai_locust/cli.py +452 -0
- devdox_ai_locust/config.py +24 -0
- devdox_ai_locust/hybrid_loctus_generator.py +904 -0
- devdox_ai_locust/locust_generator.py +732 -0
- devdox_ai_locust/py.typed +0 -0
- devdox_ai_locust/schemas/__init__.py +0 -0
- devdox_ai_locust/schemas/processing_result.py +24 -0
- devdox_ai_locust/templates/base_workflow.py.j2 +180 -0
- devdox_ai_locust/templates/config.py.j2 +173 -0
- devdox_ai_locust/templates/custom_flows.py.j2 +95 -0
- devdox_ai_locust/templates/endpoint_template.py.j2 +34 -0
- devdox_ai_locust/templates/env.example.j2 +3 -0
- devdox_ai_locust/templates/fallback_locust.py.j2 +25 -0
- devdox_ai_locust/templates/locust.py.j2 +70 -0
- devdox_ai_locust/templates/readme.md.j2 +46 -0
- devdox_ai_locust/templates/requirement.txt.j2 +31 -0
- devdox_ai_locust/templates/test_data.py.j2 +276 -0
- devdox_ai_locust/templates/utils.py.j2 +335 -0
- devdox_ai_locust/utils/__init__.py +0 -0
- devdox_ai_locust/utils/file_creation.py +120 -0
- devdox_ai_locust/utils/open_ai_parser.py +431 -0
- devdox_ai_locust/utils/swagger_utils.py +94 -0
- devdox_ai_locust-0.1.1.dist-info/METADATA +424 -0
- devdox_ai_locust-0.1.1.dist-info/RECORD +29 -0
- devdox_ai_locust-0.1.1.dist-info/WHEEL +5 -0
- devdox_ai_locust-0.1.1.dist-info/entry_points.txt +3 -0
- devdox_ai_locust-0.1.1.dist-info/licenses/LICENSE +201 -0
- devdox_ai_locust-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
import uuid
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileCreationConfig:
|
|
14
|
+
"""Configuration for file creation process"""
|
|
15
|
+
|
|
16
|
+
ALLOWED_EXTENSIONS = {
|
|
17
|
+
".py",
|
|
18
|
+
".md",
|
|
19
|
+
".txt",
|
|
20
|
+
".sh",
|
|
21
|
+
".yml",
|
|
22
|
+
".yaml",
|
|
23
|
+
".json",
|
|
24
|
+
".example",
|
|
25
|
+
}
|
|
26
|
+
MAX_FILE_SIZE = 1024 * 1024 # 1MB
|
|
27
|
+
EXECUTABLE_EXTENSIONS = {".sh"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SafeFileCreator:
|
|
31
|
+
"""Handles safe file creation with separated concerns"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: Optional[FileCreationConfig] = None):
|
|
34
|
+
self.config = config or FileCreationConfig()
|
|
35
|
+
|
|
36
|
+
def _sanitize_filename(self, filename: str) -> str:
|
|
37
|
+
"""Sanitize filename to prevent security issues"""
|
|
38
|
+
|
|
39
|
+
# Remove directory components
|
|
40
|
+
clean_name = os.path.basename(filename).lower()
|
|
41
|
+
|
|
42
|
+
# Remove dangerous characters
|
|
43
|
+
clean_name = re.sub(r'[<>:"/\\|?*]', "", clean_name)
|
|
44
|
+
# Replace spaces with underscores
|
|
45
|
+
clean_name = clean_name.replace("- ", "_")
|
|
46
|
+
|
|
47
|
+
# Ensure reasonable length
|
|
48
|
+
if len(clean_name) > 255:
|
|
49
|
+
name_part, ext = os.path.splitext(clean_name)
|
|
50
|
+
clean_name = name_part[:250] + ext
|
|
51
|
+
|
|
52
|
+
# Prevent hidden files and ensure not empty
|
|
53
|
+
safe_dotfiles = {".env.example", ".gitignore", ".env.template"}
|
|
54
|
+
if not clean_name or (
|
|
55
|
+
clean_name.startswith(".") and clean_name not in safe_dotfiles
|
|
56
|
+
):
|
|
57
|
+
clean_name = f"generated_{uuid.uuid4().hex[:8]}.py"
|
|
58
|
+
|
|
59
|
+
return clean_name
|
|
60
|
+
|
|
61
|
+
def validate_file(self, filename: str, content: str) -> tuple[bool, str, str]:
|
|
62
|
+
"""Validate file and return (is_valid, clean_filename, processed_content)"""
|
|
63
|
+
clean_filename = self._sanitize_filename(filename)
|
|
64
|
+
file_extension = Path(clean_filename).suffix.lower()
|
|
65
|
+
|
|
66
|
+
if file_extension not in self.config.ALLOWED_EXTENSIONS:
|
|
67
|
+
logger.warning(f"Skipping file with disallowed extension: {filename}")
|
|
68
|
+
return False, clean_filename, content
|
|
69
|
+
|
|
70
|
+
# Handle oversized files
|
|
71
|
+
if len(content.encode("utf-8")) > self.config.MAX_FILE_SIZE:
|
|
72
|
+
logger.warning(f"File too large, truncating: {filename}")
|
|
73
|
+
content = content[: self.config.MAX_FILE_SIZE // 2]
|
|
74
|
+
|
|
75
|
+
return True, clean_filename, content
|
|
76
|
+
|
|
77
|
+
async def create_temp_file(
|
|
78
|
+
self, filename: str, content: str, temp_dir: Path
|
|
79
|
+
) -> dict:
|
|
80
|
+
"""Create a single file in temp directory"""
|
|
81
|
+
temp_file_path = temp_dir / filename
|
|
82
|
+
|
|
83
|
+
await asyncio.to_thread(temp_file_path.write_text, content, encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
# Set permissions
|
|
86
|
+
file_extension = Path(filename).suffix.lower()
|
|
87
|
+
permissions = (
|
|
88
|
+
0o755 if file_extension in self.config.EXECUTABLE_EXTENSIONS else 0o644
|
|
89
|
+
)
|
|
90
|
+
temp_file_path.chmod(permissions)
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"filename": filename,
|
|
94
|
+
"temp_path": temp_file_path,
|
|
95
|
+
"size": len(content.encode("utf-8")),
|
|
96
|
+
"type": file_extension.lstrip("."),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async def move_files_atomically(
|
|
100
|
+
self, file_infos: List[dict], output_path: Path
|
|
101
|
+
) -> List[dict]:
|
|
102
|
+
"""Move all files from temp to final location atomically"""
|
|
103
|
+
successfully_moved = []
|
|
104
|
+
|
|
105
|
+
for file_info in file_infos:
|
|
106
|
+
final_path = output_path / file_info["filename"]
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
await asyncio.to_thread(
|
|
110
|
+
shutil.move, str(file_info["temp_path"]), str(final_path)
|
|
111
|
+
)
|
|
112
|
+
file_info["final_path"] = final_path
|
|
113
|
+
file_info["path"] = final_path
|
|
114
|
+
successfully_moved.append(file_info)
|
|
115
|
+
logger.info(f"Created: {file_info['filename']}")
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Failed to move file {file_info['filename']}: {e}")
|
|
119
|
+
|
|
120
|
+
return successfully_moved
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAPI 3.x Specification Parser
|
|
3
|
+
|
|
4
|
+
Parses OpenAPI/Swagger specifications and extracts endpoint information
|
|
5
|
+
for automated test generation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import yaml
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Dict, List, Optional, Any, Union
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
application_json_type = "application/json"
|
|
18
|
+
localhost_url = "http://localhost"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ParameterType(Enum):
|
|
22
|
+
QUERY = "query"
|
|
23
|
+
PATH = "path"
|
|
24
|
+
HEADER = "header"
|
|
25
|
+
COOKIE = "cookie"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Parameter:
|
|
30
|
+
"""Represents an OpenAPI parameter"""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
location: ParameterType
|
|
34
|
+
required: bool
|
|
35
|
+
type: str
|
|
36
|
+
description: Optional[str] = None
|
|
37
|
+
example: Optional[Any] = None
|
|
38
|
+
enum: Optional[List[Any]] = None
|
|
39
|
+
default: Optional[Any] = None
|
|
40
|
+
format: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RequestBody:
|
|
45
|
+
"""Represents an OpenAPI request body"""
|
|
46
|
+
|
|
47
|
+
content_type: str
|
|
48
|
+
schema: Dict[str, Any]
|
|
49
|
+
required: bool = True
|
|
50
|
+
description: Optional[str] = None
|
|
51
|
+
examples: Optional[Dict[str, Any]] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Response:
|
|
56
|
+
"""Represents an OpenAPI response"""
|
|
57
|
+
|
|
58
|
+
status_code: str
|
|
59
|
+
description: str
|
|
60
|
+
content_type: Optional[str] = None
|
|
61
|
+
schema: Optional[Dict[str, Any]] = None
|
|
62
|
+
headers: Optional[Dict[str, Any]] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Endpoint:
|
|
67
|
+
"""Represents a parsed API endpoint"""
|
|
68
|
+
|
|
69
|
+
path: str
|
|
70
|
+
method: str
|
|
71
|
+
operation_id: Optional[str]
|
|
72
|
+
summary: Optional[str]
|
|
73
|
+
description: Optional[str]
|
|
74
|
+
parameters: List[Parameter]
|
|
75
|
+
request_body: Optional[RequestBody]
|
|
76
|
+
responses: List[Response]
|
|
77
|
+
tags: List[str]
|
|
78
|
+
security: Optional[List[Dict[str, Any]]] = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OpenAPIParser:
|
|
82
|
+
"""Parser for OpenAPI 3.x specifications"""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self.spec_data: Optional[Dict[str, Any]] = None
|
|
86
|
+
self.components: Optional[Dict[str, Any]] = None
|
|
87
|
+
|
|
88
|
+
def parse_schema(self, schema_content: str) -> Dict[str, Any]:
|
|
89
|
+
"""
|
|
90
|
+
Parse OpenAPI schema from string content
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
schema_content: Raw OpenAPI schema as string (JSON or YAML)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Parsed schema dictionary
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If schema is invalid or cannot be parsed
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
# Try parsing as JSON first
|
|
103
|
+
try:
|
|
104
|
+
self.spec_data = json.loads(schema_content)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
# If JSON fails, try YAML
|
|
107
|
+
self.spec_data = yaml.safe_load(schema_content)
|
|
108
|
+
|
|
109
|
+
# Validate OpenAPI structure
|
|
110
|
+
self._validate_openapi_schema()
|
|
111
|
+
|
|
112
|
+
# Store components for reference resolution
|
|
113
|
+
self.components = self.spec_data.get("components", {})
|
|
114
|
+
|
|
115
|
+
return self.spec_data
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Failed to parse OpenAPI schema: {e}")
|
|
119
|
+
raise ValueError(f"Invalid OpenAPI schema: {e}")
|
|
120
|
+
|
|
121
|
+
def _validate_openapi_schema(self) -> None:
|
|
122
|
+
"""Validate that the schema has required OpenAPI structure"""
|
|
123
|
+
if not self.spec_data:
|
|
124
|
+
raise ValueError("No schema data to validate")
|
|
125
|
+
|
|
126
|
+
required_fields = ["openapi", "info", "paths"]
|
|
127
|
+
missing_fields = [
|
|
128
|
+
field for field in required_fields if field not in self.spec_data
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
if missing_fields:
|
|
132
|
+
raise ValueError(f"Missing required OpenAPI fields: {missing_fields}")
|
|
133
|
+
|
|
134
|
+
# Check OpenAPI version
|
|
135
|
+
version = self.spec_data.get("openapi", "")
|
|
136
|
+
if not version.startswith("3."):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Unsupported OpenAPI version: {version}. Only 3.x is supported."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def parse_endpoints(self) -> List[Endpoint]:
|
|
142
|
+
"""
|
|
143
|
+
Extract all endpoints from the OpenAPI specification
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
List of parsed Endpoint objects
|
|
147
|
+
"""
|
|
148
|
+
if not self.spec_data:
|
|
149
|
+
raise ValueError("Schema must be parsed first. Call parse_schema().")
|
|
150
|
+
|
|
151
|
+
endpoints = []
|
|
152
|
+
paths = self.spec_data.get("paths", {})
|
|
153
|
+
|
|
154
|
+
for path, path_item in paths.items():
|
|
155
|
+
# Skip parameters defined at path level for now
|
|
156
|
+
# (they apply to all operations in the path)
|
|
157
|
+
path_parameters = path_item.get("parameters", [])
|
|
158
|
+
|
|
159
|
+
# Process each HTTP method
|
|
160
|
+
http_methods = [
|
|
161
|
+
"get",
|
|
162
|
+
"post",
|
|
163
|
+
"put",
|
|
164
|
+
"patch",
|
|
165
|
+
"delete",
|
|
166
|
+
"head",
|
|
167
|
+
"options",
|
|
168
|
+
"trace",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
for method in http_methods:
|
|
172
|
+
if method in path_item:
|
|
173
|
+
operation = path_item[method]
|
|
174
|
+
|
|
175
|
+
endpoint = Endpoint(
|
|
176
|
+
path=path,
|
|
177
|
+
method=method.upper(),
|
|
178
|
+
operation_id=operation.get("operationId"),
|
|
179
|
+
summary=operation.get("summary"),
|
|
180
|
+
description=operation.get("description"),
|
|
181
|
+
parameters=self._extract_parameters(operation, path_parameters),
|
|
182
|
+
request_body=self._extract_request_body(operation),
|
|
183
|
+
responses=self._extract_responses(operation),
|
|
184
|
+
tags=operation.get("tags", []),
|
|
185
|
+
security=operation.get("security"),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
endpoints.append(endpoint)
|
|
189
|
+
return endpoints
|
|
190
|
+
|
|
191
|
+
def _extract_parameters(
|
|
192
|
+
self,
|
|
193
|
+
operation: Dict[str, Any],
|
|
194
|
+
path_parameters: Optional[List[Dict[str, Any]]] = None,
|
|
195
|
+
) -> List[Parameter]:
|
|
196
|
+
"""
|
|
197
|
+
Extract parameters from an OpenAPI operation
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
operation: OpenAPI operation object
|
|
201
|
+
path_parameters: Parameters defined at path level
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of Parameter objects
|
|
205
|
+
"""
|
|
206
|
+
parameters = []
|
|
207
|
+
|
|
208
|
+
# Combine path-level and operation-level parameters
|
|
209
|
+
all_params = (path_parameters or []) + operation.get("parameters", [])
|
|
210
|
+
|
|
211
|
+
for param in all_params:
|
|
212
|
+
# Resolve reference if needed
|
|
213
|
+
param = self._resolve_reference(param)
|
|
214
|
+
|
|
215
|
+
if not param:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# Extract parameter type from schema
|
|
219
|
+
param_schema = param.get("schema", {})
|
|
220
|
+
param_type = param_schema.get("type", "string")
|
|
221
|
+
param_format = param_schema.get("format")
|
|
222
|
+
|
|
223
|
+
# Handle array types
|
|
224
|
+
if param_type == "array":
|
|
225
|
+
items = param_schema.get("items", {})
|
|
226
|
+
param_type = f"array[{items.get('type', 'string')}]"
|
|
227
|
+
|
|
228
|
+
parameter = Parameter(
|
|
229
|
+
name=param.get("name", ""),
|
|
230
|
+
location=ParameterType(param.get("in", "query")),
|
|
231
|
+
required=param.get("required", False),
|
|
232
|
+
type=param_type,
|
|
233
|
+
description=param.get("description"),
|
|
234
|
+
example=param.get("example") or param_schema.get("example"),
|
|
235
|
+
enum=param_schema.get("enum"),
|
|
236
|
+
default=param_schema.get("default"),
|
|
237
|
+
format=param_format,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
parameters.append(parameter)
|
|
241
|
+
|
|
242
|
+
return parameters
|
|
243
|
+
|
|
244
|
+
def _extract_request_body(self, operation: Dict[str, Any]) -> Optional[RequestBody]:
|
|
245
|
+
"""
|
|
246
|
+
Extract request body from an OpenAPI operation
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
operation: OpenAPI operation object
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
RequestBody object or None if no request body
|
|
253
|
+
"""
|
|
254
|
+
request_body_def = operation.get("requestBody")
|
|
255
|
+
if not request_body_def:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
# Resolve reference if needed
|
|
259
|
+
request_body_def = self._resolve_reference(request_body_def)
|
|
260
|
+
if not request_body_def:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
content = request_body_def.get("content", {})
|
|
264
|
+
if not content:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
# Get the first content type (prioritize JSON)
|
|
268
|
+
content_types = list(content.keys())
|
|
269
|
+
preferred_types = [
|
|
270
|
+
application_json_type,
|
|
271
|
+
"application/x-www-form-urlencoded",
|
|
272
|
+
"multipart/form-data",
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
content_type = None
|
|
276
|
+
for preferred in preferred_types:
|
|
277
|
+
if preferred in content_types:
|
|
278
|
+
content_type = preferred
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
if not content_type:
|
|
282
|
+
content_type = content_types[0]
|
|
283
|
+
|
|
284
|
+
media_type = content[content_type]
|
|
285
|
+
schema = media_type.get("schema", {})
|
|
286
|
+
|
|
287
|
+
# Resolve schema reference if needed
|
|
288
|
+
schema = self._resolve_reference(schema)
|
|
289
|
+
|
|
290
|
+
return RequestBody(
|
|
291
|
+
content_type=content_type,
|
|
292
|
+
schema=schema or {},
|
|
293
|
+
required=request_body_def.get("required", True),
|
|
294
|
+
description=request_body_def.get("description"),
|
|
295
|
+
examples=media_type.get("examples"),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _extract_responses(self, operation: Dict[str, Any]) -> List[Response]:
|
|
299
|
+
"""
|
|
300
|
+
Extract responses from an OpenAPI operation
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
operation: OpenAPI operation object
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
List of Response objects
|
|
307
|
+
"""
|
|
308
|
+
responses = []
|
|
309
|
+
responses_def = operation.get("responses", {})
|
|
310
|
+
|
|
311
|
+
for status_code, response_def in responses_def.items():
|
|
312
|
+
# Resolve reference if needed
|
|
313
|
+
response_def = self._resolve_reference(response_def)
|
|
314
|
+
if not response_def:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# Extract content information
|
|
318
|
+
content = response_def.get("content", {})
|
|
319
|
+
content_type = None
|
|
320
|
+
schema = None
|
|
321
|
+
|
|
322
|
+
if content:
|
|
323
|
+
# Prioritize JSON content type
|
|
324
|
+
if application_json_type in content:
|
|
325
|
+
content_type = application_json_type
|
|
326
|
+
media_type = content[application_json_type]
|
|
327
|
+
else:
|
|
328
|
+
content_type = list(content.keys())[0]
|
|
329
|
+
media_type = content[content_type]
|
|
330
|
+
|
|
331
|
+
schema = media_type.get("schema")
|
|
332
|
+
if schema:
|
|
333
|
+
schema = self._resolve_reference(schema)
|
|
334
|
+
|
|
335
|
+
response = Response(
|
|
336
|
+
status_code=status_code,
|
|
337
|
+
description=response_def.get("description", ""),
|
|
338
|
+
content_type=content_type,
|
|
339
|
+
schema=schema,
|
|
340
|
+
headers=response_def.get("headers"),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
responses.append(response)
|
|
344
|
+
|
|
345
|
+
return responses
|
|
346
|
+
|
|
347
|
+
def _resolve_reference(
|
|
348
|
+
self, obj: Union[Dict[str, Any], str]
|
|
349
|
+
) -> Optional[Dict[str, Any]]:
|
|
350
|
+
"""
|
|
351
|
+
Resolve OpenAPI reference ($ref) to actual object
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
obj: Object that might contain a $ref
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Resolved object or None if resolution fails
|
|
358
|
+
"""
|
|
359
|
+
if not isinstance(obj, dict):
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
ref = obj.get("$ref")
|
|
363
|
+
if not ref:
|
|
364
|
+
return obj
|
|
365
|
+
|
|
366
|
+
# Parse reference path (e.g., "#/components/schemas/User")
|
|
367
|
+
if not ref.startswith("#/"):
|
|
368
|
+
logger.warning(f"External references not supported: {ref}")
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
# Split path and navigate through the spec
|
|
373
|
+
path_parts = ref[2:].split("/") # Remove "#/" prefix
|
|
374
|
+
current = self.spec_data
|
|
375
|
+
if current is None:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
for part in path_parts:
|
|
379
|
+
if not isinstance(current, dict):
|
|
380
|
+
return None
|
|
381
|
+
current = current.get(part)
|
|
382
|
+
if current is None:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
return current
|
|
386
|
+
|
|
387
|
+
except (KeyError, TypeError) as e:
|
|
388
|
+
logger.warning(f"Failed to resolve reference {ref}: {e}")
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
def get_schema_info(self) -> Dict[str, Any]:
|
|
392
|
+
"""
|
|
393
|
+
Get basic information about the API
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Dictionary with API information
|
|
397
|
+
"""
|
|
398
|
+
if not self.spec_data:
|
|
399
|
+
return {}
|
|
400
|
+
|
|
401
|
+
info = self.spec_data.get("info", {})
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
"title": info.get("title", "Unknown API"),
|
|
405
|
+
"version": info.get("version", "Unknown"),
|
|
406
|
+
"description": info.get("description", ""),
|
|
407
|
+
"base_url": self._extract_base_url(),
|
|
408
|
+
"security_schemes": self._extract_security_schemes(),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
def _extract_base_url(self) -> str:
|
|
412
|
+
"""Extract base URL from servers section"""
|
|
413
|
+
if not isinstance(self.spec_data, dict):
|
|
414
|
+
return localhost_url
|
|
415
|
+
|
|
416
|
+
servers = self.spec_data.get("servers", [])
|
|
417
|
+
if servers and isinstance(servers[0], dict):
|
|
418
|
+
url = servers[0].get("url", localhost_url)
|
|
419
|
+
if isinstance(url, str):
|
|
420
|
+
return url
|
|
421
|
+
return localhost_url
|
|
422
|
+
|
|
423
|
+
def _extract_security_schemes(self) -> Dict[str, Any]:
|
|
424
|
+
"""Extract security schemes from components"""
|
|
425
|
+
if not isinstance(self.components, dict):
|
|
426
|
+
return {}
|
|
427
|
+
|
|
428
|
+
schemes = self.components.get("securitySchemes", {})
|
|
429
|
+
if isinstance(schemes, dict):
|
|
430
|
+
return schemes
|
|
431
|
+
return {}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from devdox_ai_locust.schemas.processing_result import SwaggerProcessingRequest
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_api_schema(source: SwaggerProcessingRequest) -> Optional[str]:
|
|
13
|
+
"""
|
|
14
|
+
Get API schema content from URL or file path based on source dictionary.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
source (dict): Dictionary containing swagger_source ("url" or "file") and swagger_url/swagger_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Expected source structure:
|
|
21
|
+
{
|
|
22
|
+
"swagger_source": "url", # or "file"
|
|
23
|
+
"swagger_url": "https://api.example.com/swagger.json", # if source is "url"
|
|
24
|
+
"swagger_path": "/path/to/swagger.json" # if source is "file"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Optional[str]: Schema content as string, or None if failed
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If source is invalid or missing required fields
|
|
32
|
+
FileNotFoundError: If file path doesn't exist
|
|
33
|
+
httpx.HTTPError: If URL request fails
|
|
34
|
+
Exception: For other unexpected errors
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
if not source.swagger_url:
|
|
39
|
+
raise ValueError("Missing or empty 'swagger_url'")
|
|
40
|
+
swagger_url = source.swagger_url.strip()
|
|
41
|
+
if not swagger_url:
|
|
42
|
+
raise ValueError("Missing 'swagger_url' for url source")
|
|
43
|
+
return await _fetch_from_url(swagger_url)
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
source_info = getattr(source, "swagger_url", "unknown")
|
|
47
|
+
|
|
48
|
+
logger.error(f"Failed to get API schema from source '{source_info}': {str(e)}")
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _fetch_from_url(url: str) -> str:
|
|
53
|
+
"""Fetch schema content from URL."""
|
|
54
|
+
headers = {
|
|
55
|
+
"User-Agent": "API-Schema-Fetcher/1.0",
|
|
56
|
+
"Accept": "application/json, application/yaml, text/yaml, text/plain, */*",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
60
|
+
try:
|
|
61
|
+
response = await client.get(url, headers=headers)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
|
|
64
|
+
# Check content type
|
|
65
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
66
|
+
logger.info(
|
|
67
|
+
f"Fetching schema from URL: {url}, Content-Type: {content_type}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Read content as text
|
|
71
|
+
content = response.text
|
|
72
|
+
|
|
73
|
+
if not content or not content.strip():
|
|
74
|
+
raise ValueError(f"Empty response from URL: {url}")
|
|
75
|
+
|
|
76
|
+
return content.strip()
|
|
77
|
+
|
|
78
|
+
except httpx.HTTPStatusError as e:
|
|
79
|
+
raise httpx.HTTPError(
|
|
80
|
+
f"HTTP {e.response.status_code}: {e.response.reason_phrase} for URL: {url}"
|
|
81
|
+
)
|
|
82
|
+
except httpx.TimeoutException:
|
|
83
|
+
raise httpx.HTTPError(f"Request timeout after 30s for URL: {url}")
|
|
84
|
+
except httpx.RequestError as e:
|
|
85
|
+
raise httpx.HTTPError(f"Request failed for URL {url}: {str(e)}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sanitize_filename(filename: str) -> str:
|
|
89
|
+
# Remove directory components and sanitize
|
|
90
|
+
clean_name = os.path.basename(filename)
|
|
91
|
+
clean_name = re.sub(r"[^\w\-\.]", "", clean_name)
|
|
92
|
+
if not clean_name or clean_name.startswith("."):
|
|
93
|
+
clean_name = f"generated_{uuid.uuid4().hex[:8]}.py"
|
|
94
|
+
return clean_name
|