openapi-cli4ai 0.1.1__tar.gz → 0.3.0__tar.gz
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.
- openapi_cli4ai-0.3.0/.github/release.yml +20 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/workflows/ci.yml +2 -2
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/workflows/codeql.yml +2 -2
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/workflows/publish.yml +2 -2
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/CHANGELOG.md +25 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/PKG-INFO +62 -8
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/README.md +61 -7
- openapi_cli4ai-0.3.0/demo.gif +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/pyproject.toml +1 -1
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/src/openapi_cli4ai/cli.py +482 -7
- openapi_cli4ai-0.3.0/tests/test_oidc.py +130 -0
- openapi_cli4ai-0.3.0/tests/test_run_command.py +165 -0
- openapi_cli4ai-0.1.1/demo.gif +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/CODEOWNERS +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/dependabot.yml +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.gitignore +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/CONTRIBUTING.md +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/LICENSE +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/SECURITY.md +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/examples/profiles.toml.example +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/openapi-cli4ai +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/src/openapi_cli4ai/__init__.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/src/openapi_cli4ai/__main__.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/__init__.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/conftest.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/fixtures/petstore_spec.json +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_auth.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_caching.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_integration.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_profile_management.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_spec_parsing.py +0 -0
- {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_sse_streaming.py +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
changelog:
|
|
2
|
+
categories:
|
|
3
|
+
- title: New Features
|
|
4
|
+
labels:
|
|
5
|
+
- enhancement
|
|
6
|
+
- title: Bug Fixes
|
|
7
|
+
labels:
|
|
8
|
+
- bug
|
|
9
|
+
- title: Security
|
|
10
|
+
labels:
|
|
11
|
+
- security
|
|
12
|
+
- title: Dependencies
|
|
13
|
+
labels:
|
|
14
|
+
- dependencies
|
|
15
|
+
- title: Other Changes
|
|
16
|
+
labels:
|
|
17
|
+
- "*"
|
|
18
|
+
exclude:
|
|
19
|
+
labels:
|
|
20
|
+
- skip-changelog
|
|
@@ -17,7 +17,7 @@ jobs:
|
|
|
17
17
|
python-version: ["3.11", "3.12", "3.13"]
|
|
18
18
|
steps:
|
|
19
19
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
20
|
-
- uses: astral-sh/setup-uv@
|
|
20
|
+
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
|
21
21
|
with:
|
|
22
22
|
enable-cache: true
|
|
23
23
|
- name: Set up Python ${{ matrix.python-version }}
|
|
@@ -29,7 +29,7 @@ jobs:
|
|
|
29
29
|
runs-on: ubuntu-latest
|
|
30
30
|
steps:
|
|
31
31
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
32
|
-
- uses: astral-sh/setup-uv@
|
|
32
|
+
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
|
33
33
|
- name: Ruff check
|
|
34
34
|
run: uvx ruff check .
|
|
35
35
|
- name: Ruff format check
|
|
@@ -17,7 +17,7 @@ jobs:
|
|
|
17
17
|
runs-on: ubuntu-latest
|
|
18
18
|
steps:
|
|
19
19
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
20
|
-
- uses: github/codeql-action/init@
|
|
20
|
+
- uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
|
21
21
|
with:
|
|
22
22
|
languages: python
|
|
23
|
-
- uses: github/codeql-action/analyze@
|
|
23
|
+
- uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
|
@@ -14,7 +14,7 @@ jobs:
|
|
|
14
14
|
contents: read
|
|
15
15
|
steps:
|
|
16
16
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
17
|
-
- uses: astral-sh/setup-uv@
|
|
17
|
+
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
|
18
18
|
- name: Build sdist and wheel
|
|
19
19
|
run: uv build
|
|
20
20
|
- name: Upload artifacts
|
|
@@ -42,7 +42,7 @@ jobs:
|
|
|
42
42
|
name: dist
|
|
43
43
|
path: dist/
|
|
44
44
|
- name: Generate attestations
|
|
45
|
-
uses: actions/attest-build-provenance@
|
|
45
|
+
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
|
46
46
|
with:
|
|
47
47
|
subject-path: dist/*
|
|
48
48
|
- name: Publish to PyPI
|
|
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-03-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- OIDC Authorization Code + PKCE auth type (`auth.type = "oidc"`)
|
|
12
|
+
- Browser-based login with localhost callback
|
|
13
|
+
- `--no-browser` flag for headless/SSH environments
|
|
14
|
+
- CSRF protection via state parameter validation
|
|
15
|
+
- Tested with Auth0 and Keycloak
|
|
16
|
+
|
|
17
|
+
## [0.2.0] - 2026-03-26
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `run` command — call API operations by name with auto-routed inputs
|
|
22
|
+
- Case-insensitive operationId matching
|
|
23
|
+
- Fuzzy suggestions when an operationId isn't found
|
|
24
|
+
- Auto-generated release notes via `.github/release.yml`
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `typer>=0.12` crashed with click 8.2+ — bumped to `>=0.24` (#3)
|
|
29
|
+
- `--format json` appended non-JSON summary line breaking machine parsing
|
|
30
|
+
- `init` auto-detect ignored `--insecure` flag
|
|
31
|
+
|
|
7
32
|
## [0.1.0] - 2026-03-25
|
|
8
33
|
|
|
9
34
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openapi-cli4ai
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn any REST API with an OpenAPI spec into an AI-ready CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/dbgorilla/openapi-cli4ai
|
|
6
6
|
Project-URL: Repository, https://github.com/dbgorilla/openapi-cli4ai
|
|
@@ -41,19 +41,24 @@ Let your AI coding agent (Claude Code, Cursor, Codex, Copilot) talk to any REST
|
|
|
41
41
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
# Requires uv (https://docs.astral.sh/uv/)
|
|
46
|
-
uv pip install openapi-cli4ai
|
|
44
|
+
**Requires:** [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
|
47
45
|
|
|
48
|
-
|
|
46
|
+
```bash
|
|
47
|
+
# Recommended — run directly, no install needed
|
|
49
48
|
uvx openapi-cli4ai --help
|
|
49
|
+
|
|
50
|
+
# Or install globally
|
|
51
|
+
uv tool install openapi-cli4ai
|
|
52
|
+
|
|
53
|
+
# Or install into a virtual environment
|
|
54
|
+
uv pip install openapi-cli4ai
|
|
50
55
|
```
|
|
51
56
|
|
|
52
57
|
## Quick Start
|
|
53
58
|
|
|
54
59
|
```bash
|
|
55
|
-
# Point it at any API with an OpenAPI spec
|
|
56
|
-
openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
|
|
60
|
+
# Point it at any API with an OpenAPI spec (prefix with uvx if not installed)
|
|
61
|
+
uvx openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
|
|
57
62
|
|
|
58
63
|
# Discover endpoints
|
|
59
64
|
openapi-cli4ai endpoints
|
|
@@ -61,7 +66,10 @@ openapi-cli4ai endpoints
|
|
|
61
66
|
# Search endpoints
|
|
62
67
|
openapi-cli4ai endpoints -s pet
|
|
63
68
|
|
|
64
|
-
#
|
|
69
|
+
# Run an operation by name (inputs auto-routed from the spec)
|
|
70
|
+
openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
|
|
71
|
+
|
|
72
|
+
# Or call an endpoint directly
|
|
65
73
|
openapi-cli4ai call GET /pet/findByStatus --query status=available
|
|
66
74
|
```
|
|
67
75
|
|
|
@@ -118,6 +126,27 @@ openapi-cli4ai init myapi --url https://api.example.com --spec-url https://examp
|
|
|
118
126
|
openapi-cli4ai -k init myapi --url https://staging.internal.example.com
|
|
119
127
|
```
|
|
120
128
|
|
|
129
|
+
### `run` — Run an operation by name
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Query parameter — auto-routed from the spec
|
|
133
|
+
openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
|
|
134
|
+
|
|
135
|
+
# Path parameter — substituted into the URL
|
|
136
|
+
openapi-cli4ai run getPetById --input '{"petId": 123}'
|
|
137
|
+
|
|
138
|
+
# Request body — keys not matching a parameter go to the body
|
|
139
|
+
openapi-cli4ai run addPet --input '{"name": "Rex", "status": "available"}'
|
|
140
|
+
|
|
141
|
+
# Load input from a file
|
|
142
|
+
openapi-cli4ai run addPet --input-file pet.json
|
|
143
|
+
|
|
144
|
+
# Raw JSON output
|
|
145
|
+
openapi-cli4ai run getInventory --json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The spec tells the tool where each parameter goes (path, query, header, body). You just pass a flat JSON object.
|
|
149
|
+
|
|
121
150
|
### `endpoints` — Discover API endpoints
|
|
122
151
|
|
|
123
152
|
```bash
|
|
@@ -193,6 +222,12 @@ openapi-cli4ai login --username admin --password-file /path/to/secret
|
|
|
193
222
|
# Login with password from stdin
|
|
194
223
|
echo "my-password" | openapi-cli4ai login --username admin --password-stdin
|
|
195
224
|
|
|
225
|
+
# OIDC login (opens browser for Auth0, Keycloak, etc.)
|
|
226
|
+
openapi-cli4ai login
|
|
227
|
+
|
|
228
|
+
# OIDC login without browser (headless/SSH — prints URL, you paste redirect back)
|
|
229
|
+
openapi-cli4ai login --no-browser
|
|
230
|
+
|
|
196
231
|
# Logout (clear cached tokens)
|
|
197
232
|
openapi-cli4ai logout
|
|
198
233
|
```
|
|
@@ -240,6 +275,22 @@ account_type = "USERNAME"
|
|
|
240
275
|
|
|
241
276
|
Payload placeholders: `{username}` and `{password}` come from the login prompt. `{env:VAR_NAME}` pulls from environment variables or a `.env` file (loaded automatically).
|
|
242
277
|
|
|
278
|
+
OIDC example (Auth0, Keycloak, or any OIDC provider):
|
|
279
|
+
|
|
280
|
+
```toml
|
|
281
|
+
[profiles.my-oidc-app]
|
|
282
|
+
base_url = "https://api.example.com"
|
|
283
|
+
openapi_path = "/openapi.json"
|
|
284
|
+
|
|
285
|
+
[profiles.my-oidc-app.auth]
|
|
286
|
+
type = "oidc"
|
|
287
|
+
authorize_url = "https://your-idp.com/authorize"
|
|
288
|
+
token_url = "https://your-idp.com/oauth/token"
|
|
289
|
+
client_id = "your-client-id"
|
|
290
|
+
scopes = "openid profile email"
|
|
291
|
+
callback_port = 9876
|
|
292
|
+
```
|
|
293
|
+
|
|
243
294
|
### Auth Types
|
|
244
295
|
|
|
245
296
|
| Type | Use Case | Config Fields |
|
|
@@ -247,6 +298,7 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
|
|
|
247
298
|
| `none` | Public APIs | — |
|
|
248
299
|
| `bearer` | Token from env var | `token_env_var` |
|
|
249
300
|
| `bearer` | OAuth token endpoint | `token_endpoint`, `refresh_endpoint`, `payload` |
|
|
301
|
+
| `oidc` | OIDC Authorization Code + PKCE | `authorize_url`, `token_url`, `client_id`, `scopes` |
|
|
250
302
|
| `api-key` | API key in header | `env_var`, `header`, `prefix` |
|
|
251
303
|
| `basic` | HTTP Basic auth | `username_env_var`, `password_env_var` |
|
|
252
304
|
|
|
@@ -263,6 +315,8 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
|
|
|
263
315
|
| [Jira Cloud](https://developer.atlassian.com/cloud/jira/platform/rest/v3/) | 581 | Basic | `init jira --url https://your-domain.atlassian.net --spec-url ... --auth basic` |
|
|
264
316
|
| [OpenRouter](https://openrouter.ai) | 36 | Bearer | `init openrouter --url https://openrouter.ai/api/v1 --spec-url ... --auth bearer` |
|
|
265
317
|
| [DBGorilla](https://dbgorilla.com) | 500+ | Token endpoint | See `examples/profiles.toml.example` |
|
|
318
|
+
| [Auth0](https://auth0.com) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
|
|
319
|
+
| [Keycloak](https://www.keycloak.org) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
|
|
266
320
|
|
|
267
321
|
Works with any AI agent that has shell access — Claude Code, Cursor, GitHub Copilot, or anything that can run `endpoints` and `call`.
|
|
268
322
|
|
|
@@ -13,19 +13,24 @@ Let your AI coding agent (Claude Code, Cursor, Codex, Copilot) talk to any REST
|
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
# Requires uv (https://docs.astral.sh/uv/)
|
|
18
|
-
uv pip install openapi-cli4ai
|
|
16
|
+
**Requires:** [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
```bash
|
|
19
|
+
# Recommended — run directly, no install needed
|
|
21
20
|
uvx openapi-cli4ai --help
|
|
21
|
+
|
|
22
|
+
# Or install globally
|
|
23
|
+
uv tool install openapi-cli4ai
|
|
24
|
+
|
|
25
|
+
# Or install into a virtual environment
|
|
26
|
+
uv pip install openapi-cli4ai
|
|
22
27
|
```
|
|
23
28
|
|
|
24
29
|
## Quick Start
|
|
25
30
|
|
|
26
31
|
```bash
|
|
27
|
-
# Point it at any API with an OpenAPI spec
|
|
28
|
-
openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
|
|
32
|
+
# Point it at any API with an OpenAPI spec (prefix with uvx if not installed)
|
|
33
|
+
uvx openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
|
|
29
34
|
|
|
30
35
|
# Discover endpoints
|
|
31
36
|
openapi-cli4ai endpoints
|
|
@@ -33,7 +38,10 @@ openapi-cli4ai endpoints
|
|
|
33
38
|
# Search endpoints
|
|
34
39
|
openapi-cli4ai endpoints -s pet
|
|
35
40
|
|
|
36
|
-
#
|
|
41
|
+
# Run an operation by name (inputs auto-routed from the spec)
|
|
42
|
+
openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
|
|
43
|
+
|
|
44
|
+
# Or call an endpoint directly
|
|
37
45
|
openapi-cli4ai call GET /pet/findByStatus --query status=available
|
|
38
46
|
```
|
|
39
47
|
|
|
@@ -90,6 +98,27 @@ openapi-cli4ai init myapi --url https://api.example.com --spec-url https://examp
|
|
|
90
98
|
openapi-cli4ai -k init myapi --url https://staging.internal.example.com
|
|
91
99
|
```
|
|
92
100
|
|
|
101
|
+
### `run` — Run an operation by name
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Query parameter — auto-routed from the spec
|
|
105
|
+
openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
|
|
106
|
+
|
|
107
|
+
# Path parameter — substituted into the URL
|
|
108
|
+
openapi-cli4ai run getPetById --input '{"petId": 123}'
|
|
109
|
+
|
|
110
|
+
# Request body — keys not matching a parameter go to the body
|
|
111
|
+
openapi-cli4ai run addPet --input '{"name": "Rex", "status": "available"}'
|
|
112
|
+
|
|
113
|
+
# Load input from a file
|
|
114
|
+
openapi-cli4ai run addPet --input-file pet.json
|
|
115
|
+
|
|
116
|
+
# Raw JSON output
|
|
117
|
+
openapi-cli4ai run getInventory --json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The spec tells the tool where each parameter goes (path, query, header, body). You just pass a flat JSON object.
|
|
121
|
+
|
|
93
122
|
### `endpoints` — Discover API endpoints
|
|
94
123
|
|
|
95
124
|
```bash
|
|
@@ -165,6 +194,12 @@ openapi-cli4ai login --username admin --password-file /path/to/secret
|
|
|
165
194
|
# Login with password from stdin
|
|
166
195
|
echo "my-password" | openapi-cli4ai login --username admin --password-stdin
|
|
167
196
|
|
|
197
|
+
# OIDC login (opens browser for Auth0, Keycloak, etc.)
|
|
198
|
+
openapi-cli4ai login
|
|
199
|
+
|
|
200
|
+
# OIDC login without browser (headless/SSH — prints URL, you paste redirect back)
|
|
201
|
+
openapi-cli4ai login --no-browser
|
|
202
|
+
|
|
168
203
|
# Logout (clear cached tokens)
|
|
169
204
|
openapi-cli4ai logout
|
|
170
205
|
```
|
|
@@ -212,6 +247,22 @@ account_type = "USERNAME"
|
|
|
212
247
|
|
|
213
248
|
Payload placeholders: `{username}` and `{password}` come from the login prompt. `{env:VAR_NAME}` pulls from environment variables or a `.env` file (loaded automatically).
|
|
214
249
|
|
|
250
|
+
OIDC example (Auth0, Keycloak, or any OIDC provider):
|
|
251
|
+
|
|
252
|
+
```toml
|
|
253
|
+
[profiles.my-oidc-app]
|
|
254
|
+
base_url = "https://api.example.com"
|
|
255
|
+
openapi_path = "/openapi.json"
|
|
256
|
+
|
|
257
|
+
[profiles.my-oidc-app.auth]
|
|
258
|
+
type = "oidc"
|
|
259
|
+
authorize_url = "https://your-idp.com/authorize"
|
|
260
|
+
token_url = "https://your-idp.com/oauth/token"
|
|
261
|
+
client_id = "your-client-id"
|
|
262
|
+
scopes = "openid profile email"
|
|
263
|
+
callback_port = 9876
|
|
264
|
+
```
|
|
265
|
+
|
|
215
266
|
### Auth Types
|
|
216
267
|
|
|
217
268
|
| Type | Use Case | Config Fields |
|
|
@@ -219,6 +270,7 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
|
|
|
219
270
|
| `none` | Public APIs | — |
|
|
220
271
|
| `bearer` | Token from env var | `token_env_var` |
|
|
221
272
|
| `bearer` | OAuth token endpoint | `token_endpoint`, `refresh_endpoint`, `payload` |
|
|
273
|
+
| `oidc` | OIDC Authorization Code + PKCE | `authorize_url`, `token_url`, `client_id`, `scopes` |
|
|
222
274
|
| `api-key` | API key in header | `env_var`, `header`, `prefix` |
|
|
223
275
|
| `basic` | HTTP Basic auth | `username_env_var`, `password_env_var` |
|
|
224
276
|
|
|
@@ -235,6 +287,8 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
|
|
|
235
287
|
| [Jira Cloud](https://developer.atlassian.com/cloud/jira/platform/rest/v3/) | 581 | Basic | `init jira --url https://your-domain.atlassian.net --spec-url ... --auth basic` |
|
|
236
288
|
| [OpenRouter](https://openrouter.ai) | 36 | Bearer | `init openrouter --url https://openrouter.ai/api/v1 --spec-url ... --auth bearer` |
|
|
237
289
|
| [DBGorilla](https://dbgorilla.com) | 500+ | Token endpoint | See `examples/profiles.toml.example` |
|
|
290
|
+
| [Auth0](https://auth0.com) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
|
|
291
|
+
| [Keycloak](https://www.keycloak.org) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
|
|
238
292
|
|
|
239
293
|
Works with any AI agent that has shell access — Claude Code, Cursor, GitHub Copilot, or anything that can run `endpoints` and `call`.
|
|
240
294
|
|
|
Binary file
|
|
@@ -27,9 +27,13 @@ import hashlib
|
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
29
|
import re
|
|
30
|
+
import secrets
|
|
30
31
|
import sys
|
|
31
32
|
import time
|
|
32
33
|
import tomllib
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import webbrowser
|
|
36
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
33
37
|
from pathlib import Path
|
|
34
38
|
from typing import Annotated, Optional
|
|
35
39
|
|
|
@@ -47,7 +51,7 @@ from rich.panel import Panel # noqa: E402
|
|
|
47
51
|
from rich.table import Table # noqa: E402
|
|
48
52
|
|
|
49
53
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
|
50
|
-
VERSION = "0.
|
|
54
|
+
VERSION = "0.3.0"
|
|
51
55
|
APP_NAME = "openapi-cli4ai"
|
|
52
56
|
CONFIG_FILE = Path.home() / ".openapi-cli4ai.toml"
|
|
53
57
|
CACHE_DIR = Path.home() / ".cache" / APP_NAME
|
|
@@ -376,6 +380,8 @@ def get_auth_headers(profile: dict, quiet: bool = False) -> dict:
|
|
|
376
380
|
return {}
|
|
377
381
|
elif auth_type == "bearer":
|
|
378
382
|
return _bearer_auth(profile, auth_config, quiet)
|
|
383
|
+
elif auth_type == "oidc":
|
|
384
|
+
return _oidc_auth(profile, auth_config, quiet)
|
|
379
385
|
elif auth_type == "api-key":
|
|
380
386
|
return _api_key_auth(auth_config, quiet)
|
|
381
387
|
elif auth_type == "basic":
|
|
@@ -469,6 +475,262 @@ def _try_refresh_token(profile: dict, auth_config: dict, cached: dict) -> dict |
|
|
|
469
475
|
return None
|
|
470
476
|
|
|
471
477
|
|
|
478
|
+
# ── OIDC (Authorization Code + PKCE) ─────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _oidc_auth(profile: dict, auth_config: dict, quiet: bool = False) -> dict:
|
|
482
|
+
"""Handle OIDC auth -- cached token with form-encoded refresh."""
|
|
483
|
+
profile_name = profile.get("_name", "default")
|
|
484
|
+
token_cache = CACHE_DIR / f"{profile_name}_token.json"
|
|
485
|
+
|
|
486
|
+
if token_cache.exists():
|
|
487
|
+
try:
|
|
488
|
+
cached = json.loads(token_cache.read_text())
|
|
489
|
+
expires_at = cached.get("expires_at", 0)
|
|
490
|
+
|
|
491
|
+
# Valid token -- use it
|
|
492
|
+
if time.time() < (expires_at - 300):
|
|
493
|
+
return {"Authorization": f"Bearer {cached['access_token']}"}
|
|
494
|
+
|
|
495
|
+
# Try refresh (form-encoded, standard OIDC)
|
|
496
|
+
if "refresh_token" in cached:
|
|
497
|
+
verify = profile.get("verify_ssl", True) and get_verify_ssl()
|
|
498
|
+
refreshed = _oidc_refresh(auth_config, cached, verify=verify)
|
|
499
|
+
if refreshed:
|
|
500
|
+
refreshed["expires_at"] = time.time() + refreshed.get("expires_in", 300)
|
|
501
|
+
ensure_dirs()
|
|
502
|
+
token_cache.write_text(json.dumps(refreshed))
|
|
503
|
+
token_cache.chmod(0o600)
|
|
504
|
+
return {"Authorization": f"Bearer {refreshed['access_token']}"}
|
|
505
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
if not quiet:
|
|
509
|
+
console.print("[yellow]Token expired or missing. Run 'openapi-cli4ai login' to authenticate.[/yellow]")
|
|
510
|
+
raise typer.Exit(1)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _oidc_refresh(auth_config: dict, cached: dict, verify: bool = True) -> dict | None:
|
|
514
|
+
"""Refresh an OIDC token using form-encoded POST (standard OIDC spec)."""
|
|
515
|
+
token_url = auth_config.get("token_url", "")
|
|
516
|
+
client_id = auth_config.get("client_id", "")
|
|
517
|
+
if not token_url or not client_id:
|
|
518
|
+
return None
|
|
519
|
+
try:
|
|
520
|
+
with httpx.Client(verify=verify, timeout=30.0) as client:
|
|
521
|
+
resp = client.post(
|
|
522
|
+
token_url,
|
|
523
|
+
data={
|
|
524
|
+
"grant_type": "refresh_token",
|
|
525
|
+
"client_id": client_id,
|
|
526
|
+
"refresh_token": cached["refresh_token"],
|
|
527
|
+
},
|
|
528
|
+
)
|
|
529
|
+
if resp.status_code == 200:
|
|
530
|
+
return resp.json()
|
|
531
|
+
except Exception:
|
|
532
|
+
pass
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class _OIDCCallbackHandler(BaseHTTPRequestHandler):
|
|
537
|
+
"""HTTP handler that captures the OIDC authorization code callback."""
|
|
538
|
+
|
|
539
|
+
auth_code: str | None = None
|
|
540
|
+
error: str | None = None
|
|
541
|
+
expected_state: str | None = None
|
|
542
|
+
|
|
543
|
+
def do_GET(self) -> None:
|
|
544
|
+
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
545
|
+
|
|
546
|
+
# Validate state to prevent CSRF
|
|
547
|
+
received_state = params.get("state", [None])[0]
|
|
548
|
+
if _OIDCCallbackHandler.expected_state and received_state != _OIDCCallbackHandler.expected_state:
|
|
549
|
+
_OIDCCallbackHandler.error = "state_mismatch"
|
|
550
|
+
self.send_response(400)
|
|
551
|
+
self.send_header("Content-Type", "text/html")
|
|
552
|
+
self.end_headers()
|
|
553
|
+
self.wfile.write(
|
|
554
|
+
b"<html><body><h2>Login failed</h2><p>State mismatch - possible CSRF attack.</p></body></html>"
|
|
555
|
+
)
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
if "code" in params:
|
|
559
|
+
_OIDCCallbackHandler.auth_code = params["code"][0]
|
|
560
|
+
self.send_response(200)
|
|
561
|
+
self.send_header("Content-Type", "text/html")
|
|
562
|
+
self.end_headers()
|
|
563
|
+
self.wfile.write(
|
|
564
|
+
b"<html><body><h2>Login successful</h2>"
|
|
565
|
+
b"<p>You can close this tab and return to the terminal.</p>"
|
|
566
|
+
b"</body></html>"
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
_OIDCCallbackHandler.error = params.get("error", ["unknown"])[0]
|
|
570
|
+
self.send_response(400)
|
|
571
|
+
self.send_header("Content-Type", "text/html")
|
|
572
|
+
self.end_headers()
|
|
573
|
+
self.wfile.write(
|
|
574
|
+
f"<html><body><h2>Login failed</h2><p>{_OIDCCallbackHandler.error}</p></body></html>".encode()
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
578
|
+
pass # Suppress HTTP server logging
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _oidc_login(auth_config: dict, profile_name: str, no_browser: bool = False, verify: bool = True) -> None:
|
|
582
|
+
"""Run OIDC Authorization Code + PKCE flow.
|
|
583
|
+
|
|
584
|
+
With browser (default): opens browser, listens on localhost for callback.
|
|
585
|
+
Without browser (--no-browser): prints URL, user pastes redirect URL back.
|
|
586
|
+
"""
|
|
587
|
+
authorize_url = auth_config.get("authorize_url", "")
|
|
588
|
+
token_url = auth_config.get("token_url", "")
|
|
589
|
+
client_id = auth_config.get("client_id", "")
|
|
590
|
+
scopes = auth_config.get("scopes", "openid")
|
|
591
|
+
callback_port = auth_config.get("callback_port", 8484)
|
|
592
|
+
redirect_uri = auth_config.get("redirect_uri", f"http://localhost:{callback_port}/callback")
|
|
593
|
+
|
|
594
|
+
if not authorize_url or not token_url or not client_id:
|
|
595
|
+
console.print("[red]OIDC auth requires authorize_url, token_url, and client_id.[/red]")
|
|
596
|
+
raise typer.Exit(1)
|
|
597
|
+
|
|
598
|
+
# PKCE: generate code verifier + challenge
|
|
599
|
+
code_verifier = secrets.token_urlsafe(64)
|
|
600
|
+
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
|
|
601
|
+
state = secrets.token_urlsafe(32)
|
|
602
|
+
|
|
603
|
+
# Build authorization URL
|
|
604
|
+
auth_params = urllib.parse.urlencode(
|
|
605
|
+
{
|
|
606
|
+
"response_type": "code",
|
|
607
|
+
"client_id": client_id,
|
|
608
|
+
"redirect_uri": redirect_uri,
|
|
609
|
+
"scope": scopes,
|
|
610
|
+
"state": state,
|
|
611
|
+
"code_challenge": code_challenge,
|
|
612
|
+
"code_challenge_method": "S256",
|
|
613
|
+
}
|
|
614
|
+
)
|
|
615
|
+
full_auth_url = f"{authorize_url}?{auth_params}"
|
|
616
|
+
|
|
617
|
+
if no_browser:
|
|
618
|
+
auth_code = _oidc_login_no_browser(full_auth_url)
|
|
619
|
+
else:
|
|
620
|
+
auth_code = _oidc_login_browser(full_auth_url, callback_port, state)
|
|
621
|
+
|
|
622
|
+
# Exchange code for tokens
|
|
623
|
+
_oidc_exchange_code(
|
|
624
|
+
token_url=token_url,
|
|
625
|
+
client_id=client_id,
|
|
626
|
+
auth_code=auth_code,
|
|
627
|
+
redirect_uri=redirect_uri,
|
|
628
|
+
code_verifier=code_verifier,
|
|
629
|
+
profile_name=profile_name,
|
|
630
|
+
verify=verify,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _oidc_login_browser(full_auth_url: str, callback_port: int, state: str) -> str:
|
|
635
|
+
"""Open browser and listen for the OIDC callback on localhost."""
|
|
636
|
+
_OIDCCallbackHandler.auth_code = None
|
|
637
|
+
_OIDCCallbackHandler.error = None
|
|
638
|
+
_OIDCCallbackHandler.expected_state = state
|
|
639
|
+
server = HTTPServer(("127.0.0.1", callback_port), _OIDCCallbackHandler)
|
|
640
|
+
server.timeout = 120
|
|
641
|
+
|
|
642
|
+
console.print(f"[dim]Listening on http://localhost:{callback_port}/callback[/dim]")
|
|
643
|
+
console.print("[bold]Opening browser for login...[/bold]")
|
|
644
|
+
webbrowser.open(full_auth_url)
|
|
645
|
+
console.print("[dim]Waiting for callback (120s timeout)...[/dim]")
|
|
646
|
+
|
|
647
|
+
server.handle_request()
|
|
648
|
+
server.server_close()
|
|
649
|
+
|
|
650
|
+
if _OIDCCallbackHandler.error:
|
|
651
|
+
console.print(f"[red]OIDC error: {_OIDCCallbackHandler.error}[/red]")
|
|
652
|
+
raise typer.Exit(1)
|
|
653
|
+
if not _OIDCCallbackHandler.auth_code:
|
|
654
|
+
console.print("[red]No authorization code received.[/red]")
|
|
655
|
+
raise typer.Exit(1)
|
|
656
|
+
|
|
657
|
+
return _OIDCCallbackHandler.auth_code
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _oidc_login_no_browser(full_auth_url: str) -> str:
|
|
661
|
+
"""Print the auth URL, user pastes back the redirect URL containing the code."""
|
|
662
|
+
console.print("\n[bold]Open this URL in any browser to log in:[/bold]\n")
|
|
663
|
+
console.print(f" {full_auth_url}\n")
|
|
664
|
+
console.print(
|
|
665
|
+
"[dim]After login, your browser will redirect to a localhost URL.\n"
|
|
666
|
+
"Copy the full URL from your browser's address bar and paste it below.[/dim]\n"
|
|
667
|
+
)
|
|
668
|
+
redirect_input = typer.prompt("Paste the redirect URL")
|
|
669
|
+
|
|
670
|
+
# Extract the authorization code from the pasted URL
|
|
671
|
+
parsed = urllib.parse.urlparse(redirect_input.strip())
|
|
672
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
673
|
+
|
|
674
|
+
if "error" in params:
|
|
675
|
+
console.print(f"[red]OIDC error: {params['error'][0]}[/red]")
|
|
676
|
+
raise typer.Exit(1)
|
|
677
|
+
|
|
678
|
+
if "code" not in params:
|
|
679
|
+
console.print("[red]No authorization code found in the URL.[/red]")
|
|
680
|
+
console.print("[dim]Expected a URL like: http://localhost:.../callback?code=...&state=...[/dim]")
|
|
681
|
+
raise typer.Exit(1)
|
|
682
|
+
|
|
683
|
+
return params["code"][0]
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _oidc_exchange_code(
|
|
687
|
+
*,
|
|
688
|
+
token_url: str,
|
|
689
|
+
client_id: str,
|
|
690
|
+
auth_code: str,
|
|
691
|
+
redirect_uri: str,
|
|
692
|
+
code_verifier: str,
|
|
693
|
+
profile_name: str,
|
|
694
|
+
verify: bool = True,
|
|
695
|
+
) -> None:
|
|
696
|
+
"""Exchange an authorization code for tokens and cache them."""
|
|
697
|
+
try:
|
|
698
|
+
with httpx.Client(verify=verify, timeout=30.0) as client:
|
|
699
|
+
resp = client.post(
|
|
700
|
+
token_url,
|
|
701
|
+
data={
|
|
702
|
+
"grant_type": "authorization_code",
|
|
703
|
+
"client_id": client_id,
|
|
704
|
+
"code": auth_code,
|
|
705
|
+
"redirect_uri": redirect_uri,
|
|
706
|
+
"code_verifier": code_verifier,
|
|
707
|
+
},
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
if resp.status_code != 200:
|
|
711
|
+
console.print(f"[red]Token exchange failed ({resp.status_code}):[/red]")
|
|
712
|
+
console.print(resp.text)
|
|
713
|
+
raise typer.Exit(1)
|
|
714
|
+
|
|
715
|
+
token_data = resp.json()
|
|
716
|
+
if "expires_in" in token_data:
|
|
717
|
+
token_data["expires_at"] = time.time() + token_data["expires_in"]
|
|
718
|
+
elif "expires_at" not in token_data:
|
|
719
|
+
token_data["expires_at"] = time.time() + 86400
|
|
720
|
+
|
|
721
|
+
token_cache = CACHE_DIR / f"{profile_name}_token.json"
|
|
722
|
+
ensure_dirs()
|
|
723
|
+
token_cache.write_text(json.dumps(token_data))
|
|
724
|
+
token_cache.chmod(0o600)
|
|
725
|
+
|
|
726
|
+
console.print("[green]Logged in successfully![/green]")
|
|
727
|
+
console.print(f"[dim]Token cached at {token_cache}[/dim]")
|
|
728
|
+
|
|
729
|
+
except httpx.ConnectError:
|
|
730
|
+
console.print(f"[red]Cannot connect to {token_url}[/red]")
|
|
731
|
+
raise typer.Exit(1)
|
|
732
|
+
|
|
733
|
+
|
|
472
734
|
def _api_key_auth(auth_config: dict, quiet: bool = False) -> dict:
|
|
473
735
|
"""Handle API key auth via custom header."""
|
|
474
736
|
env_var = auth_config.get("env_var", "")
|
|
@@ -878,6 +1140,181 @@ def cmd_call(
|
|
|
878
1140
|
console.print(f"[dim]{elapsed:.2f}s[/dim]")
|
|
879
1141
|
|
|
880
1142
|
|
|
1143
|
+
# ── Commands: run ──────────────────────────────────────────────────────────────
|
|
1144
|
+
def _route_inputs(input_data: dict, parameters: list, has_request_body: bool) -> tuple[dict, dict, dict, dict | None]:
|
|
1145
|
+
"""Route flat input keys to path params, query params, headers, and body.
|
|
1146
|
+
|
|
1147
|
+
Returns (path_params, query_params, header_params, body).
|
|
1148
|
+
"""
|
|
1149
|
+
path_params = {}
|
|
1150
|
+
query_params = {}
|
|
1151
|
+
header_params = {}
|
|
1152
|
+
body_keys = {}
|
|
1153
|
+
|
|
1154
|
+
# Build a lookup of parameter names to their location
|
|
1155
|
+
param_map: dict[str, str] = {}
|
|
1156
|
+
for p in parameters:
|
|
1157
|
+
if isinstance(p, dict) and "name" in p:
|
|
1158
|
+
param_map[p["name"]] = p.get("in", "query")
|
|
1159
|
+
|
|
1160
|
+
for key, value in input_data.items():
|
|
1161
|
+
location = param_map.get(key)
|
|
1162
|
+
if location == "path":
|
|
1163
|
+
path_params[key] = value
|
|
1164
|
+
elif location == "query":
|
|
1165
|
+
query_params[key] = value
|
|
1166
|
+
elif location == "header":
|
|
1167
|
+
header_params[key] = value
|
|
1168
|
+
elif location:
|
|
1169
|
+
# cookie or other — treat as query
|
|
1170
|
+
query_params[key] = value
|
|
1171
|
+
else:
|
|
1172
|
+
# Not a declared parameter — goes into request body
|
|
1173
|
+
body_keys[key] = value
|
|
1174
|
+
|
|
1175
|
+
body = body_keys if body_keys else None
|
|
1176
|
+
# If there's a requestBody defined but no body keys collected,
|
|
1177
|
+
# and the entire input looks like it could be the body, send it all
|
|
1178
|
+
if has_request_body and not body and not param_map:
|
|
1179
|
+
body = input_data
|
|
1180
|
+
|
|
1181
|
+
return path_params, query_params, header_params, body
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
@app.command("run")
|
|
1185
|
+
def cmd_run(
|
|
1186
|
+
operation: Annotated[
|
|
1187
|
+
str, typer.Argument(help="Operation ID from the OpenAPI spec (e.g., findPetsByStatus, addPet)")
|
|
1188
|
+
],
|
|
1189
|
+
input_data: Annotated[
|
|
1190
|
+
Optional[str], typer.Option("--input", "-i", help="Input as JSON (keys auto-routed to path/query/body)")
|
|
1191
|
+
] = None,
|
|
1192
|
+
input_file: Annotated[Optional[str], typer.Option("--input-file", "-f", help="Read input from a JSON file")] = None,
|
|
1193
|
+
stream: Annotated[bool, typer.Option("--stream", help="Stream SSE response")] = False,
|
|
1194
|
+
raw: Annotated[bool, typer.Option("--raw", help="Print raw response without formatting")] = False,
|
|
1195
|
+
output_json_flag: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
1196
|
+
) -> None:
|
|
1197
|
+
"""Run an API operation by name. Inputs are auto-routed to the right place.
|
|
1198
|
+
|
|
1199
|
+
The spec defines where each parameter goes (path, query, header, body).
|
|
1200
|
+
You just pass a flat JSON object and the tool figures it out.
|
|
1201
|
+
|
|
1202
|
+
Examples:
|
|
1203
|
+
openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
|
|
1204
|
+
openapi-cli4ai run getPetById --input '{"petId": 123}'
|
|
1205
|
+
openapi-cli4ai run addPet --input '{"name": "Rex", "status": "available"}'
|
|
1206
|
+
openapi-cli4ai run addPet --input-file pet.json
|
|
1207
|
+
"""
|
|
1208
|
+
profile_name, profile = get_active_profile()
|
|
1209
|
+
spec = fetch_spec(profile)
|
|
1210
|
+
|
|
1211
|
+
# Look up the operation in the spec
|
|
1212
|
+
endpoint = extract_full_endpoint_schema(spec, operation)
|
|
1213
|
+
if not endpoint:
|
|
1214
|
+
# Try case-insensitive fuzzy match
|
|
1215
|
+
all_eps = extract_endpoint_summaries(spec)
|
|
1216
|
+
matches = [e for e in all_eps if e["operationId"].lower() == operation.lower()]
|
|
1217
|
+
if matches:
|
|
1218
|
+
endpoint = extract_full_endpoint_schema(spec, matches[0]["operationId"])
|
|
1219
|
+
|
|
1220
|
+
if not endpoint:
|
|
1221
|
+
console.print(f"[red]Operation '{operation}' not found in spec.[/red]")
|
|
1222
|
+
# Suggest similar operations
|
|
1223
|
+
all_eps = extract_endpoint_summaries(spec)
|
|
1224
|
+
op_lower = operation.lower()
|
|
1225
|
+
suggestions = [e["operationId"] for e in all_eps if op_lower in e["operationId"].lower()][:5]
|
|
1226
|
+
if suggestions:
|
|
1227
|
+
console.print("[dim]Did you mean:[/dim]")
|
|
1228
|
+
for s in suggestions:
|
|
1229
|
+
console.print(f" [cyan]{s}[/cyan]")
|
|
1230
|
+
raise typer.Exit(1)
|
|
1231
|
+
|
|
1232
|
+
# Parse input
|
|
1233
|
+
parsed_input = {}
|
|
1234
|
+
if input_file:
|
|
1235
|
+
fp = Path(input_file)
|
|
1236
|
+
if not fp.exists():
|
|
1237
|
+
console.print(f"[red]Input file not found: {input_file}[/red]")
|
|
1238
|
+
raise typer.Exit(1)
|
|
1239
|
+
try:
|
|
1240
|
+
parsed_input = json.loads(fp.read_text())
|
|
1241
|
+
except json.JSONDecodeError as e:
|
|
1242
|
+
console.print(f"[red]Invalid JSON in {input_file}: {e}[/red]")
|
|
1243
|
+
raise typer.Exit(1)
|
|
1244
|
+
elif input_data:
|
|
1245
|
+
try:
|
|
1246
|
+
parsed_input = json.loads(input_data)
|
|
1247
|
+
except json.JSONDecodeError as e:
|
|
1248
|
+
console.print(f"[red]Invalid JSON input: {e}[/red]")
|
|
1249
|
+
raise typer.Exit(1)
|
|
1250
|
+
|
|
1251
|
+
# Route inputs to the right places
|
|
1252
|
+
method = endpoint["method"]
|
|
1253
|
+
path_template = endpoint["path"]
|
|
1254
|
+
parameters = endpoint.get("parameters", [])
|
|
1255
|
+
has_request_body = endpoint.get("requestBody") is not None
|
|
1256
|
+
|
|
1257
|
+
path_params, query_params, header_params, json_body = _route_inputs(parsed_input, parameters, has_request_body)
|
|
1258
|
+
|
|
1259
|
+
# Substitute path parameters
|
|
1260
|
+
full_path = path_template
|
|
1261
|
+
for key, value in path_params.items():
|
|
1262
|
+
full_path = full_path.replace(f"{{{key}}}", str(value))
|
|
1263
|
+
|
|
1264
|
+
# Check for unresolved path params
|
|
1265
|
+
if "{" in full_path:
|
|
1266
|
+
import re as _re
|
|
1267
|
+
|
|
1268
|
+
missing = _re.findall(r"\{(\w+)\}", full_path)
|
|
1269
|
+
console.print(f"[red]Missing required path parameter(s): {', '.join(missing)}[/red]")
|
|
1270
|
+
console.print(f'[dim]Provide them in --input, e.g. --input \'{{"{missing[0]}": "value"}}\'[/dim]')
|
|
1271
|
+
raise typer.Exit(1)
|
|
1272
|
+
|
|
1273
|
+
# Build URL and make request
|
|
1274
|
+
base_url = profile["base_url"].rstrip("/")
|
|
1275
|
+
url = f"{base_url}{full_path}"
|
|
1276
|
+
|
|
1277
|
+
verify = profile.get("verify_ssl", True) and get_verify_ssl()
|
|
1278
|
+
headers = dict(profile.get("headers", {}))
|
|
1279
|
+
headers.update(get_auth_headers(profile))
|
|
1280
|
+
headers.update(header_params)
|
|
1281
|
+
|
|
1282
|
+
start_time = time.perf_counter()
|
|
1283
|
+
console.print(f"[dim]{method} {full_path}...[/dim]")
|
|
1284
|
+
|
|
1285
|
+
with httpx.Client(verify=verify, timeout=60.0, follow_redirects=True) as client:
|
|
1286
|
+
if stream:
|
|
1287
|
+
headers["Accept"] = "text/event-stream"
|
|
1288
|
+
with client.stream(
|
|
1289
|
+
method,
|
|
1290
|
+
url,
|
|
1291
|
+
json=json_body,
|
|
1292
|
+
params=query_params or None,
|
|
1293
|
+
headers=headers,
|
|
1294
|
+
) as response:
|
|
1295
|
+
if response.status_code >= 400:
|
|
1296
|
+
response.read()
|
|
1297
|
+
_display_error(
|
|
1298
|
+
response.json() if "json" in response.headers.get("content-type", "") else response.text,
|
|
1299
|
+
response.status_code,
|
|
1300
|
+
)
|
|
1301
|
+
raise typer.Exit(1)
|
|
1302
|
+
stream_sse(response)
|
|
1303
|
+
elapsed = time.perf_counter() - start_time
|
|
1304
|
+
console.print(f"[dim]Completed in {elapsed:.2f}s[/dim]")
|
|
1305
|
+
else:
|
|
1306
|
+
response = client.request(
|
|
1307
|
+
method,
|
|
1308
|
+
url,
|
|
1309
|
+
json=json_body,
|
|
1310
|
+
params=query_params or None,
|
|
1311
|
+
headers=headers,
|
|
1312
|
+
)
|
|
1313
|
+
elapsed = time.perf_counter() - start_time
|
|
1314
|
+
handle_response(response, raw=raw, json_output=output_json_flag)
|
|
1315
|
+
console.print(f"[dim]{elapsed:.2f}s[/dim]")
|
|
1316
|
+
|
|
1317
|
+
|
|
881
1318
|
# ── Commands: init ─────────────────────────────────────────────────────────────
|
|
882
1319
|
@app.command("init")
|
|
883
1320
|
def cmd_init(
|
|
@@ -887,7 +1324,7 @@ def cmd_init(
|
|
|
887
1324
|
Optional[str], typer.Option("--spec", "-s", help="Path to OpenAPI spec (auto-detected if omitted)")
|
|
888
1325
|
] = None,
|
|
889
1326
|
spec_url: Annotated[Optional[str], typer.Option("--spec-url", help="Full URL to OpenAPI spec file")] = None,
|
|
890
|
-
auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, api-key, basic, none")] = "none",
|
|
1327
|
+
auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, oidc, api-key, basic, none")] = "none",
|
|
891
1328
|
) -> None:
|
|
892
1329
|
"""Initialize a new API profile with guided setup.
|
|
893
1330
|
|
|
@@ -972,6 +1409,22 @@ def cmd_init(
|
|
|
972
1409
|
"password": "{password}",
|
|
973
1410
|
}
|
|
974
1411
|
console.print("[dim]Run 'openapi-cli4ai login --username <user>' to authenticate.[/dim]")
|
|
1412
|
+
elif auth_type == "oidc":
|
|
1413
|
+
authorize_url = typer.prompt("Authorization URL (full URL)")
|
|
1414
|
+
token_url = typer.prompt("Token URL (full URL)")
|
|
1415
|
+
client_id_val = typer.prompt("Client ID")
|
|
1416
|
+
scopes = typer.prompt("Scopes", default="openid")
|
|
1417
|
+
redirect_uri_val = typer.prompt("Redirect URI (or leave blank for localhost callback)", default="")
|
|
1418
|
+
if redirect_uri_val:
|
|
1419
|
+
profile["auth"]["redirect_uri"] = redirect_uri_val
|
|
1420
|
+
else:
|
|
1421
|
+
cb_port = typer.prompt("Local callback port", default="8484")
|
|
1422
|
+
profile["auth"]["callback_port"] = int(cb_port)
|
|
1423
|
+
profile["auth"]["authorize_url"] = authorize_url
|
|
1424
|
+
profile["auth"]["token_url"] = token_url
|
|
1425
|
+
profile["auth"]["client_id"] = client_id_val
|
|
1426
|
+
profile["auth"]["scopes"] = scopes
|
|
1427
|
+
console.print("[dim]Run 'openapi-cli4ai login' to authenticate via browser.[/dim]")
|
|
975
1428
|
elif auth_type == "api-key":
|
|
976
1429
|
env_var = typer.prompt("Environment variable for API key", default=f"{name.upper()}_API_KEY")
|
|
977
1430
|
header_name = typer.prompt("Header name", default="Authorization")
|
|
@@ -1031,13 +1484,21 @@ def cmd_login(
|
|
|
1031
1484
|
] = "",
|
|
1032
1485
|
password_file: Annotated[Optional[str], typer.Option("--password-file", help="Read password from file")] = None,
|
|
1033
1486
|
password_stdin: Annotated[bool, typer.Option("--password-stdin", help="Read password from stdin")] = False,
|
|
1487
|
+
no_browser: Annotated[
|
|
1488
|
+
bool,
|
|
1489
|
+
typer.Option("--no-browser", help="OIDC: print login URL instead of opening browser (for headless/SSH)"),
|
|
1490
|
+
] = False,
|
|
1034
1491
|
) -> None:
|
|
1035
1492
|
"""Login to an API that uses OAuth/token-endpoint authentication.
|
|
1036
1493
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1494
|
+
Supports two auth modes:
|
|
1495
|
+
- auth.type=bearer with token_endpoint: username/password grant
|
|
1496
|
+
- auth.type=oidc: Authorization Code + PKCE flow (browser or --no-browser)
|
|
1039
1497
|
|
|
1040
|
-
|
|
1498
|
+
For OIDC, use --no-browser on headless machines: prints the login URL,
|
|
1499
|
+
then prompts you to paste back the redirect URL after authenticating.
|
|
1500
|
+
|
|
1501
|
+
Password input methods (bearer mode, in priority order):
|
|
1041
1502
|
1. --password-file /path/to/file
|
|
1042
1503
|
2. --password-stdin (piped input)
|
|
1043
1504
|
3. --password flag (avoid for special characters)
|
|
@@ -1046,8 +1507,22 @@ def cmd_login(
|
|
|
1046
1507
|
profile_name, profile = get_active_profile()
|
|
1047
1508
|
auth_config = profile.get("auth", {})
|
|
1048
1509
|
|
|
1510
|
+
# OIDC flow
|
|
1511
|
+
if auth_config.get("type") == "oidc":
|
|
1512
|
+
verify = profile.get("verify_ssl", True) and get_verify_ssl()
|
|
1513
|
+
_oidc_login(auth_config, profile_name, no_browser=no_browser, verify=verify)
|
|
1514
|
+
# Try fetching the spec now that we're authenticated
|
|
1515
|
+
try:
|
|
1516
|
+
spec = fetch_spec(profile, refresh=True)
|
|
1517
|
+
endpoints = extract_endpoint_summaries(spec)
|
|
1518
|
+
spec_title = spec.get("info", {}).get("title", "Unknown")
|
|
1519
|
+
console.print(f"[green]Fetched spec: {spec_title} ({len(endpoints)} endpoints)[/green]")
|
|
1520
|
+
except (typer.Exit, Exception):
|
|
1521
|
+
pass
|
|
1522
|
+
return
|
|
1523
|
+
|
|
1049
1524
|
if auth_config.get("type") != "bearer" or not auth_config.get("token_endpoint"):
|
|
1050
|
-
console.print("[yellow]Login is for profiles with bearer auth + token_endpoint.[/yellow]")
|
|
1525
|
+
console.print("[yellow]Login is for profiles with bearer auth + token_endpoint, or oidc.[/yellow]")
|
|
1051
1526
|
console.print("[dim]If your API uses a static token or API key, set the environment variable instead.[/dim]")
|
|
1052
1527
|
raise typer.Exit(1)
|
|
1053
1528
|
|
|
@@ -1154,7 +1629,7 @@ def cmd_profile_add(
|
|
|
1154
1629
|
name: Annotated[str, typer.Argument(help="Profile name")],
|
|
1155
1630
|
url: Annotated[str, typer.Option("--url", "-u", help="Base URL of the API")] = "",
|
|
1156
1631
|
spec_path: Annotated[str, typer.Option("--spec", "-s", help="Path to OpenAPI spec")] = "/openapi.json",
|
|
1157
|
-
auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, api-key, basic, none")] = "none",
|
|
1632
|
+
auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, oidc, api-key, basic, none")] = "none",
|
|
1158
1633
|
) -> None:
|
|
1159
1634
|
"""Add a new API profile."""
|
|
1160
1635
|
if not url:
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Tests for OIDC Authorization Code + PKCE auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from click.exceptions import Exit as ClickExit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_oidc_auth_returns_cached_token(cli_module, tmp_config):
|
|
15
|
+
"""Should return cached token when it's still valid."""
|
|
16
|
+
mod, tmp_path, cache_dir = tmp_config
|
|
17
|
+
token_data = {
|
|
18
|
+
"access_token": "oidc-token-123",
|
|
19
|
+
"expires_at": time.time() + 3600,
|
|
20
|
+
}
|
|
21
|
+
token_file = cache_dir / "testprofile_token.json"
|
|
22
|
+
token_file.write_text(json.dumps(token_data))
|
|
23
|
+
token_file.chmod(0o600)
|
|
24
|
+
|
|
25
|
+
profile = {
|
|
26
|
+
"_name": "testprofile",
|
|
27
|
+
"auth": {
|
|
28
|
+
"type": "oidc",
|
|
29
|
+
"authorize_url": "https://idp.example.com/authorize",
|
|
30
|
+
"token_url": "https://idp.example.com/token",
|
|
31
|
+
"client_id": "my-client",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
headers = mod._oidc_auth(profile, profile["auth"])
|
|
35
|
+
assert headers == {"Authorization": "Bearer oidc-token-123"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_oidc_auth_expired_token_prompts_login(cli_module, tmp_config):
|
|
39
|
+
"""Should exit with login prompt when token is expired and no refresh token."""
|
|
40
|
+
mod, tmp_path, cache_dir = tmp_config
|
|
41
|
+
token_data = {
|
|
42
|
+
"access_token": "expired-token",
|
|
43
|
+
"expires_at": time.time() - 100,
|
|
44
|
+
}
|
|
45
|
+
token_file = cache_dir / "testprofile_token.json"
|
|
46
|
+
token_file.write_text(json.dumps(token_data))
|
|
47
|
+
|
|
48
|
+
profile = {
|
|
49
|
+
"_name": "testprofile",
|
|
50
|
+
"auth": {
|
|
51
|
+
"type": "oidc",
|
|
52
|
+
"authorize_url": "https://idp.example.com/authorize",
|
|
53
|
+
"token_url": "https://idp.example.com/token",
|
|
54
|
+
"client_id": "my-client",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
with pytest.raises((SystemExit, ClickExit)):
|
|
58
|
+
mod._oidc_auth(profile, profile["auth"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_oidc_auth_no_cache_prompts_login(cli_module, tmp_config):
|
|
62
|
+
"""Should exit with login prompt when no cached token exists."""
|
|
63
|
+
mod, tmp_path, cache_dir = tmp_config
|
|
64
|
+
profile = {
|
|
65
|
+
"_name": "testprofile",
|
|
66
|
+
"auth": {
|
|
67
|
+
"type": "oidc",
|
|
68
|
+
"authorize_url": "https://idp.example.com/authorize",
|
|
69
|
+
"token_url": "https://idp.example.com/token",
|
|
70
|
+
"client_id": "my-client",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
with pytest.raises((SystemExit, ClickExit)):
|
|
74
|
+
mod._oidc_auth(profile, profile["auth"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_oidc_refresh_missing_config(cli_module):
|
|
78
|
+
"""Should return None when token_url or client_id is missing."""
|
|
79
|
+
result = cli_module._oidc_refresh({"client_id": "x"}, {"refresh_token": "rt"})
|
|
80
|
+
assert result is None
|
|
81
|
+
result = cli_module._oidc_refresh({"token_url": "x"}, {"refresh_token": "rt"})
|
|
82
|
+
assert result is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_pkce_challenge_computation(cli_module):
|
|
86
|
+
"""Verify PKCE S256 challenge is computed correctly per RFC 7636."""
|
|
87
|
+
import secrets
|
|
88
|
+
|
|
89
|
+
verifier = secrets.token_urlsafe(64)
|
|
90
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
91
|
+
|
|
92
|
+
# Verify it's base64url without padding
|
|
93
|
+
assert "=" not in challenge
|
|
94
|
+
assert "+" not in challenge
|
|
95
|
+
assert "/" not in challenge
|
|
96
|
+
# Verify it decodes back to 32 bytes (SHA-256 output)
|
|
97
|
+
padded = challenge + "=" * (4 - len(challenge) % 4)
|
|
98
|
+
decoded = base64.urlsafe_b64decode(padded)
|
|
99
|
+
assert len(decoded) == 32
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_oidc_callback_handler_state_validation(cli_module):
|
|
103
|
+
"""The callback handler class should have expected_state attribute."""
|
|
104
|
+
handler_cls = cli_module._OIDCCallbackHandler
|
|
105
|
+
assert hasattr(handler_cls, "expected_state")
|
|
106
|
+
assert hasattr(handler_cls, "auth_code")
|
|
107
|
+
assert hasattr(handler_cls, "error")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_get_auth_headers_dispatches_oidc(cli_module, tmp_config):
|
|
111
|
+
"""get_auth_headers should dispatch to _oidc_auth for type=oidc."""
|
|
112
|
+
mod, tmp_path, cache_dir = tmp_config
|
|
113
|
+
token_data = {
|
|
114
|
+
"access_token": "dispatched-token",
|
|
115
|
+
"expires_at": time.time() + 3600,
|
|
116
|
+
}
|
|
117
|
+
token_file = cache_dir / "testprofile_token.json"
|
|
118
|
+
token_file.write_text(json.dumps(token_data))
|
|
119
|
+
|
|
120
|
+
profile = {
|
|
121
|
+
"_name": "testprofile",
|
|
122
|
+
"auth": {
|
|
123
|
+
"type": "oidc",
|
|
124
|
+
"authorize_url": "https://idp.example.com/authorize",
|
|
125
|
+
"token_url": "https://idp.example.com/token",
|
|
126
|
+
"client_id": "my-client",
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
headers = mod.get_auth_headers(profile)
|
|
130
|
+
assert headers == {"Authorization": "Bearer dispatched-token"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Tests for the run command and input routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_route_inputs_path_params(cli_module):
|
|
7
|
+
"""Should route path parameters correctly."""
|
|
8
|
+
parameters = [
|
|
9
|
+
{"name": "petId", "in": "path"},
|
|
10
|
+
{"name": "status", "in": "query"},
|
|
11
|
+
]
|
|
12
|
+
input_data = {"petId": 123, "status": "available"}
|
|
13
|
+
|
|
14
|
+
path_params, query_params, header_params, body = cli_module._route_inputs(
|
|
15
|
+
input_data, parameters, has_request_body=False
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
assert path_params == {"petId": 123}
|
|
19
|
+
assert query_params == {"status": "available"}
|
|
20
|
+
assert header_params == {}
|
|
21
|
+
assert body is None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_route_inputs_query_params(cli_module):
|
|
25
|
+
"""Should route query parameters correctly."""
|
|
26
|
+
parameters = [
|
|
27
|
+
{"name": "status", "in": "query"},
|
|
28
|
+
{"name": "limit", "in": "query"},
|
|
29
|
+
]
|
|
30
|
+
input_data = {"status": "available", "limit": 10}
|
|
31
|
+
|
|
32
|
+
path_params, query_params, header_params, body = cli_module._route_inputs(
|
|
33
|
+
input_data, parameters, has_request_body=False
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
assert path_params == {}
|
|
37
|
+
assert query_params == {"status": "available", "limit": 10}
|
|
38
|
+
assert body is None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_route_inputs_header_params(cli_module):
|
|
42
|
+
"""Should route header parameters correctly."""
|
|
43
|
+
parameters = [
|
|
44
|
+
{"name": "X-Request-Id", "in": "header"},
|
|
45
|
+
]
|
|
46
|
+
input_data = {"X-Request-Id": "abc-123", "name": "Rex"}
|
|
47
|
+
|
|
48
|
+
path_params, query_params, header_params, body = cli_module._route_inputs(
|
|
49
|
+
input_data, parameters, has_request_body=True
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
assert header_params == {"X-Request-Id": "abc-123"}
|
|
53
|
+
assert body == {"name": "Rex"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_route_inputs_body_from_undeclared_keys(cli_module):
|
|
57
|
+
"""Keys not matching any parameter should go to body."""
|
|
58
|
+
parameters = [
|
|
59
|
+
{"name": "petId", "in": "path"},
|
|
60
|
+
]
|
|
61
|
+
input_data = {"petId": 1, "name": "Rex", "status": "available"}
|
|
62
|
+
|
|
63
|
+
path_params, query_params, header_params, body = cli_module._route_inputs(
|
|
64
|
+
input_data, parameters, has_request_body=True
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert path_params == {"petId": 1}
|
|
68
|
+
assert body == {"name": "Rex", "status": "available"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_route_inputs_all_body_when_no_params(cli_module):
|
|
72
|
+
"""When no parameters declared and requestBody exists, send all as body."""
|
|
73
|
+
parameters = []
|
|
74
|
+
input_data = {"name": "Rex", "status": "available"}
|
|
75
|
+
|
|
76
|
+
path_params, query_params, header_params, body = cli_module._route_inputs(
|
|
77
|
+
input_data, parameters, has_request_body=True
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert body == {"name": "Rex", "status": "available"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_route_inputs_empty_input(cli_module):
|
|
84
|
+
"""Should handle empty input gracefully."""
|
|
85
|
+
parameters = [
|
|
86
|
+
{"name": "status", "in": "query"},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
path_params, query_params, header_params, body = cli_module._route_inputs({}, parameters, has_request_body=False)
|
|
90
|
+
|
|
91
|
+
assert path_params == {}
|
|
92
|
+
assert query_params == {}
|
|
93
|
+
assert header_params == {}
|
|
94
|
+
assert body is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_route_inputs_no_body_when_not_declared(cli_module):
|
|
98
|
+
"""Undeclared keys without requestBody should still go to body dict."""
|
|
99
|
+
parameters = []
|
|
100
|
+
input_data = {"name": "Rex"}
|
|
101
|
+
|
|
102
|
+
path_params, query_params, header_params, body = cli_module._route_inputs(
|
|
103
|
+
input_data, parameters, has_request_body=False
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# No requestBody and no param_map — body_keys has content but not the fallback path
|
|
107
|
+
assert body == {"name": "Rex"}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_route_inputs_mixed_all_locations(cli_module):
|
|
111
|
+
"""Should correctly split input across path, query, header, and body."""
|
|
112
|
+
parameters = [
|
|
113
|
+
{"name": "userId", "in": "path"},
|
|
114
|
+
{"name": "format", "in": "query"},
|
|
115
|
+
{"name": "X-Trace-Id", "in": "header"},
|
|
116
|
+
]
|
|
117
|
+
input_data = {
|
|
118
|
+
"userId": 42,
|
|
119
|
+
"format": "json",
|
|
120
|
+
"X-Trace-Id": "trace-abc",
|
|
121
|
+
"email": "user@example.com",
|
|
122
|
+
"name": "Alice",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
path_params, query_params, header_params, body = cli_module._route_inputs(
|
|
126
|
+
input_data, parameters, has_request_body=True
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
assert path_params == {"userId": 42}
|
|
130
|
+
assert query_params == {"format": "json"}
|
|
131
|
+
assert header_params == {"X-Trace-Id": "trace-abc"}
|
|
132
|
+
assert body == {"email": "user@example.com", "name": "Alice"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_extract_full_endpoint_schema_has_parameters(cli_module, petstore_spec):
|
|
136
|
+
"""Should extract parameters with 'in' locations from petstore spec."""
|
|
137
|
+
endpoint = cli_module.extract_full_endpoint_schema(petstore_spec, "findPetsByStatus")
|
|
138
|
+
assert endpoint is not None
|
|
139
|
+
assert endpoint["method"] == "GET"
|
|
140
|
+
assert endpoint["path"] == "/pet/findByStatus"
|
|
141
|
+
# findPetsByStatus has a 'status' query parameter
|
|
142
|
+
param_names = [p["name"] for p in endpoint["parameters"]]
|
|
143
|
+
assert "status" in param_names
|
|
144
|
+
status_param = next(p for p in endpoint["parameters"] if p["name"] == "status")
|
|
145
|
+
assert status_param["in"] == "query"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_extract_full_endpoint_schema_path_param(cli_module, petstore_spec):
|
|
149
|
+
"""Should extract path parameters from petstore spec."""
|
|
150
|
+
endpoint = cli_module.extract_full_endpoint_schema(petstore_spec, "getPetById")
|
|
151
|
+
assert endpoint is not None
|
|
152
|
+
assert endpoint["method"] == "GET"
|
|
153
|
+
assert "/pet/{petId}" == endpoint["path"]
|
|
154
|
+
param_names = [p["name"] for p in endpoint["parameters"]]
|
|
155
|
+
assert "petId" in param_names
|
|
156
|
+
pet_id_param = next(p for p in endpoint["parameters"] if p["name"] == "petId")
|
|
157
|
+
assert pet_id_param["in"] == "path"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_extract_full_endpoint_schema_with_request_body(cli_module, petstore_spec):
|
|
161
|
+
"""Should detect requestBody on POST endpoints."""
|
|
162
|
+
endpoint = cli_module.extract_full_endpoint_schema(petstore_spec, "addPet")
|
|
163
|
+
assert endpoint is not None
|
|
164
|
+
assert endpoint["method"] == "POST"
|
|
165
|
+
assert endpoint["requestBody"] is not None
|
openapi_cli4ai-0.1.1/demo.gif
DELETED
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|