openapi2cli 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.
- openapi2cli/__init__.py +17 -0
- openapi2cli/__main__.py +6 -0
- openapi2cli/cli.py +117 -0
- openapi2cli/generator.py +430 -0
- openapi2cli/parser.py +325 -0
- openapi2cli/runtime.py +170 -0
- openapi2cli-0.1.0.dist-info/METADATA +267 -0
- openapi2cli-0.1.0.dist-info/RECORD +11 -0
- openapi2cli-0.1.0.dist-info/WHEEL +4 -0
- openapi2cli-0.1.0.dist-info/entry_points.txt +2 -0
- openapi2cli-0.1.0.dist-info/licenses/LICENSE +21 -0
openapi2cli/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""api2cli - Generate CLI tools from OpenAPI specs."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .generator import CLIGenerator, GeneratedCLI
|
|
6
|
+
from .parser import Endpoint, OpenAPIParser, Parameter, ParsedSpec
|
|
7
|
+
from .runtime import APIClient
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"OpenAPIParser",
|
|
11
|
+
"ParsedSpec",
|
|
12
|
+
"Endpoint",
|
|
13
|
+
"Parameter",
|
|
14
|
+
"CLIGenerator",
|
|
15
|
+
"GeneratedCLI",
|
|
16
|
+
"APIClient",
|
|
17
|
+
]
|
openapi2cli/__main__.py
ADDED
openapi2cli/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Main CLI for openapi2cli."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .generator import CLIGenerator
|
|
10
|
+
from .parser import OpenAPIParser
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(version=__version__)
|
|
15
|
+
def main():
|
|
16
|
+
"""openapi2cli - Generate CLI tools from OpenAPI specs.
|
|
17
|
+
|
|
18
|
+
Built for AI agents who need to interact with APIs.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
|
|
22
|
+
# Generate a CLI from a spec
|
|
23
|
+
openapi2cli generate https://httpbin.org/spec.json --name httpbin
|
|
24
|
+
|
|
25
|
+
# Then use it
|
|
26
|
+
./httpbin get --output json
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@main.command()
|
|
32
|
+
@click.argument("spec", type=str)
|
|
33
|
+
@click.option("--name", "-n", required=True, help="Name for the generated CLI")
|
|
34
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
|
35
|
+
@click.option("--stdout", is_flag=True, help="Print to stdout instead of file")
|
|
36
|
+
def generate(spec: str, name: str, output: str, stdout: bool):
|
|
37
|
+
"""Generate a CLI from an OpenAPI spec.
|
|
38
|
+
|
|
39
|
+
SPEC can be a file path or URL to an OpenAPI 3.x specification.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
|
|
43
|
+
openapi2cli generate petstore.yaml --name petstore
|
|
44
|
+
openapi2cli generate https://api.example.com/openapi.json --name example -o example_cli.py
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
# Parse the spec
|
|
48
|
+
parser = OpenAPIParser()
|
|
49
|
+
parsed = parser.parse(spec)
|
|
50
|
+
|
|
51
|
+
click.echo(f"Parsed: {parsed.title} v{parsed.version}", err=True)
|
|
52
|
+
click.echo(f"Found {len(parsed.endpoints)} endpoints", err=True)
|
|
53
|
+
|
|
54
|
+
# Generate CLI
|
|
55
|
+
generator = CLIGenerator()
|
|
56
|
+
cli = generator.generate(parsed, name=name)
|
|
57
|
+
|
|
58
|
+
click.echo(f"Generated {len(cli.groups)} command groups", err=True)
|
|
59
|
+
|
|
60
|
+
# Output
|
|
61
|
+
if stdout:
|
|
62
|
+
click.echo(cli.to_python())
|
|
63
|
+
else:
|
|
64
|
+
output_path = Path(output) if output else Path(f"{name}_cli.py")
|
|
65
|
+
cli.save(output_path)
|
|
66
|
+
click.echo(f"Saved to: {output_path}", err=True)
|
|
67
|
+
click.echo(f"\nUsage: python {output_path} --help", err=True)
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
click.echo(f"Error: {e}", err=True)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@main.command()
|
|
75
|
+
@click.argument("spec", type=str)
|
|
76
|
+
def inspect(spec: str):
|
|
77
|
+
"""Inspect an OpenAPI spec without generating code.
|
|
78
|
+
|
|
79
|
+
Shows the structure of the API: endpoints, parameters, auth, etc.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
parser = OpenAPIParser()
|
|
83
|
+
parsed = parser.parse(spec)
|
|
84
|
+
|
|
85
|
+
click.echo(f"\nš {parsed.title} v{parsed.version}")
|
|
86
|
+
click.echo(f" {parsed.description[:100]}..." if len(parsed.description) > 100 else f" {parsed.description}")
|
|
87
|
+
click.echo(f"\nš Base URL: {parsed.base_url}")
|
|
88
|
+
|
|
89
|
+
# Auth schemes
|
|
90
|
+
if parsed.auth_schemes:
|
|
91
|
+
click.echo("\nš Authentication:")
|
|
92
|
+
for scheme in parsed.auth_schemes:
|
|
93
|
+
click.echo(f" - {scheme.name}: {scheme.type}")
|
|
94
|
+
|
|
95
|
+
# Endpoints by tag
|
|
96
|
+
grouped = parsed.group_by_tag()
|
|
97
|
+
click.echo(f"\nš” Endpoints ({len(parsed.endpoints)} total):")
|
|
98
|
+
|
|
99
|
+
for tag, endpoints in sorted(grouped.items()):
|
|
100
|
+
click.echo(f"\n [{tag}]")
|
|
101
|
+
for ep in endpoints[:5]: # Show first 5
|
|
102
|
+
params = ", ".join(p.name for p in ep.parameters[:3])
|
|
103
|
+
if len(ep.parameters) > 3:
|
|
104
|
+
params += "..."
|
|
105
|
+
click.echo(f" ⢠{ep.method:6} {ep.path}")
|
|
106
|
+
if params:
|
|
107
|
+
click.echo(f" params: {params}")
|
|
108
|
+
if len(endpoints) > 5:
|
|
109
|
+
click.echo(f" ... and {len(endpoints) - 5} more")
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
click.echo(f"Error: {e}", err=True)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
openapi2cli/generator.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""CLI code generator."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from jinja2 import Template
|
|
9
|
+
|
|
10
|
+
from .parser import AuthScheme, Endpoint, ParsedSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CLIOption:
|
|
15
|
+
"""A CLI option/argument."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
param_type: str = "str"
|
|
19
|
+
required: bool = False
|
|
20
|
+
default: Optional[str] = None
|
|
21
|
+
help: str = ""
|
|
22
|
+
is_flag: bool = False
|
|
23
|
+
multiple: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CLICommand:
|
|
28
|
+
"""A CLI command."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
method: str
|
|
32
|
+
path: str
|
|
33
|
+
help: str = ""
|
|
34
|
+
options: List[CLIOption] = field(default_factory=list)
|
|
35
|
+
has_body: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CLIGroup:
|
|
40
|
+
"""A group of CLI commands (tag)."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
help: str = ""
|
|
44
|
+
commands: List[CLICommand] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class GeneratedCLI:
|
|
49
|
+
"""A generated CLI structure."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
version: str = "1.0.0"
|
|
53
|
+
description: str = ""
|
|
54
|
+
base_url: str = ""
|
|
55
|
+
groups: List[CLIGroup] = field(default_factory=list)
|
|
56
|
+
global_options: List[CLIOption] = field(default_factory=list)
|
|
57
|
+
auth_schemes: List[AuthScheme] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
def to_python(self) -> str:
|
|
60
|
+
"""Generate Python code for the CLI."""
|
|
61
|
+
return CLI_TEMPLATE.render(cli=self)
|
|
62
|
+
|
|
63
|
+
def to_standalone_script(self) -> str:
|
|
64
|
+
"""Generate a standalone executable script."""
|
|
65
|
+
code = self.to_python()
|
|
66
|
+
return f"#!/usr/bin/env python3\n{code}"
|
|
67
|
+
|
|
68
|
+
def save(self, path: Union[Path, str]) -> None:
|
|
69
|
+
"""Save the generated CLI to a file."""
|
|
70
|
+
path = Path(path)
|
|
71
|
+
code = self.to_standalone_script()
|
|
72
|
+
path.write_text(code)
|
|
73
|
+
# Make executable
|
|
74
|
+
path.chmod(0o755)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CLIGenerator:
|
|
78
|
+
"""Generates CLI code from a parsed OpenAPI spec."""
|
|
79
|
+
|
|
80
|
+
def generate(self, spec: ParsedSpec, name: str) -> GeneratedCLI:
|
|
81
|
+
"""Generate a CLI from a parsed spec."""
|
|
82
|
+
# Generate global options (auth, output format, base URL)
|
|
83
|
+
global_options = self._generate_global_options(spec)
|
|
84
|
+
|
|
85
|
+
# Group endpoints by tag and generate command groups
|
|
86
|
+
grouped = spec.group_by_tag()
|
|
87
|
+
groups = []
|
|
88
|
+
|
|
89
|
+
for tag, endpoints in grouped.items():
|
|
90
|
+
group = self._generate_group(tag, endpoints)
|
|
91
|
+
groups.append(group)
|
|
92
|
+
|
|
93
|
+
return GeneratedCLI(
|
|
94
|
+
name=name,
|
|
95
|
+
version=spec.version,
|
|
96
|
+
description=spec.description or f"CLI for {spec.title}",
|
|
97
|
+
base_url=spec.base_url,
|
|
98
|
+
groups=groups,
|
|
99
|
+
global_options=global_options,
|
|
100
|
+
auth_schemes=spec.auth_schemes,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _generate_global_options(self, spec: ParsedSpec) -> List[CLIOption]:
|
|
104
|
+
"""Generate global CLI options."""
|
|
105
|
+
options = [
|
|
106
|
+
CLIOption(
|
|
107
|
+
name="--output",
|
|
108
|
+
param_type="str",
|
|
109
|
+
default="table",
|
|
110
|
+
help="Output format (json, table, raw)",
|
|
111
|
+
),
|
|
112
|
+
CLIOption(
|
|
113
|
+
name="--base-url",
|
|
114
|
+
param_type="str",
|
|
115
|
+
default=spec.base_url,
|
|
116
|
+
help="API base URL",
|
|
117
|
+
),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# Add auth options based on security schemes
|
|
121
|
+
for scheme in spec.auth_schemes:
|
|
122
|
+
if scheme.type == "apiKey":
|
|
123
|
+
options.append(CLIOption(
|
|
124
|
+
name="--api-key",
|
|
125
|
+
param_type="str",
|
|
126
|
+
help=f"API key ({scheme.param_name})",
|
|
127
|
+
))
|
|
128
|
+
elif scheme.type == "http" and scheme.scheme == "bearer":
|
|
129
|
+
options.append(CLIOption(
|
|
130
|
+
name="--token",
|
|
131
|
+
param_type="str",
|
|
132
|
+
help="Bearer token for authentication",
|
|
133
|
+
))
|
|
134
|
+
|
|
135
|
+
# Default auth option if no schemes defined
|
|
136
|
+
if not any(o.name in ("--api-key", "--token") for o in options):
|
|
137
|
+
options.append(CLIOption(
|
|
138
|
+
name="--api-key",
|
|
139
|
+
param_type="str",
|
|
140
|
+
help="API key for authentication",
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
return options
|
|
144
|
+
|
|
145
|
+
def _generate_group(self, tag: str, endpoints: List[Endpoint]) -> CLIGroup:
|
|
146
|
+
"""Generate a command group from a tag."""
|
|
147
|
+
commands = []
|
|
148
|
+
|
|
149
|
+
for endpoint in endpoints:
|
|
150
|
+
cmd = self._generate_command(endpoint)
|
|
151
|
+
commands.append(cmd)
|
|
152
|
+
|
|
153
|
+
return CLIGroup(
|
|
154
|
+
name=self._sanitize_name(tag),
|
|
155
|
+
help=f"Commands for {tag}",
|
|
156
|
+
commands=commands,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _generate_command(self, endpoint: Endpoint) -> CLICommand:
|
|
160
|
+
"""Generate a CLI command from an endpoint."""
|
|
161
|
+
options = []
|
|
162
|
+
seen_names = set()
|
|
163
|
+
|
|
164
|
+
def add_option(opt: CLIOption) -> None:
|
|
165
|
+
"""Add option if name not already used."""
|
|
166
|
+
if opt.name not in seen_names:
|
|
167
|
+
seen_names.add(opt.name)
|
|
168
|
+
options.append(opt)
|
|
169
|
+
|
|
170
|
+
# Add options for parameters
|
|
171
|
+
for param in endpoint.parameters:
|
|
172
|
+
add_option(CLIOption(
|
|
173
|
+
name=param.cli_name,
|
|
174
|
+
param_type=self._map_type(param.schema_type),
|
|
175
|
+
required=param.required,
|
|
176
|
+
default=str(param.default) if param.default is not None else None,
|
|
177
|
+
help=param.description or f"{param.name} parameter",
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
# Add options for request body properties
|
|
181
|
+
has_body = False
|
|
182
|
+
if endpoint.request_body:
|
|
183
|
+
has_body = True
|
|
184
|
+
for prop_name, prop_schema in endpoint.request_body.properties.items():
|
|
185
|
+
required = prop_name in endpoint.request_body.required_props
|
|
186
|
+
add_option(CLIOption(
|
|
187
|
+
name=f"--{self._sanitize_name(prop_name)}",
|
|
188
|
+
param_type=self._map_type(prop_schema.get('type', 'string')),
|
|
189
|
+
required=required,
|
|
190
|
+
help=prop_schema.get('description', f"{prop_name} field"),
|
|
191
|
+
))
|
|
192
|
+
|
|
193
|
+
# Also add a --data option for raw JSON input
|
|
194
|
+
add_option(CLIOption(
|
|
195
|
+
name="--data",
|
|
196
|
+
param_type="str",
|
|
197
|
+
help="Raw JSON data for request body",
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
return CLICommand(
|
|
201
|
+
name=endpoint.cli_name,
|
|
202
|
+
method=endpoint.method,
|
|
203
|
+
path=endpoint.path,
|
|
204
|
+
help=endpoint.summary or endpoint.description or f"{endpoint.method} {endpoint.path}",
|
|
205
|
+
options=options,
|
|
206
|
+
has_body=has_body,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _sanitize_name(self, name: str) -> str:
|
|
210
|
+
"""Sanitize a name for use as a CLI command/option."""
|
|
211
|
+
# Convert camelCase to kebab-case
|
|
212
|
+
name = re.sub(r'([a-z])([A-Z])', r'\1-\2', name)
|
|
213
|
+
# Replace underscores, spaces, dots with hyphens
|
|
214
|
+
name = name.replace('_', '-').replace(' ', '-').replace('.', '-')
|
|
215
|
+
# Remove invalid characters
|
|
216
|
+
name = re.sub(r'[^a-zA-Z0-9-]', '', name)
|
|
217
|
+
# Remove consecutive hyphens
|
|
218
|
+
name = re.sub(r'-+', '-', name)
|
|
219
|
+
# Remove leading/trailing hyphens
|
|
220
|
+
name = name.strip('-')
|
|
221
|
+
return name.lower()
|
|
222
|
+
|
|
223
|
+
def _map_type(self, schema_type: str) -> str:
|
|
224
|
+
"""Map OpenAPI type to Python/Click type."""
|
|
225
|
+
mapping = {
|
|
226
|
+
'integer': 'int',
|
|
227
|
+
'number': 'float',
|
|
228
|
+
'boolean': 'bool',
|
|
229
|
+
'array': 'str', # JSON string for arrays
|
|
230
|
+
'object': 'str', # JSON string for objects
|
|
231
|
+
}
|
|
232
|
+
return mapping.get(schema_type, 'str')
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Template for generated CLI - use raw strings to avoid escaping issues
|
|
236
|
+
CLI_TEMPLATE_STR = '''
|
|
237
|
+
"""{{ cli.name }} - Generated CLI for {{ cli.description }}
|
|
238
|
+
|
|
239
|
+
Auto-generated by openapi2cli. Do not edit manually.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
import json
|
|
243
|
+
import os
|
|
244
|
+
import sys
|
|
245
|
+
from typing import Optional
|
|
246
|
+
|
|
247
|
+
import click
|
|
248
|
+
import requests
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
from rich.console import Console
|
|
252
|
+
from rich.table import Table
|
|
253
|
+
RICH_AVAILABLE = True
|
|
254
|
+
except ImportError:
|
|
255
|
+
RICH_AVAILABLE = False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# Configuration
|
|
259
|
+
BASE_URL = "{{ cli.base_url }}"
|
|
260
|
+
ENV_PREFIX = "{{ cli.name.upper().replace('-', '_') }}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_auth_headers(api_key: Optional[str] = None, token: Optional[str] = None) -> dict:
|
|
264
|
+
"""Get authentication headers."""
|
|
265
|
+
headers = {}
|
|
266
|
+
|
|
267
|
+
# Try CLI args first, then env vars
|
|
268
|
+
key = api_key or os.environ.get(ENV_PREFIX + "_API_KEY")
|
|
269
|
+
tok = token or os.environ.get(ENV_PREFIX + "_TOKEN")
|
|
270
|
+
|
|
271
|
+
if tok:
|
|
272
|
+
headers["Authorization"] = "Bearer " + tok
|
|
273
|
+
elif key:
|
|
274
|
+
{%- for scheme in cli.auth_schemes %}
|
|
275
|
+
{%- if scheme.type == "apiKey" and scheme.location == "header" %}
|
|
276
|
+
headers["{{ scheme.param_name }}"] = key
|
|
277
|
+
{%- endif %}
|
|
278
|
+
{%- endfor %}
|
|
279
|
+
{%- if not cli.auth_schemes %}
|
|
280
|
+
headers["X-API-Key"] = key
|
|
281
|
+
{%- endif %}
|
|
282
|
+
|
|
283
|
+
return headers
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def format_output(data, output_format: str):
|
|
287
|
+
"""Format output based on requested format."""
|
|
288
|
+
if output_format == "json":
|
|
289
|
+
click.echo(json.dumps(data, indent=2))
|
|
290
|
+
elif output_format == "raw":
|
|
291
|
+
click.echo(data)
|
|
292
|
+
elif output_format == "table" and RICH_AVAILABLE:
|
|
293
|
+
console = Console()
|
|
294
|
+
if isinstance(data, list) and data:
|
|
295
|
+
table = Table()
|
|
296
|
+
first = data[0]
|
|
297
|
+
if isinstance(first, dict):
|
|
298
|
+
for key in first.keys():
|
|
299
|
+
table.add_column(str(key))
|
|
300
|
+
for item in data:
|
|
301
|
+
if isinstance(item, dict):
|
|
302
|
+
table.add_row(*[str(v) for v in item.values()])
|
|
303
|
+
else:
|
|
304
|
+
table.add_column("value")
|
|
305
|
+
for item in data:
|
|
306
|
+
table.add_row(str(item))
|
|
307
|
+
console.print(table)
|
|
308
|
+
elif isinstance(data, dict):
|
|
309
|
+
table = Table()
|
|
310
|
+
table.add_column("Key")
|
|
311
|
+
table.add_column("Value")
|
|
312
|
+
for k, v in data.items():
|
|
313
|
+
table.add_row(str(k), str(v))
|
|
314
|
+
console.print(table)
|
|
315
|
+
else:
|
|
316
|
+
console.print(data)
|
|
317
|
+
else:
|
|
318
|
+
click.echo(json.dumps(data, indent=2))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def make_request(
|
|
322
|
+
method: str,
|
|
323
|
+
path: str,
|
|
324
|
+
base_url: str,
|
|
325
|
+
params: dict = None,
|
|
326
|
+
json_data: dict = None,
|
|
327
|
+
headers: dict = None,
|
|
328
|
+
path_params: dict = None,
|
|
329
|
+
):
|
|
330
|
+
"""Make an HTTP request to the API."""
|
|
331
|
+
if path_params:
|
|
332
|
+
for key, value in path_params.items():
|
|
333
|
+
path = path.replace("{" + key + "}", str(value))
|
|
334
|
+
|
|
335
|
+
url = base_url.rstrip("/") + path
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
response = requests.request(
|
|
339
|
+
method=method,
|
|
340
|
+
url=url,
|
|
341
|
+
params=params,
|
|
342
|
+
json=json_data,
|
|
343
|
+
headers=headers,
|
|
344
|
+
timeout=30,
|
|
345
|
+
)
|
|
346
|
+
response.raise_for_status()
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
return response.json()
|
|
350
|
+
except json.JSONDecodeError:
|
|
351
|
+
return response.text
|
|
352
|
+
|
|
353
|
+
except requests.exceptions.RequestException as e:
|
|
354
|
+
click.echo("Error: " + str(e), err=True)
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@click.group()
|
|
359
|
+
@click.option("--output", "-o", default="json", help="Output format (json, table, raw)")
|
|
360
|
+
@click.option("--base-url", default=BASE_URL, help="API base URL")
|
|
361
|
+
@click.option("--api-key", envvar=ENV_PREFIX + "_API_KEY", help="API key")
|
|
362
|
+
@click.option("--token", envvar=ENV_PREFIX + "_TOKEN", help="Bearer token")
|
|
363
|
+
@click.version_option(version="{{ cli.version }}")
|
|
364
|
+
@click.pass_context
|
|
365
|
+
def cli(ctx, output, base_url, api_key, token):
|
|
366
|
+
"""{{ cli.description }}"""
|
|
367
|
+
ctx.ensure_object(dict)
|
|
368
|
+
ctx.obj["output"] = output
|
|
369
|
+
ctx.obj["base_url"] = base_url
|
|
370
|
+
ctx.obj["headers"] = get_auth_headers(api_key, token)
|
|
371
|
+
|
|
372
|
+
{% for group in cli.groups %}
|
|
373
|
+
|
|
374
|
+
@cli.group()
|
|
375
|
+
def {{ group.name | replace("-", "_") }}():
|
|
376
|
+
"""{{ group.help }}"""
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
{% for cmd in group.commands %}
|
|
380
|
+
|
|
381
|
+
@{{ group.name | replace("-", "_") }}.command("{{ cmd.name }}")
|
|
382
|
+
{%- for opt in cmd.options %}
|
|
383
|
+
@click.option("{{ opt.name }}"{% if opt.required %}, required=True{% endif %}{% if opt.default %}, default="{{ opt.default }}"{% endif %}, help="{{ opt.help | replace('"', '\\"') }}")
|
|
384
|
+
{%- endfor %}
|
|
385
|
+
@click.pass_context
|
|
386
|
+
def {{ group.name | replace("-", "_") | replace(".", "_") }}_{{ cmd.name | replace("-", "_") | replace(".", "_") }}(ctx{% for opt in cmd.options %}, {{ opt.name | replace("--", "") | replace("-", "_") | replace(".", "_") }}{% endfor %}):
|
|
387
|
+
"""{{ cmd.help | replace('"', '\\"') }}"""
|
|
388
|
+
path_params = {}
|
|
389
|
+
query_params = {}
|
|
390
|
+
body_data = {}
|
|
391
|
+
|
|
392
|
+
{%- for opt in cmd.options %}
|
|
393
|
+
{%- set var_name = opt.name | replace("--", "") | replace("-", "_") %}
|
|
394
|
+
if {{ var_name }} is not None:
|
|
395
|
+
{%- if "id" in opt.name.lower() and "{" in cmd.path %}
|
|
396
|
+
path_params["{{ opt.name | replace("--", "") | replace("-", "") }}"] = {{ var_name }}
|
|
397
|
+
{%- elif opt.name == "--data" %}
|
|
398
|
+
body_data = json.loads({{ var_name }})
|
|
399
|
+
{%- elif cmd.has_body and opt.name != "--data" %}
|
|
400
|
+
body_data["{{ opt.name | replace("--", "") | replace("-", "_") }}"] = {{ var_name }}
|
|
401
|
+
{%- else %}
|
|
402
|
+
query_params["{{ opt.name | replace("--", "") }}"] = {{ var_name }}
|
|
403
|
+
{%- endif %}
|
|
404
|
+
{%- endfor %}
|
|
405
|
+
|
|
406
|
+
result = make_request(
|
|
407
|
+
method="{{ cmd.method }}",
|
|
408
|
+
path="{{ cmd.path }}",
|
|
409
|
+
base_url=ctx.obj["base_url"],
|
|
410
|
+
params=query_params or None,
|
|
411
|
+
json_data=body_data or None,
|
|
412
|
+
headers=ctx.obj["headers"],
|
|
413
|
+
path_params=path_params or None,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
format_output(result, ctx.obj["output"])
|
|
417
|
+
{% endfor %}
|
|
418
|
+
{% endfor %}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main():
|
|
422
|
+
"""Main entry point."""
|
|
423
|
+
cli()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
main()
|
|
428
|
+
'''
|
|
429
|
+
|
|
430
|
+
CLI_TEMPLATE = Template(CLI_TEMPLATE_STR)
|
openapi2cli/parser.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""OpenAPI spec parser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Parameter:
|
|
15
|
+
"""An API endpoint parameter."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
location: str # path, query, header, cookie
|
|
19
|
+
required: bool = False
|
|
20
|
+
description: str = ""
|
|
21
|
+
schema_type: str = "string"
|
|
22
|
+
default: Any = None
|
|
23
|
+
enum: List[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def cli_name(self) -> str:
|
|
27
|
+
"""Convert to CLI option name."""
|
|
28
|
+
# petId -> pet-id, api_key -> api-key
|
|
29
|
+
name = re.sub(r'([a-z])([A-Z])', r'\1-\2', self.name)
|
|
30
|
+
name = name.replace('_', '-').lower()
|
|
31
|
+
return f"--{name}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RequestBody:
|
|
36
|
+
"""Request body schema."""
|
|
37
|
+
|
|
38
|
+
content_type: str = "application/json"
|
|
39
|
+
required: bool = False
|
|
40
|
+
properties: Dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
required_props: List[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AuthScheme:
|
|
46
|
+
"""Authentication scheme."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
type: str # apiKey, http, oauth2, openIdConnect
|
|
50
|
+
location: str = "" # header, query, cookie (for apiKey)
|
|
51
|
+
scheme: str = "" # bearer, basic (for http)
|
|
52
|
+
param_name: str = "" # name of the header/query param
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Endpoint:
|
|
57
|
+
"""An API endpoint."""
|
|
58
|
+
|
|
59
|
+
path: str
|
|
60
|
+
method: str
|
|
61
|
+
operation_id: str = ""
|
|
62
|
+
summary: str = ""
|
|
63
|
+
description: str = ""
|
|
64
|
+
tags: List[str] = field(default_factory=list)
|
|
65
|
+
parameters: List[Parameter] = field(default_factory=list)
|
|
66
|
+
request_body: Optional[RequestBody] = None
|
|
67
|
+
security: List[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def cli_name(self) -> str:
|
|
71
|
+
"""Generate CLI command name."""
|
|
72
|
+
if self.operation_id:
|
|
73
|
+
# getPetById -> get-pet-by-id -> get (simplified)
|
|
74
|
+
name = re.sub(r'([a-z])([A-Z])', r'\1-\2', self.operation_id)
|
|
75
|
+
name = name.lower()
|
|
76
|
+
|
|
77
|
+
# Simplify common patterns
|
|
78
|
+
# getPetById -> get, listPets -> list, addPet -> add
|
|
79
|
+
parts = name.split('-')
|
|
80
|
+
if len(parts) >= 2:
|
|
81
|
+
# Keep first part and maybe second if it's meaningful
|
|
82
|
+
verb = parts[0]
|
|
83
|
+
if verb in ('get', 'list', 'find', 'add', 'create', 'update', 'delete', 'remove'):
|
|
84
|
+
if len(parts) > 2 and parts[-1] not in ('by', 'all'):
|
|
85
|
+
# get-pet-by-id -> get-by-id
|
|
86
|
+
return '-'.join([verb] + parts[2:])
|
|
87
|
+
return verb
|
|
88
|
+
return name
|
|
89
|
+
|
|
90
|
+
# Fallback: method + simplified path
|
|
91
|
+
path_parts = [p for p in self.path.split('/') if p and not p.startswith('{')]
|
|
92
|
+
if path_parts:
|
|
93
|
+
return f"{self.method.lower()}-{'-'.join(path_parts[-2:])}"
|
|
94
|
+
return self.method.lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ParsedSpec:
|
|
99
|
+
"""A parsed OpenAPI specification."""
|
|
100
|
+
|
|
101
|
+
title: str
|
|
102
|
+
version: str
|
|
103
|
+
description: str = ""
|
|
104
|
+
base_url: str = ""
|
|
105
|
+
endpoints: List[Endpoint] = field(default_factory=list)
|
|
106
|
+
auth_schemes: List[AuthScheme] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
def group_by_tag(self) -> Dict[str, List[Endpoint]]:
|
|
109
|
+
"""Group endpoints by their tags."""
|
|
110
|
+
groups: Dict[str, List[Endpoint]] = {}
|
|
111
|
+
|
|
112
|
+
for endpoint in self.endpoints:
|
|
113
|
+
tags = endpoint.tags or ["default"]
|
|
114
|
+
for tag in tags:
|
|
115
|
+
if tag not in groups:
|
|
116
|
+
groups[tag] = []
|
|
117
|
+
groups[tag].append(endpoint)
|
|
118
|
+
|
|
119
|
+
return groups
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class OpenAPIParser:
|
|
123
|
+
"""Parser for OpenAPI 3.x specifications."""
|
|
124
|
+
|
|
125
|
+
def parse(self, source: Union[str, Path]) -> ParsedSpec:
|
|
126
|
+
"""Parse an OpenAPI spec from a file path or URL."""
|
|
127
|
+
raw = self._load_spec(source)
|
|
128
|
+
return self._parse_spec(raw)
|
|
129
|
+
|
|
130
|
+
def _load_spec(self, source: Union[str, Path]) -> dict:
|
|
131
|
+
"""Load spec from file or URL."""
|
|
132
|
+
if isinstance(source, Path):
|
|
133
|
+
source = str(source)
|
|
134
|
+
|
|
135
|
+
# Check if URL
|
|
136
|
+
if source.startswith(('http://', 'https://')):
|
|
137
|
+
response = requests.get(source, timeout=30)
|
|
138
|
+
response.raise_for_status()
|
|
139
|
+
content = response.text
|
|
140
|
+
# Detect format
|
|
141
|
+
if source.endswith('.yaml') or source.endswith('.yml'):
|
|
142
|
+
return yaml.safe_load(content)
|
|
143
|
+
try:
|
|
144
|
+
return json.loads(content)
|
|
145
|
+
except json.JSONDecodeError:
|
|
146
|
+
return yaml.safe_load(content)
|
|
147
|
+
|
|
148
|
+
# Local file
|
|
149
|
+
path = Path(source)
|
|
150
|
+
content = path.read_text()
|
|
151
|
+
|
|
152
|
+
if path.suffix in ('.yaml', '.yml'):
|
|
153
|
+
return yaml.safe_load(content)
|
|
154
|
+
return json.loads(content)
|
|
155
|
+
|
|
156
|
+
def _parse_spec(self, raw: dict) -> ParsedSpec:
|
|
157
|
+
"""Parse raw spec dict into ParsedSpec."""
|
|
158
|
+
info = raw.get('info', {})
|
|
159
|
+
|
|
160
|
+
# Get base URL from servers
|
|
161
|
+
servers = raw.get('servers', [])
|
|
162
|
+
base_url = servers[0]['url'] if servers else ""
|
|
163
|
+
|
|
164
|
+
# Parse endpoints
|
|
165
|
+
endpoints = self._parse_paths(raw.get('paths', {}), raw)
|
|
166
|
+
|
|
167
|
+
# Parse auth schemes
|
|
168
|
+
auth_schemes = self._parse_security_schemes(
|
|
169
|
+
raw.get('components', {}).get('securitySchemes', {})
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return ParsedSpec(
|
|
173
|
+
title=info.get('title', 'API'),
|
|
174
|
+
version=info.get('version', '1.0.0'),
|
|
175
|
+
description=info.get('description', ''),
|
|
176
|
+
base_url=base_url,
|
|
177
|
+
endpoints=endpoints,
|
|
178
|
+
auth_schemes=auth_schemes,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _parse_paths(self, paths: dict, spec: dict) -> List[Endpoint]:
|
|
182
|
+
"""Parse paths into endpoints."""
|
|
183
|
+
endpoints = []
|
|
184
|
+
|
|
185
|
+
for path, methods in paths.items():
|
|
186
|
+
# Handle path-level parameters
|
|
187
|
+
path_params = self._parse_parameters(
|
|
188
|
+
methods.get('parameters', []), spec
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
for method, details in methods.items():
|
|
192
|
+
if method in ('get', 'post', 'put', 'patch', 'delete', 'head', 'options'):
|
|
193
|
+
endpoint = self._parse_endpoint(
|
|
194
|
+
path, method.upper(), details, spec, path_params
|
|
195
|
+
)
|
|
196
|
+
endpoints.append(endpoint)
|
|
197
|
+
|
|
198
|
+
return endpoints
|
|
199
|
+
|
|
200
|
+
def _parse_endpoint(
|
|
201
|
+
self,
|
|
202
|
+
path: str,
|
|
203
|
+
method: str,
|
|
204
|
+
details: dict,
|
|
205
|
+
spec: dict,
|
|
206
|
+
path_params: List[Parameter]
|
|
207
|
+
) -> Endpoint:
|
|
208
|
+
"""Parse a single endpoint."""
|
|
209
|
+
# Parse parameters (combine path-level and operation-level)
|
|
210
|
+
params = path_params.copy()
|
|
211
|
+
params.extend(
|
|
212
|
+
self._parse_parameters(details.get('parameters', []), spec)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Parse request body
|
|
216
|
+
request_body = None
|
|
217
|
+
if 'requestBody' in details:
|
|
218
|
+
request_body = self._parse_request_body(details['requestBody'], spec)
|
|
219
|
+
|
|
220
|
+
# Get security requirements
|
|
221
|
+
security = []
|
|
222
|
+
for sec in details.get('security', []):
|
|
223
|
+
security.extend(sec.keys())
|
|
224
|
+
|
|
225
|
+
return Endpoint(
|
|
226
|
+
path=path,
|
|
227
|
+
method=method,
|
|
228
|
+
operation_id=details.get('operationId', ''),
|
|
229
|
+
summary=details.get('summary', ''),
|
|
230
|
+
description=details.get('description', ''),
|
|
231
|
+
tags=details.get('tags', []),
|
|
232
|
+
parameters=params,
|
|
233
|
+
request_body=request_body,
|
|
234
|
+
security=security,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _parse_parameters(self, params: list, spec: dict) -> List[Parameter]:
|
|
238
|
+
"""Parse parameters."""
|
|
239
|
+
result = []
|
|
240
|
+
|
|
241
|
+
for param in params:
|
|
242
|
+
# Handle $ref
|
|
243
|
+
if '$ref' in param:
|
|
244
|
+
param = self._resolve_ref(param['$ref'], spec)
|
|
245
|
+
|
|
246
|
+
schema = param.get('schema', {})
|
|
247
|
+
|
|
248
|
+
result.append(Parameter(
|
|
249
|
+
name=param.get('name', ''),
|
|
250
|
+
location=param.get('in', 'query'),
|
|
251
|
+
required=param.get('required', False),
|
|
252
|
+
description=param.get('description', ''),
|
|
253
|
+
schema_type=schema.get('type', 'string'),
|
|
254
|
+
default=schema.get('default'),
|
|
255
|
+
enum=schema.get('enum', []),
|
|
256
|
+
))
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
def _parse_request_body(self, body: dict, spec: dict) -> RequestBody:
|
|
261
|
+
"""Parse request body."""
|
|
262
|
+
# Handle $ref
|
|
263
|
+
if '$ref' in body:
|
|
264
|
+
body = self._resolve_ref(body['$ref'], spec)
|
|
265
|
+
|
|
266
|
+
content = body.get('content', {})
|
|
267
|
+
|
|
268
|
+
# Prefer JSON
|
|
269
|
+
content_type = 'application/json'
|
|
270
|
+
if content_type not in content:
|
|
271
|
+
content_type = next(iter(content.keys()), 'application/json')
|
|
272
|
+
|
|
273
|
+
schema = content.get(content_type, {}).get('schema', {})
|
|
274
|
+
|
|
275
|
+
# Handle $ref in schema
|
|
276
|
+
if '$ref' in schema:
|
|
277
|
+
schema = self._resolve_ref(schema['$ref'], spec)
|
|
278
|
+
|
|
279
|
+
properties = schema.get('properties', {})
|
|
280
|
+
required_props = schema.get('required', [])
|
|
281
|
+
|
|
282
|
+
return RequestBody(
|
|
283
|
+
content_type=content_type,
|
|
284
|
+
required=body.get('required', False),
|
|
285
|
+
properties=properties,
|
|
286
|
+
required_props=required_props,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _parse_security_schemes(self, schemes: dict) -> List[AuthScheme]:
|
|
290
|
+
"""Parse security schemes."""
|
|
291
|
+
result = []
|
|
292
|
+
|
|
293
|
+
for name, details in schemes.items():
|
|
294
|
+
scheme_type = details.get('type', '')
|
|
295
|
+
|
|
296
|
+
auth = AuthScheme(
|
|
297
|
+
name=name,
|
|
298
|
+
type=scheme_type,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if scheme_type == 'apiKey':
|
|
302
|
+
auth.location = details.get('in', 'header')
|
|
303
|
+
auth.param_name = details.get('name', '')
|
|
304
|
+
elif scheme_type == 'http':
|
|
305
|
+
auth.scheme = details.get('scheme', 'bearer')
|
|
306
|
+
|
|
307
|
+
result.append(auth)
|
|
308
|
+
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
def _resolve_ref(self, ref: str, spec: dict) -> dict:
|
|
312
|
+
"""Resolve a $ref pointer."""
|
|
313
|
+
if not ref.startswith('#/'):
|
|
314
|
+
return {}
|
|
315
|
+
|
|
316
|
+
parts = ref[2:].split('/')
|
|
317
|
+
current = spec
|
|
318
|
+
|
|
319
|
+
for part in parts:
|
|
320
|
+
if isinstance(current, dict) and part in current:
|
|
321
|
+
current = current[part]
|
|
322
|
+
else:
|
|
323
|
+
return {}
|
|
324
|
+
|
|
325
|
+
return current if isinstance(current, dict) else {}
|
openapi2cli/runtime.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Runtime for executing API calls and running generated CLIs."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Union
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CLIResult:
|
|
14
|
+
"""Result of running a CLI command."""
|
|
15
|
+
|
|
16
|
+
exit_code: int
|
|
17
|
+
output: str
|
|
18
|
+
error: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIClient:
|
|
22
|
+
"""HTTP client for making API requests."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_url: str,
|
|
27
|
+
auth_header: Optional[str] = None,
|
|
28
|
+
auth_value: Optional[str] = None,
|
|
29
|
+
api_key_param: Optional[str] = None,
|
|
30
|
+
api_key_value: Optional[str] = None,
|
|
31
|
+
timeout: int = 30,
|
|
32
|
+
):
|
|
33
|
+
self.base_url = base_url.rstrip("/")
|
|
34
|
+
self.auth_header = auth_header
|
|
35
|
+
self.auth_value = auth_value
|
|
36
|
+
self.api_key_param = api_key_param
|
|
37
|
+
self.api_key_value = api_key_value
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self.session = requests.Session()
|
|
40
|
+
|
|
41
|
+
def _get_headers(self) -> dict:
|
|
42
|
+
"""Get request headers including auth."""
|
|
43
|
+
headers = {"Content-Type": "application/json"}
|
|
44
|
+
|
|
45
|
+
if self.auth_header and self.auth_value:
|
|
46
|
+
headers[self.auth_header] = self.auth_value
|
|
47
|
+
|
|
48
|
+
return headers
|
|
49
|
+
|
|
50
|
+
def _get_params(self, params: Optional[dict]) -> dict:
|
|
51
|
+
"""Get query parameters including API key if configured."""
|
|
52
|
+
result = params.copy() if params else {}
|
|
53
|
+
|
|
54
|
+
if self.api_key_param and self.api_key_value:
|
|
55
|
+
result[self.api_key_param] = self.api_key_value
|
|
56
|
+
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def _build_url(self, path: str, path_params: Optional[dict] = None) -> str:
|
|
60
|
+
"""Build full URL with path parameter substitution."""
|
|
61
|
+
if path_params:
|
|
62
|
+
for key, value in path_params.items():
|
|
63
|
+
path = path.replace(f"{{{key}}}", str(value))
|
|
64
|
+
|
|
65
|
+
return f"{self.base_url}{path}"
|
|
66
|
+
|
|
67
|
+
def request(
|
|
68
|
+
self,
|
|
69
|
+
method: str,
|
|
70
|
+
path: str,
|
|
71
|
+
params: Optional[dict] = None,
|
|
72
|
+
json_data: Optional[dict] = None,
|
|
73
|
+
path_params: Optional[dict] = None,
|
|
74
|
+
) -> requests.Response:
|
|
75
|
+
"""Make an HTTP request."""
|
|
76
|
+
url = self._build_url(path, path_params)
|
|
77
|
+
headers = self._get_headers()
|
|
78
|
+
query_params = self._get_params(params)
|
|
79
|
+
|
|
80
|
+
return self.session.request(
|
|
81
|
+
method=method,
|
|
82
|
+
url=url,
|
|
83
|
+
params=query_params or None,
|
|
84
|
+
json=json_data,
|
|
85
|
+
headers=headers,
|
|
86
|
+
timeout=self.timeout,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def get(
|
|
90
|
+
self,
|
|
91
|
+
path: str,
|
|
92
|
+
params: Optional[dict] = None,
|
|
93
|
+
path_params: Optional[dict] = None,
|
|
94
|
+
) -> requests.Response:
|
|
95
|
+
"""Make a GET request."""
|
|
96
|
+
return self.request("GET", path, params=params, path_params=path_params)
|
|
97
|
+
|
|
98
|
+
def post(
|
|
99
|
+
self,
|
|
100
|
+
path: str,
|
|
101
|
+
json_data: Optional[dict] = None,
|
|
102
|
+
params: Optional[dict] = None,
|
|
103
|
+
path_params: Optional[dict] = None,
|
|
104
|
+
) -> requests.Response:
|
|
105
|
+
"""Make a POST request."""
|
|
106
|
+
return self.request(
|
|
107
|
+
"POST", path, params=params, json_data=json_data, path_params=path_params
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def put(
|
|
111
|
+
self,
|
|
112
|
+
path: str,
|
|
113
|
+
json_data: Optional[dict] = None,
|
|
114
|
+
params: Optional[dict] = None,
|
|
115
|
+
path_params: Optional[dict] = None,
|
|
116
|
+
) -> requests.Response:
|
|
117
|
+
"""Make a PUT request."""
|
|
118
|
+
return self.request(
|
|
119
|
+
"PUT", path, params=params, json_data=json_data, path_params=path_params
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def delete(
|
|
123
|
+
self,
|
|
124
|
+
path: str,
|
|
125
|
+
params: Optional[dict] = None,
|
|
126
|
+
path_params: Optional[dict] = None,
|
|
127
|
+
) -> requests.Response:
|
|
128
|
+
"""Make a DELETE request."""
|
|
129
|
+
return self.request("DELETE", path, params=params, path_params=path_params)
|
|
130
|
+
|
|
131
|
+
def patch(
|
|
132
|
+
self,
|
|
133
|
+
path: str,
|
|
134
|
+
json_data: Optional[dict] = None,
|
|
135
|
+
params: Optional[dict] = None,
|
|
136
|
+
path_params: Optional[dict] = None,
|
|
137
|
+
) -> requests.Response:
|
|
138
|
+
"""Make a PATCH request."""
|
|
139
|
+
return self.request(
|
|
140
|
+
"PATCH", path, params=params, json_data=json_data, path_params=path_params
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CLIRunner:
|
|
145
|
+
"""Runner for executing generated CLIs."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, script_path: Union[Path, str]):
|
|
148
|
+
self.script_path = Path(script_path)
|
|
149
|
+
|
|
150
|
+
def run(self, args: List[str], env: Optional[dict] = None) -> CLIResult:
|
|
151
|
+
"""Run the CLI with given arguments."""
|
|
152
|
+
import os
|
|
153
|
+
|
|
154
|
+
full_env = os.environ.copy()
|
|
155
|
+
if env:
|
|
156
|
+
full_env.update(env)
|
|
157
|
+
|
|
158
|
+
result = subprocess.run(
|
|
159
|
+
[sys.executable, str(self.script_path)] + args,
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
env=full_env,
|
|
163
|
+
timeout=60,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return CLIResult(
|
|
167
|
+
exit_code=result.returncode,
|
|
168
|
+
output=result.stdout,
|
|
169
|
+
error=result.stderr,
|
|
170
|
+
)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openapi2cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate CLI tools from OpenAPI specs ā built for AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/Olafs-World/openapi2cli
|
|
6
|
+
Project-URL: Repository, https://github.com/Olafs-World/openapi2cli
|
|
7
|
+
Project-URL: Documentation, https://github.com/Olafs-World/openapi2cli#readme
|
|
8
|
+
Author-email: Olaf <olaf.bot@agentmail.to>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,api,cli,code-generation,openapi,swagger
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: jinja2>=3.0
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Requires-Dist: requests>=2.25
|
|
27
|
+
Requires-Dist: rich>=13.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# openapi2cli š§
|
|
35
|
+
|
|
36
|
+
[](https://github.com/Olafs-World/openapi2cli/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/openapi2cli/)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](https://opensource.org/licenses/MIT)
|
|
40
|
+
|
|
41
|
+
**Generate CLI tools from OpenAPI specs.** Built for AI agents who need to interact with APIs.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Generate a CLI from any OpenAPI spec
|
|
45
|
+
$ openapi2cli generate https://httpbin.org/spec.json --name httpbin
|
|
46
|
+
|
|
47
|
+
# Use it immediately
|
|
48
|
+
$ ./httpbin_cli.py get --output json
|
|
49
|
+
{
|
|
50
|
+
"url": "https://httpbin.org/get",
|
|
51
|
+
"headers": { ... }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why?
|
|
56
|
+
|
|
57
|
+
AI agents are great at executing CLI commands. They're less great at crafting HTTP requests from memory. This tool bridges the gap:
|
|
58
|
+
|
|
59
|
+
1. **OpenAPI spec** ā any API with a spec becomes usable
|
|
60
|
+
2. **CLI generation** ā instant `--help`, tab completion, validation
|
|
61
|
+
3. **No code changes** ā just point at a spec and go
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install openapi2cli
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
### Generate a CLI
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# From a URL
|
|
75
|
+
openapi2cli generate https://petstore3.swagger.io/api/v3/openapi.json --name petstore
|
|
76
|
+
|
|
77
|
+
# From a local file
|
|
78
|
+
openapi2cli generate ./api-spec.yaml --name myapi --output myapi
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Use the Generated CLI
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# See available commands
|
|
85
|
+
./petstore --help
|
|
86
|
+
|
|
87
|
+
# List pets
|
|
88
|
+
./petstore pet find-by-status --status available
|
|
89
|
+
|
|
90
|
+
# Add a pet (with auth)
|
|
91
|
+
export PETSTORE_API_KEY=your-key
|
|
92
|
+
./petstore pet add --name "Fluffy" --status available
|
|
93
|
+
|
|
94
|
+
# JSON output for scripting
|
|
95
|
+
./petstore pet get --pet-id 123 --output json | jq '.name'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Inspect a Spec
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# See what's in a spec without generating
|
|
102
|
+
openapi2cli inspect https://httpbin.org/spec.json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Features
|
|
106
|
+
|
|
107
|
+
| Feature | Description |
|
|
108
|
+
|---------|-------------|
|
|
109
|
+
| š **Auto-discovery** | Parses OpenAPI 3.x specs (YAML or JSON) |
|
|
110
|
+
| š·ļø **Smart grouping** | Commands grouped by API tags |
|
|
111
|
+
| š **Auth support** | API keys, Bearer tokens, env vars |
|
|
112
|
+
| š **Output formats** | JSON, table, or raw |
|
|
113
|
+
| ā” **Fast generation** | Single command, instant CLI |
|
|
114
|
+
| š¤ **Agent-friendly** | Self-documenting with `--help` |
|
|
115
|
+
|
|
116
|
+
## Configuration
|
|
117
|
+
|
|
118
|
+
### Authentication
|
|
119
|
+
|
|
120
|
+
Generated CLIs support multiple auth methods:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Via environment variable (recommended)
|
|
124
|
+
export PETSTORE_API_KEY=your-key
|
|
125
|
+
./petstore pet list
|
|
126
|
+
|
|
127
|
+
# Via CLI option
|
|
128
|
+
./petstore --api-key your-key pet list
|
|
129
|
+
|
|
130
|
+
# Bearer token
|
|
131
|
+
export PETSTORE_TOKEN=your-bearer-token
|
|
132
|
+
./petstore pet list
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The env var prefix is derived from the CLI name (uppercase, underscores).
|
|
136
|
+
|
|
137
|
+
### Base URL Override
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Use a different API server
|
|
141
|
+
./petstore --base-url https://staging.petstore.io/api pet list
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Output Formats
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# JSON (default, good for piping)
|
|
148
|
+
./petstore pet list --output json
|
|
149
|
+
|
|
150
|
+
# Table (human-readable, requires rich)
|
|
151
|
+
./petstore pet list --output table
|
|
152
|
+
|
|
153
|
+
# Raw (API response as-is)
|
|
154
|
+
./petstore pet list --output raw
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Generated CLI Structure
|
|
158
|
+
|
|
159
|
+
For a spec with tags `pet`, `store`, `user`:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
petstore
|
|
163
|
+
āāā pet
|
|
164
|
+
ā āāā add # POST /pet
|
|
165
|
+
ā āāā get # GET /pet/{petId}
|
|
166
|
+
ā āāā update # PUT /pet
|
|
167
|
+
ā āāā delete # DELETE /pet/{petId}
|
|
168
|
+
ā āāā find-by-status
|
|
169
|
+
āāā store
|
|
170
|
+
ā āāā order
|
|
171
|
+
ā āāā inventory
|
|
172
|
+
āāā user
|
|
173
|
+
āāā create
|
|
174
|
+
āāā login
|
|
175
|
+
āāā logout
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## API Reference
|
|
179
|
+
|
|
180
|
+
### `openapi2cli generate`
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
openapi2cli generate SPEC --name NAME [--output PATH] [--stdout]
|
|
184
|
+
|
|
185
|
+
Arguments:
|
|
186
|
+
SPEC OpenAPI spec (file path or URL)
|
|
187
|
+
|
|
188
|
+
Options:
|
|
189
|
+
-n, --name CLI name (required)
|
|
190
|
+
-o, --output Output file path (default: {name}_cli.py)
|
|
191
|
+
--stdout Print to stdout instead of file
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### `openapi2cli inspect`
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
openapi2cli inspect SPEC
|
|
198
|
+
|
|
199
|
+
Arguments:
|
|
200
|
+
SPEC OpenAPI spec (file path or URL)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Python API
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from openapi2cli import OpenAPIParser, CLIGenerator
|
|
207
|
+
|
|
208
|
+
# Parse a spec
|
|
209
|
+
parser = OpenAPIParser()
|
|
210
|
+
spec = parser.parse("https://api.example.com/openapi.json")
|
|
211
|
+
|
|
212
|
+
print(f"API: {spec.title}")
|
|
213
|
+
print(f"Endpoints: {len(spec.endpoints)}")
|
|
214
|
+
|
|
215
|
+
# Generate CLI
|
|
216
|
+
generator = CLIGenerator()
|
|
217
|
+
cli = generator.generate(spec, name="example")
|
|
218
|
+
|
|
219
|
+
# Save to file
|
|
220
|
+
cli.save("example_cli.py")
|
|
221
|
+
|
|
222
|
+
# Or get the code
|
|
223
|
+
code = cli.to_python()
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
# Clone
|
|
230
|
+
git clone https://github.com/Olafs-World/openapi2cli.git
|
|
231
|
+
cd openapi2cli
|
|
232
|
+
|
|
233
|
+
# Install dev dependencies
|
|
234
|
+
pip install -e ".[dev]"
|
|
235
|
+
|
|
236
|
+
# Run tests
|
|
237
|
+
pytest tests/ -v
|
|
238
|
+
|
|
239
|
+
# Run only unit tests (no API calls)
|
|
240
|
+
pytest tests/ -v -m "not integration"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## How It Works
|
|
244
|
+
|
|
245
|
+
1. **Parse** - Load OpenAPI 3.x spec (YAML/JSON, local/URL)
|
|
246
|
+
2. **Extract** - Pull endpoints, parameters, auth schemes, request bodies
|
|
247
|
+
3. **Generate** - Create Click-based CLI with proper groups and options
|
|
248
|
+
4. **Output** - Save as standalone Python script (executable)
|
|
249
|
+
|
|
250
|
+
The generated CLI uses `requests` for HTTP and optionally `rich` for pretty output.
|
|
251
|
+
|
|
252
|
+
## Limitations
|
|
253
|
+
|
|
254
|
+
- OpenAPI 3.x only (not Swagger 2.0)
|
|
255
|
+
- No file upload support yet
|
|
256
|
+
- Complex nested request bodies may need `--data` JSON flag
|
|
257
|
+
- OAuth2 flows not fully implemented (use `--token` with pre-obtained tokens)
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT Ā© [Olaf](https://olafs-world.vercel.app)
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
<p align="center">
|
|
266
|
+
<i>Built by an AI who got tired of writing curl commands š¤</i>
|
|
267
|
+
</p>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
openapi2cli/__init__.py,sha256=tbcK5dHpbnX8UP8b9yLidCZF02eOVwb2Z15w80XatIs,371
|
|
2
|
+
openapi2cli/__main__.py,sha256=YemrHLS5fqMtkTuPMAnCmPLHN0nqfB6OC-mRMGFCccA,106
|
|
3
|
+
openapi2cli/cli.py,sha256=szav-4w2LMlogvTBfDKilPerCvrnyZCDRSlZpqHSZMg,3660
|
|
4
|
+
openapi2cli/generator.py,sha256=IhVSCHEh6KfNRmaeK8_xU8ZhExU7I0Ok0RJj-6XcnBI,13476
|
|
5
|
+
openapi2cli/parser.py,sha256=02q24MUBKss-1CDJ0vvI5qwDlio3FBpV7PbtOpa_Qo4,10155
|
|
6
|
+
openapi2cli/runtime.py,sha256=5Vo6pt-KShC2MjX63BvfJ-e4TW670owbDvMVvbrf2Zk,4877
|
|
7
|
+
openapi2cli-0.1.0.dist-info/METADATA,sha256=OaPAPIEp3cyW7G5GWW6QXEuxkpejJLBHLdxje1ekCKY,6778
|
|
8
|
+
openapi2cli-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
openapi2cli-0.1.0.dist-info/entry_points.txt,sha256=PYoT4cTlCmdI6m1-yOM58pA2rRpkt667UKBaq386tEk,53
|
|
10
|
+
openapi2cli-0.1.0.dist-info/licenses/LICENSE,sha256=R6wp-QnWz5hcOS9xs_DAAzChQm-ir9s5IQyVsVRXzz4,1061
|
|
11
|
+
openapi2cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Olaf
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|