mcp-openapi-generator 1.0.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,291 @@
1
+ """Code generation tool using openapi-generator and swagger-codegen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import TypedDict
12
+
13
+ from ..types import GenerationOptions, GenerationResult, GeneratorTarget, SpecInput
14
+ from ..utils.config_inference import infer_project_config
15
+ from ..utils.post_process import post_process_generation
16
+ from ..utils.spec_resolver import resolve_spec
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ __all__ = ["generate_client", "list_generators", "GeneratorWrapper"]
21
+
22
+
23
+ class GeneratorListResult(TypedDict):
24
+ generator: str
25
+ available: bool
26
+ targets: list[str]
27
+
28
+
29
+ # Generator executable names
30
+ OPENAPI_GENERATOR_CLI = "openapi-generator-cli"
31
+ SWAGGER_CODEGEN_CLI = "swagger-codegen"
32
+
33
+
34
+ class GeneratorWrapper:
35
+ """Wrapper for OpenAPI code generators."""
36
+
37
+ def __init__(self, prefer: str = "openapi-generator"):
38
+ self.prefer = prefer
39
+ self._openapi_generator_path = shutil.which(OPENAPI_GENERATOR_CLI)
40
+ self._swagger_codegen_path = shutil.which(SWAGGER_CODEGEN_CLI)
41
+
42
+ @property
43
+ def available(self) -> bool:
44
+ return bool(self._openapi_generator_path or self._swagger_codegen_path)
45
+
46
+ @property
47
+ def generator_name(self) -> str:
48
+ if self._openapi_generator_path and self.prefer == "openapi-generator":
49
+ return "openapi-generator"
50
+ if self._swagger_codegen_path:
51
+ return "swagger-codegen"
52
+ if self._openapi_generator_path:
53
+ return "openapi-generator"
54
+ return "none"
55
+
56
+ @property
57
+ def executable(self) -> str | None:
58
+ if self.generator_name == "openapi-generator":
59
+ return self._openapi_generator_path
60
+ if self.generator_name == "swagger-codegen":
61
+ return self._swagger_codegen_path
62
+ return None
63
+
64
+ def get_supported_targets(self) -> list[str]:
65
+ """Get list of supported generation targets."""
66
+ if not self.executable:
67
+ return []
68
+
69
+ try:
70
+ command = [self.executable, "list"]
71
+ if self.generator_name == "openapi-generator":
72
+ command.append("-s")
73
+ result = subprocess.run(
74
+ command,
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=30,
78
+ )
79
+ if result.returncode == 0:
80
+ if self.generator_name == "openapi-generator":
81
+ return sorted({
82
+ target.strip()
83
+ for target in result.stdout.replace("\n", ",").split(",")
84
+ if target.strip()
85
+ })
86
+
87
+ return [
88
+ line.split()[0]
89
+ for line in result.stdout.splitlines()
90
+ if line.strip() and not line.strip().startswith("Available")
91
+ ]
92
+ except Exception as e:
93
+ logger.warning("Failed to list generators: %s", e)
94
+ return []
95
+
96
+ async def generate(
97
+ self,
98
+ options: GenerationOptions,
99
+ project_root: Path | None = None,
100
+ ) -> GenerationResult:
101
+ """Generate client code using the configured generator."""
102
+ if not self.executable:
103
+ return GenerationResult(
104
+ success=False,
105
+ output_dir=options.output_dir,
106
+ files_created=[],
107
+ target=options.target,
108
+ spec_source=options.spec.location,
109
+ error=(
110
+ "No OpenAPI generator found. Install openapi-generator-cli or swagger-codegen."
111
+ ),
112
+ )
113
+
114
+ # Resolve spec
115
+ try:
116
+ spec = await resolve_spec(options.spec)
117
+ logger.debug("Resolved spec with %d top-level keys", len(spec))
118
+ except Exception as e:
119
+ return GenerationResult(
120
+ success=False,
121
+ output_dir=options.output_dir,
122
+ files_created=[],
123
+ target=options.target,
124
+ spec_source=options.spec.location,
125
+ error=f"Failed to resolve spec: {e}",
126
+ )
127
+
128
+ # Write spec to temp file for generator
129
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
130
+ import yaml
131
+
132
+ yaml.dump(spec, f)
133
+ spec_file = Path(f.name)
134
+
135
+ try:
136
+ return await self._run_generation(options, spec_file, project_root)
137
+ finally:
138
+ spec_file.unlink(missing_ok=True)
139
+
140
+ async def _run_generation(
141
+ self,
142
+ options: GenerationOptions,
143
+ spec_file: Path,
144
+ project_root: Path | None,
145
+ ) -> GenerationResult:
146
+ """Run the generator CLI."""
147
+ executable = self.executable
148
+ if executable is None:
149
+ return GenerationResult(
150
+ success=False,
151
+ output_dir=options.output_dir,
152
+ files_created=[],
153
+ target=options.target,
154
+ spec_source=options.spec.location,
155
+ error="No OpenAPI generator is available.",
156
+ )
157
+
158
+ output_dir = options.output_dir
159
+ output_dir.mkdir(parents=True, exist_ok=True)
160
+
161
+ cmd = [
162
+ executable,
163
+ "generate",
164
+ "-i",
165
+ str(spec_file),
166
+ "-g",
167
+ options.target.value,
168
+ "-o",
169
+ str(output_dir),
170
+ ]
171
+
172
+ # Add optional parameters
173
+ if options.package_name:
174
+ cmd.extend(["--package-name", options.package_name])
175
+ if options.package_version:
176
+ cmd.extend(["--package-version", options.package_version])
177
+
178
+ # Add additional properties
179
+ for key, value in options.additional_properties.items():
180
+ cmd.extend(["--additional-properties", f"{key}={value}"])
181
+
182
+ # Generator-specific options
183
+ if self.generator_name == "openapi-generator":
184
+ if not options.generate_tests:
185
+ cmd.extend(["--global-property", "apiTests=false,modelTests=false"])
186
+ elif self.generator_name == "swagger-codegen":
187
+ # swagger-codegen uses different flags
188
+ pass
189
+
190
+ logger.info("Running generation: %s", " ".join(cmd))
191
+
192
+ try:
193
+ process = await asyncio.create_subprocess_exec(
194
+ *cmd,
195
+ stdout=asyncio.subprocess.PIPE,
196
+ stderr=asyncio.subprocess.PIPE,
197
+ cwd=project_root,
198
+ )
199
+ stdout, stderr = await process.communicate()
200
+
201
+ if process.returncode != 0:
202
+ error_msg = stderr.decode() if stderr else "Unknown error"
203
+ logger.error("Generation failed: %s", error_msg)
204
+ return GenerationResult(
205
+ success=False,
206
+ output_dir=output_dir,
207
+ files_created=[],
208
+ target=options.target,
209
+ spec_source=options.spec.location,
210
+ error=error_msg[:500],
211
+ )
212
+
213
+ # Collect generated files
214
+ files_created = list(output_dir.rglob("*"))
215
+ files_created = [f for f in files_created if f.is_file()]
216
+
217
+ return GenerationResult(
218
+ success=True,
219
+ output_dir=output_dir,
220
+ files_created=files_created,
221
+ target=options.target,
222
+ spec_source=options.spec.location,
223
+ )
224
+
225
+ except Exception as e:
226
+ logger.exception("Generation error")
227
+ return GenerationResult(
228
+ success=False,
229
+ output_dir=output_dir,
230
+ files_created=[],
231
+ target=options.target,
232
+ spec_source=options.spec.location,
233
+ error=str(e),
234
+ )
235
+
236
+
237
+ async def generate_client(
238
+ spec: SpecInput,
239
+ target: GeneratorTarget,
240
+ output_dir: Path,
241
+ package_name: str | None = None,
242
+ package_version: str | None = None,
243
+ additional_properties: dict[str, str] | None = None,
244
+ generate_tests: bool = True,
245
+ generate_docs: bool = False,
246
+ post_process: bool = True,
247
+ project_root: Path | None = None,
248
+ ) -> GenerationResult:
249
+ """
250
+ High-level function to generate a client from an OpenAPI spec.
251
+
252
+ This is the main entry point for the MCP tool.
253
+ """
254
+ # Infer project config for defaults
255
+ if project_root is None:
256
+ project_root = Path.cwd()
257
+ config = infer_project_config(project_root)
258
+
259
+ # Use inferred target if not specified
260
+ if target is None:
261
+ target = GeneratorTarget.from_project_config(config)
262
+
263
+ options = GenerationOptions(
264
+ target=target,
265
+ spec=spec,
266
+ output_dir=output_dir,
267
+ package_name=package_name,
268
+ package_version=package_version,
269
+ additional_properties=additional_properties or {},
270
+ generate_tests=generate_tests,
271
+ generate_docs=generate_docs,
272
+ post_process=post_process,
273
+ )
274
+
275
+ wrapper = GeneratorWrapper()
276
+ result = await wrapper.generate(options, project_root)
277
+
278
+ if result.success and post_process:
279
+ result = await post_process_generation(result, options, config)
280
+
281
+ return result
282
+
283
+
284
+ async def list_generators() -> GeneratorListResult:
285
+ """List available generators and their targets."""
286
+ wrapper = GeneratorWrapper()
287
+ return {
288
+ "generator": wrapper.generator_name,
289
+ "available": wrapper.available,
290
+ "targets": wrapper.get_supported_targets(),
291
+ }
@@ -0,0 +1,277 @@
1
+ """Specification validation tool using Spectral and Vacuum."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import shutil
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from ..types import SpecInput, ValidationOptions, ValidationResult, ValidationTool
12
+ from ..utils.spec_resolver import resolve_spec
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ __all__ = ["validate_spec", "ValidationWrapper"]
17
+
18
+ # Validator executable names
19
+ SPECTRAL_CLI = "spectral"
20
+ VACUUM_CLI = "vacuum"
21
+
22
+
23
+ class ValidationWrapper:
24
+ """Wrapper for OpenAPI validation tools."""
25
+
26
+ def __init__(self) -> None:
27
+ self._spectral_path = shutil.which(SPECTRAL_CLI)
28
+ self._vacuum_path = shutil.which(VACUUM_CLI)
29
+
30
+ @property
31
+ def available_tools(self) -> list[ValidationTool]:
32
+ tools = []
33
+ if self._spectral_path:
34
+ tools.append(ValidationTool.SPECTRAL)
35
+ if self._vacuum_path:
36
+ tools.append(ValidationTool.VACUUM)
37
+ return tools
38
+
39
+ @property
40
+ def preferred_tool(self) -> ValidationTool:
41
+ if ValidationTool.SPECTRAL in self.available_tools:
42
+ return ValidationTool.SPECTRAL
43
+ if ValidationTool.VACUUM in self.available_tools:
44
+ return ValidationTool.VACUUM
45
+ return ValidationTool.SPECTRAL # default
46
+
47
+ def get_executable(self, tool: ValidationTool) -> str | None:
48
+ if tool == ValidationTool.SPECTRAL:
49
+ return self._spectral_path
50
+ if tool == ValidationTool.VACUUM:
51
+ return self._vacuum_path
52
+ return None
53
+
54
+ async def validate(
55
+ self,
56
+ options: ValidationOptions,
57
+ ) -> ValidationResult:
58
+ """Validate an OpenAPI specification."""
59
+ tool = options.tool or self.preferred_tool
60
+ executable = self.get_executable(tool)
61
+
62
+ if not executable:
63
+ return ValidationResult(
64
+ success=False,
65
+ valid=False,
66
+ tool=tool,
67
+ spec_source=options.spec.location,
68
+ error=f"Validator {tool.value} not found. Install spectral or vacuum.",
69
+ )
70
+
71
+ # Resolve spec
72
+ try:
73
+ spec = await resolve_spec(options.spec)
74
+ except Exception as e:
75
+ return ValidationResult(
76
+ success=False,
77
+ valid=False,
78
+ tool=tool,
79
+ spec_source=options.spec.location,
80
+ error=f"Failed to resolve spec: {e}",
81
+ )
82
+
83
+ # Write spec to temp file
84
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
85
+ import yaml
86
+
87
+ yaml.dump(spec, f)
88
+ spec_file = Path(f.name)
89
+
90
+ try:
91
+ return await self._run_validation(executable, tool, spec_file, options)
92
+ finally:
93
+ spec_file.unlink(missing_ok=True)
94
+
95
+ async def _run_validation(
96
+ self,
97
+ executable: str,
98
+ tool: ValidationTool,
99
+ spec_file: Path,
100
+ options: ValidationOptions,
101
+ ) -> ValidationResult:
102
+ """Run the validation CLI."""
103
+ if tool == ValidationTool.SPECTRAL:
104
+ cmd = [executable, "lint", str(spec_file), "--format", "json"]
105
+ if options.ruleset:
106
+ cmd.extend(["--ruleset", options.ruleset])
107
+ elif tool == ValidationTool.VACUUM:
108
+ cmd = [executable, "lint", str(spec_file), "-format", "json"]
109
+ if options.ruleset:
110
+ cmd.extend(["-ruleset", options.ruleset])
111
+ else:
112
+ return ValidationResult(
113
+ success=False,
114
+ valid=False,
115
+ tool=tool,
116
+ spec_source=options.spec.location,
117
+ error=f"Unknown validation tool: {tool}",
118
+ )
119
+
120
+ logger.info("Running validation: %s", " ".join(cmd))
121
+
122
+ try:
123
+ process = await asyncio.create_subprocess_exec(
124
+ *cmd,
125
+ stdout=asyncio.subprocess.PIPE,
126
+ stderr=asyncio.subprocess.PIPE,
127
+ )
128
+ stdout, stderr = await process.communicate()
129
+
130
+ # Parse results
131
+ if tool == ValidationTool.SPECTRAL:
132
+ return self._parse_spectral_output(
133
+ stdout.decode() if stdout else "[]",
134
+ options.spec.location,
135
+ options.fail_on_warnings,
136
+ )
137
+ if tool == ValidationTool.VACUUM:
138
+ return self._parse_vacuum_output(
139
+ stdout.decode() if stdout else "[]",
140
+ options.spec.location,
141
+ options.fail_on_warnings,
142
+ )
143
+
144
+ except Exception as e:
145
+ logger.exception("Validation error")
146
+ return ValidationResult(
147
+ success=False,
148
+ valid=False,
149
+ tool=tool,
150
+ spec_source=options.spec.location,
151
+ error=str(e),
152
+ )
153
+
154
+ def _parse_spectral_output(
155
+ self,
156
+ stdout: str,
157
+ spec_source: str,
158
+ fail_on_warnings: bool = False,
159
+ ) -> ValidationResult:
160
+ """Parse Spectral JSON output."""
161
+ import json
162
+
163
+ try:
164
+ results = json.loads(stdout) if stdout.strip() else []
165
+ except json.JSONDecodeError:
166
+ return ValidationResult(
167
+ success=False,
168
+ valid=False,
169
+ tool=ValidationTool.SPECTRAL,
170
+ spec_source=spec_source,
171
+ error=f"Failed to parse Spectral output: {stdout[:200]}",
172
+ )
173
+
174
+ errors = []
175
+ warnings = []
176
+
177
+ for result in results:
178
+ severity_value = result.get("severity", "error")
179
+ if isinstance(severity_value, int):
180
+ severity = {0: "error", 1: "warning", 2: "info", 3: "hint"}.get(
181
+ severity_value, "error"
182
+ )
183
+ else:
184
+ severity = str(severity_value).lower()
185
+ message = {
186
+ "code": result.get("code", ""),
187
+ "message": result.get("message", ""),
188
+ "path": result.get("path", ""),
189
+ "range": result.get("range", {}),
190
+ "severity": severity,
191
+ }
192
+ if severity == "error":
193
+ errors.append(message)
194
+ else:
195
+ warnings.append(message)
196
+
197
+ valid = len(errors) == 0
198
+ if fail_on_warnings:
199
+ valid = valid and len(warnings) == 0
200
+
201
+ return ValidationResult(
202
+ success=True,
203
+ valid=valid,
204
+ tool=ValidationTool.SPECTRAL,
205
+ spec_source=spec_source,
206
+ errors=errors,
207
+ warnings=warnings,
208
+ )
209
+
210
+ def _parse_vacuum_output(
211
+ self,
212
+ stdout: str,
213
+ spec_source: str,
214
+ fail_on_warnings: bool = False,
215
+ ) -> ValidationResult:
216
+ """Parse Vacuum JSON output."""
217
+ import json
218
+
219
+ try:
220
+ results = json.loads(stdout) if stdout.strip() else []
221
+ except json.JSONDecodeError:
222
+ return ValidationResult(
223
+ success=False,
224
+ valid=False,
225
+ tool=ValidationTool.VACUUM,
226
+ spec_source=spec_source,
227
+ error=f"Failed to parse Vacuum output: {stdout[:200]}",
228
+ )
229
+
230
+ errors = []
231
+ warnings = []
232
+
233
+ for result in results:
234
+ # Vacuum output format differs slightly
235
+ severity = result.get("severity", "error").lower()
236
+ message = {
237
+ "code": result.get("code", ""),
238
+ "message": result.get("message", ""),
239
+ "path": result.get("path", ""),
240
+ "line": result.get("line", 0),
241
+ "severity": severity,
242
+ }
243
+ if severity == "error":
244
+ errors.append(message)
245
+ else:
246
+ warnings.append(message)
247
+
248
+ valid = len(errors) == 0
249
+ if fail_on_warnings:
250
+ valid = valid and len(warnings) == 0
251
+
252
+ return ValidationResult(
253
+ success=True,
254
+ valid=valid,
255
+ tool=ValidationTool.VACUUM,
256
+ spec_source=spec_source,
257
+ errors=errors,
258
+ warnings=warnings,
259
+ )
260
+
261
+
262
+ async def validate_spec(
263
+ spec: SpecInput,
264
+ tool: ValidationTool = ValidationTool.SPECTRAL,
265
+ ruleset: str | None = None,
266
+ fail_on_warnings: bool = False,
267
+ ) -> ValidationResult:
268
+ """High-level function to validate an OpenAPI specification."""
269
+ options = ValidationOptions(
270
+ spec=spec,
271
+ tool=tool,
272
+ ruleset=ruleset,
273
+ fail_on_warnings=fail_on_warnings,
274
+ )
275
+
276
+ wrapper = ValidationWrapper()
277
+ return await wrapper.validate(options)