ramp-tool-openapi 0.1.1__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.
- ramp_tool_openapi-0.1.1/.gitignore +8 -0
- ramp_tool_openapi-0.1.1/LICENSE +9 -0
- ramp_tool_openapi-0.1.1/PKG-INFO +299 -0
- ramp_tool_openapi-0.1.1/README.md +282 -0
- ramp_tool_openapi-0.1.1/pyproject.toml +40 -0
- ramp_tool_openapi-0.1.1/ramp_tool_openapi/__init__.py +15 -0
- ramp_tool_openapi-0.1.1/ramp_tool_openapi/models.py +68 -0
- ramp_tool_openapi-0.1.1/ramp_tool_openapi/parser.py +443 -0
- ramp_tool_openapi-0.1.1/ramp_tool_openapi/py.typed +0 -0
- ramp_tool_openapi-0.1.1/ramp_tool_openapi/schema.py +35 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Ramp Business Corporation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ramp-tool-openapi
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Shared OpenAPI normalization for Ramp tool surfaces
|
|
5
|
+
Author-email: Ramp <developer@ramp.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: openapi,tooling
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# ramp-tool-openapi
|
|
19
|
+
|
|
20
|
+
Shared, dependency-free OpenAPI normalization for Ramp tool surfaces.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ramp-tool-openapi
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The current package requires Python 3.11 or newer.
|
|
29
|
+
|
|
30
|
+
## What is this?
|
|
31
|
+
|
|
32
|
+
`ramp-tool-openapi` is the shared normalization boundary between Ramp OpenAPI
|
|
33
|
+
specifications and downstream tool adapters. It accepts an already-loaded OpenAPI
|
|
34
|
+
document and returns neutral `ParsedOperation` objects that consumers translate
|
|
35
|
+
into their own representations.
|
|
36
|
+
|
|
37
|
+
This gives Ramp tool consumers:
|
|
38
|
+
|
|
39
|
+
- one implementation of shared OpenAPI parsing rules
|
|
40
|
+
- consistent local `$ref`, parameter, request, response, operation-level OAuth
|
|
41
|
+
scope, and extension handling
|
|
42
|
+
- stable operation identity for paths that support multiple HTTP methods
|
|
43
|
+
- a dependency-free intermediate representation
|
|
44
|
+
- freedom to keep product-specific behavior in each downstream adapter
|
|
45
|
+
|
|
46
|
+
## Core concepts
|
|
47
|
+
|
|
48
|
+
### Neutral parsed operations
|
|
49
|
+
|
|
50
|
+
The main entry point returns the package's typed intermediate representation:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from ramp_tool_openapi import (
|
|
54
|
+
ParsedOperation,
|
|
55
|
+
ParsedParameter,
|
|
56
|
+
ParsedRequestBody,
|
|
57
|
+
ParsedResponse,
|
|
58
|
+
parse_openapi_operations,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
operations: tuple[ParsedOperation, ...] = parse_openapi_operations(
|
|
62
|
+
spec,
|
|
63
|
+
path_prefix="/developer/v1/agent-tools/",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
operation: ParsedOperation = operations[0]
|
|
67
|
+
parameters: tuple[ParsedParameter, ...] = operation.parameters
|
|
68
|
+
request_body: ParsedRequestBody | None = operation.request_body
|
|
69
|
+
response: ParsedResponse | None = operation.response
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The intended data flow is:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
OpenAPI specification
|
|
76
|
+
-> parse_openapi_operations
|
|
77
|
+
-> tuple[ParsedOperation, ...]
|
|
78
|
+
-> downstream CLI or MCP adapter
|
|
79
|
+
-> consumer-specific tool model
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Stable operation identity
|
|
83
|
+
|
|
84
|
+
`ParsedOperation.operation_key` combines the normalized HTTP method and path:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
get /developer/v1/agent-tools/procurement-draft
|
|
88
|
+
post /developer/v1/agent-tools/procurement-draft
|
|
89
|
+
delete /developer/v1/agent-tools/procurement-draft
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This prevents operations on the same path from overwriting one another.
|
|
93
|
+
|
|
94
|
+
### Shared normalization, adapter-owned policy
|
|
95
|
+
|
|
96
|
+
This package owns OpenAPI facts that must remain consistent across consumers:
|
|
97
|
+
|
|
98
|
+
- operation enumeration and optional path-prefix filtering
|
|
99
|
+
- local reference resolution
|
|
100
|
+
- path-level and operation-level parameter merging
|
|
101
|
+
- request and response media schema extraction
|
|
102
|
+
- operation-level OAuth scope and `x-*` extension preservation
|
|
103
|
+
- raw tag and `x-platforms` metadata preservation
|
|
104
|
+
- stable `(method, path)` operation identity
|
|
105
|
+
|
|
106
|
+
Downstream consumers continue to own product and execution policy.
|
|
107
|
+
|
|
108
|
+
`ramp-cli` owns:
|
|
109
|
+
|
|
110
|
+
- Click parameter classification
|
|
111
|
+
- synthetic commands and registry behavior
|
|
112
|
+
- CLI-visible names and aliases
|
|
113
|
+
- final category and tag selection
|
|
114
|
+
- CLI platform visibility defaults
|
|
115
|
+
|
|
116
|
+
`ramp-mcp-remote` owns:
|
|
117
|
+
|
|
118
|
+
- Pydantic model generation
|
|
119
|
+
- FastAPI route registration
|
|
120
|
+
- MCP-visible names
|
|
121
|
+
- path rendering and request execution
|
|
122
|
+
- MCP platform visibility defaults
|
|
123
|
+
|
|
124
|
+
If both consumers need to derive the same fact from `raw_operation`, that fact
|
|
125
|
+
should generally become a typed field on `ParsedOperation` instead. This keeps
|
|
126
|
+
shared parsing logic from drifting back into the adapters.
|
|
127
|
+
|
|
128
|
+
## Supported OpenAPI behavior
|
|
129
|
+
|
|
130
|
+
The parser currently supports:
|
|
131
|
+
|
|
132
|
+
- `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` operations
|
|
133
|
+
- path, query, header, and cookie parameters
|
|
134
|
+
- path-level parameter inheritance
|
|
135
|
+
- operation-level parameter overrides matched by `(location, name)`
|
|
136
|
+
- local component references for parameters, request bodies, and responses
|
|
137
|
+
- JSON and multipart request schemas, preferring `application/json`
|
|
138
|
+
- the first successful response, in sorted status-code order, with an
|
|
139
|
+
`application/json` schema
|
|
140
|
+
- operation-level OAuth scopes declared under each operation's
|
|
141
|
+
`security[*].oauth2`
|
|
142
|
+
- raw OpenAPI tags
|
|
143
|
+
- string and list forms of `x-platforms`
|
|
144
|
+
- `x-alias` and arbitrary operation-level `x-*` extensions
|
|
145
|
+
|
|
146
|
+
Schemas are preserved as dictionaries. The package does not recursively
|
|
147
|
+
dereference schemas or generate Python models from them.
|
|
148
|
+
|
|
149
|
+
Operation-level `security` overrides document-level security. When an operation
|
|
150
|
+
omits `security`, document-level requirements are inherited; an explicit empty
|
|
151
|
+
array opts the operation out of those requirements.
|
|
152
|
+
|
|
153
|
+
## API reference
|
|
154
|
+
|
|
155
|
+
The intentionally narrow public API is:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from ramp_tool_openapi import (
|
|
159
|
+
ParameterLocation,
|
|
160
|
+
ParsedOperation,
|
|
161
|
+
ParsedParameter,
|
|
162
|
+
ParsedRequestBody,
|
|
163
|
+
ParsedResponse,
|
|
164
|
+
parse_openapi_operations,
|
|
165
|
+
resolve_local_ref,
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `parse_openapi_operations`
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
def parse_openapi_operations(
|
|
173
|
+
spec: Mapping[str, Any],
|
|
174
|
+
*,
|
|
175
|
+
path_prefix: str | None = None,
|
|
176
|
+
) -> tuple[ParsedOperation, ...]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Normalizes the supported operations in an already-loaded OpenAPI document.
|
|
180
|
+
|
|
181
|
+
**Arguments**
|
|
182
|
+
|
|
183
|
+
- `spec`: OpenAPI document represented as a Python mapping.
|
|
184
|
+
- `path_prefix`: Optional prefix used to include only matching paths.
|
|
185
|
+
|
|
186
|
+
**Returns**
|
|
187
|
+
|
|
188
|
+
A tuple of `ParsedOperation` objects in document order. An empty tuple is
|
|
189
|
+
returned when the document does not contain a valid `paths` mapping or no paths
|
|
190
|
+
match `path_prefix`.
|
|
191
|
+
|
|
192
|
+
**Raises**
|
|
193
|
+
|
|
194
|
+
- `ValueError` when a parameter, request-body, or response reference is remote or cyclic.
|
|
195
|
+
- `KeyError` when a local reference cannot be resolved.
|
|
196
|
+
- `TypeError` when a reference does not resolve to an OpenAPI object.
|
|
197
|
+
|
|
198
|
+
### `resolve_local_ref`
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
def resolve_local_ref(
|
|
202
|
+
ref: str,
|
|
203
|
+
spec: Mapping[str, Any],
|
|
204
|
+
) -> Mapping[str, Any]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Resolves a mapping-only local OpenAPI reference path against an already-decoded
|
|
208
|
+
document. Paths must begin with `#/`; reference tokens support JSON Pointer's
|
|
209
|
+
`~0` and `~1` escapes. URI-fragment percent-decoding and array traversal are not
|
|
210
|
+
supported.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from ramp_tool_openapi import resolve_local_ref
|
|
214
|
+
|
|
215
|
+
schema = resolve_local_ref(
|
|
216
|
+
"#/components/schemas/CreateCardRequest",
|
|
217
|
+
spec,
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Arguments**
|
|
222
|
+
|
|
223
|
+
- `ref`: Mapping-only local reference path beginning with `#/`.
|
|
224
|
+
- `spec`: OpenAPI document containing the referenced object.
|
|
225
|
+
|
|
226
|
+
**Returns**
|
|
227
|
+
|
|
228
|
+
The referenced OpenAPI object as a mapping.
|
|
229
|
+
|
|
230
|
+
**Raises**
|
|
231
|
+
|
|
232
|
+
- `ValueError` when `ref` is not local.
|
|
233
|
+
- `KeyError` when `ref` cannot be resolved.
|
|
234
|
+
- `TypeError` when `ref` does not resolve to an OpenAPI object.
|
|
235
|
+
|
|
236
|
+
Remote references are not supported.
|
|
237
|
+
|
|
238
|
+
### Returned models
|
|
239
|
+
|
|
240
|
+
The parser returns frozen dataclass models. Their schema and extension values
|
|
241
|
+
remain dictionaries so downstream adapters can consume the original OpenAPI
|
|
242
|
+
data without taking a dependency on another schema library.
|
|
243
|
+
|
|
244
|
+
| Model | Purpose | Fields |
|
|
245
|
+
| --- | --- | --- |
|
|
246
|
+
| `ParsedOperation` | One normalized HTTP operation | `operation_key`, `path`, `method`, `operation_id`, `summary`, `description`, `tags`, `platforms`, `alias`, `scopes`, `extensions`, `parameters`, `request_body`, `response`, `raw_operation` |
|
|
247
|
+
| `ParsedParameter` | One path, query, header, or cookie parameter | `name`, `location`, `required`, `schema`, `description` |
|
|
248
|
+
| `ParsedRequestBody` | The selected request media type and schema | `content_type`, `schema`, `schema_name`, `required` |
|
|
249
|
+
| `ParsedResponse` | The selected successful response and schema | `status_code`, `content_type`, `schema`, `schema_name` |
|
|
250
|
+
| `ParameterLocation` | Supported parameter location type | `"path"`, `"query"`, `"header"`, or `"cookie"` |
|
|
251
|
+
|
|
252
|
+
Internal parser helpers, including underscore-prefixed functions, are
|
|
253
|
+
implementation details and should not be imported by downstream consumers.
|
|
254
|
+
|
|
255
|
+
## Security model
|
|
256
|
+
|
|
257
|
+
This library accepts an already-decoded OpenAPI mapping. It does not parse YAML,
|
|
258
|
+
read files, fetch remote references, execute requests, or enforce authorization.
|
|
259
|
+
Only local references beginning with `#/` are supported.
|
|
260
|
+
|
|
261
|
+
`ParsedOperation.security_requirements` preserves OpenAPI's boolean structure: the
|
|
262
|
+
outer tuple contains alternatives, while every scheme inside one alternative must
|
|
263
|
+
be satisfied. `ParsedOperation.scopes` is a convenience union of scopes from OAuth2
|
|
264
|
+
schemes and must not be used as an authorization decision.
|
|
265
|
+
|
|
266
|
+
Operation paths must be absolute and canonical. Percent-encoded paths, backslashes,
|
|
267
|
+
control characters, and `.` or `..` path segments are rejected. `path_prefix`
|
|
268
|
+
selects operations but is not a replacement for authorization in a caller.
|
|
269
|
+
|
|
270
|
+
Reference-object traversal is bounded by `max_reference_depth`, which defaults to
|
|
271
|
+
100. Applications accepting untrusted documents should also enforce an input-size
|
|
272
|
+
limit before decoding JSON.
|
|
273
|
+
|
|
274
|
+
## Non-goals
|
|
275
|
+
|
|
276
|
+
This package is not a general-purpose OpenAPI framework. It does not own:
|
|
277
|
+
|
|
278
|
+
- OpenAPI document loading or full-spec validation
|
|
279
|
+
- remote `$ref` resolution or recursive schema dereferencing
|
|
280
|
+
- CLI command or Pydantic model generation
|
|
281
|
+
- CLI- or MCP-visible naming
|
|
282
|
+
- platform visibility defaults
|
|
283
|
+
- category or tag selection
|
|
284
|
+
- route or command registration
|
|
285
|
+
- request serialization or execution
|
|
286
|
+
|
|
287
|
+
## Development
|
|
288
|
+
|
|
289
|
+
Run the package tests with:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
uv run --group test python -m pytest -q
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Status
|
|
296
|
+
|
|
297
|
+
The initial `0.x` API is provisional until both `ramp-cli` and
|
|
298
|
+
`ramp-mcp-remote` have migrated to the shared normalization layer. Add links to
|
|
299
|
+
both consumer migration PRs here before treating the API as stable.
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# ramp-tool-openapi
|
|
2
|
+
|
|
3
|
+
Shared, dependency-free OpenAPI normalization for Ramp tool surfaces.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ramp-tool-openapi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The current package requires Python 3.11 or newer.
|
|
12
|
+
|
|
13
|
+
## What is this?
|
|
14
|
+
|
|
15
|
+
`ramp-tool-openapi` is the shared normalization boundary between Ramp OpenAPI
|
|
16
|
+
specifications and downstream tool adapters. It accepts an already-loaded OpenAPI
|
|
17
|
+
document and returns neutral `ParsedOperation` objects that consumers translate
|
|
18
|
+
into their own representations.
|
|
19
|
+
|
|
20
|
+
This gives Ramp tool consumers:
|
|
21
|
+
|
|
22
|
+
- one implementation of shared OpenAPI parsing rules
|
|
23
|
+
- consistent local `$ref`, parameter, request, response, operation-level OAuth
|
|
24
|
+
scope, and extension handling
|
|
25
|
+
- stable operation identity for paths that support multiple HTTP methods
|
|
26
|
+
- a dependency-free intermediate representation
|
|
27
|
+
- freedom to keep product-specific behavior in each downstream adapter
|
|
28
|
+
|
|
29
|
+
## Core concepts
|
|
30
|
+
|
|
31
|
+
### Neutral parsed operations
|
|
32
|
+
|
|
33
|
+
The main entry point returns the package's typed intermediate representation:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from ramp_tool_openapi import (
|
|
37
|
+
ParsedOperation,
|
|
38
|
+
ParsedParameter,
|
|
39
|
+
ParsedRequestBody,
|
|
40
|
+
ParsedResponse,
|
|
41
|
+
parse_openapi_operations,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
operations: tuple[ParsedOperation, ...] = parse_openapi_operations(
|
|
45
|
+
spec,
|
|
46
|
+
path_prefix="/developer/v1/agent-tools/",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
operation: ParsedOperation = operations[0]
|
|
50
|
+
parameters: tuple[ParsedParameter, ...] = operation.parameters
|
|
51
|
+
request_body: ParsedRequestBody | None = operation.request_body
|
|
52
|
+
response: ParsedResponse | None = operation.response
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The intended data flow is:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
OpenAPI specification
|
|
59
|
+
-> parse_openapi_operations
|
|
60
|
+
-> tuple[ParsedOperation, ...]
|
|
61
|
+
-> downstream CLI or MCP adapter
|
|
62
|
+
-> consumer-specific tool model
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Stable operation identity
|
|
66
|
+
|
|
67
|
+
`ParsedOperation.operation_key` combines the normalized HTTP method and path:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
get /developer/v1/agent-tools/procurement-draft
|
|
71
|
+
post /developer/v1/agent-tools/procurement-draft
|
|
72
|
+
delete /developer/v1/agent-tools/procurement-draft
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This prevents operations on the same path from overwriting one another.
|
|
76
|
+
|
|
77
|
+
### Shared normalization, adapter-owned policy
|
|
78
|
+
|
|
79
|
+
This package owns OpenAPI facts that must remain consistent across consumers:
|
|
80
|
+
|
|
81
|
+
- operation enumeration and optional path-prefix filtering
|
|
82
|
+
- local reference resolution
|
|
83
|
+
- path-level and operation-level parameter merging
|
|
84
|
+
- request and response media schema extraction
|
|
85
|
+
- operation-level OAuth scope and `x-*` extension preservation
|
|
86
|
+
- raw tag and `x-platforms` metadata preservation
|
|
87
|
+
- stable `(method, path)` operation identity
|
|
88
|
+
|
|
89
|
+
Downstream consumers continue to own product and execution policy.
|
|
90
|
+
|
|
91
|
+
`ramp-cli` owns:
|
|
92
|
+
|
|
93
|
+
- Click parameter classification
|
|
94
|
+
- synthetic commands and registry behavior
|
|
95
|
+
- CLI-visible names and aliases
|
|
96
|
+
- final category and tag selection
|
|
97
|
+
- CLI platform visibility defaults
|
|
98
|
+
|
|
99
|
+
`ramp-mcp-remote` owns:
|
|
100
|
+
|
|
101
|
+
- Pydantic model generation
|
|
102
|
+
- FastAPI route registration
|
|
103
|
+
- MCP-visible names
|
|
104
|
+
- path rendering and request execution
|
|
105
|
+
- MCP platform visibility defaults
|
|
106
|
+
|
|
107
|
+
If both consumers need to derive the same fact from `raw_operation`, that fact
|
|
108
|
+
should generally become a typed field on `ParsedOperation` instead. This keeps
|
|
109
|
+
shared parsing logic from drifting back into the adapters.
|
|
110
|
+
|
|
111
|
+
## Supported OpenAPI behavior
|
|
112
|
+
|
|
113
|
+
The parser currently supports:
|
|
114
|
+
|
|
115
|
+
- `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` operations
|
|
116
|
+
- path, query, header, and cookie parameters
|
|
117
|
+
- path-level parameter inheritance
|
|
118
|
+
- operation-level parameter overrides matched by `(location, name)`
|
|
119
|
+
- local component references for parameters, request bodies, and responses
|
|
120
|
+
- JSON and multipart request schemas, preferring `application/json`
|
|
121
|
+
- the first successful response, in sorted status-code order, with an
|
|
122
|
+
`application/json` schema
|
|
123
|
+
- operation-level OAuth scopes declared under each operation's
|
|
124
|
+
`security[*].oauth2`
|
|
125
|
+
- raw OpenAPI tags
|
|
126
|
+
- string and list forms of `x-platforms`
|
|
127
|
+
- `x-alias` and arbitrary operation-level `x-*` extensions
|
|
128
|
+
|
|
129
|
+
Schemas are preserved as dictionaries. The package does not recursively
|
|
130
|
+
dereference schemas or generate Python models from them.
|
|
131
|
+
|
|
132
|
+
Operation-level `security` overrides document-level security. When an operation
|
|
133
|
+
omits `security`, document-level requirements are inherited; an explicit empty
|
|
134
|
+
array opts the operation out of those requirements.
|
|
135
|
+
|
|
136
|
+
## API reference
|
|
137
|
+
|
|
138
|
+
The intentionally narrow public API is:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from ramp_tool_openapi import (
|
|
142
|
+
ParameterLocation,
|
|
143
|
+
ParsedOperation,
|
|
144
|
+
ParsedParameter,
|
|
145
|
+
ParsedRequestBody,
|
|
146
|
+
ParsedResponse,
|
|
147
|
+
parse_openapi_operations,
|
|
148
|
+
resolve_local_ref,
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `parse_openapi_operations`
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
def parse_openapi_operations(
|
|
156
|
+
spec: Mapping[str, Any],
|
|
157
|
+
*,
|
|
158
|
+
path_prefix: str | None = None,
|
|
159
|
+
) -> tuple[ParsedOperation, ...]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Normalizes the supported operations in an already-loaded OpenAPI document.
|
|
163
|
+
|
|
164
|
+
**Arguments**
|
|
165
|
+
|
|
166
|
+
- `spec`: OpenAPI document represented as a Python mapping.
|
|
167
|
+
- `path_prefix`: Optional prefix used to include only matching paths.
|
|
168
|
+
|
|
169
|
+
**Returns**
|
|
170
|
+
|
|
171
|
+
A tuple of `ParsedOperation` objects in document order. An empty tuple is
|
|
172
|
+
returned when the document does not contain a valid `paths` mapping or no paths
|
|
173
|
+
match `path_prefix`.
|
|
174
|
+
|
|
175
|
+
**Raises**
|
|
176
|
+
|
|
177
|
+
- `ValueError` when a parameter, request-body, or response reference is remote or cyclic.
|
|
178
|
+
- `KeyError` when a local reference cannot be resolved.
|
|
179
|
+
- `TypeError` when a reference does not resolve to an OpenAPI object.
|
|
180
|
+
|
|
181
|
+
### `resolve_local_ref`
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
def resolve_local_ref(
|
|
185
|
+
ref: str,
|
|
186
|
+
spec: Mapping[str, Any],
|
|
187
|
+
) -> Mapping[str, Any]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Resolves a mapping-only local OpenAPI reference path against an already-decoded
|
|
191
|
+
document. Paths must begin with `#/`; reference tokens support JSON Pointer's
|
|
192
|
+
`~0` and `~1` escapes. URI-fragment percent-decoding and array traversal are not
|
|
193
|
+
supported.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from ramp_tool_openapi import resolve_local_ref
|
|
197
|
+
|
|
198
|
+
schema = resolve_local_ref(
|
|
199
|
+
"#/components/schemas/CreateCardRequest",
|
|
200
|
+
spec,
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Arguments**
|
|
205
|
+
|
|
206
|
+
- `ref`: Mapping-only local reference path beginning with `#/`.
|
|
207
|
+
- `spec`: OpenAPI document containing the referenced object.
|
|
208
|
+
|
|
209
|
+
**Returns**
|
|
210
|
+
|
|
211
|
+
The referenced OpenAPI object as a mapping.
|
|
212
|
+
|
|
213
|
+
**Raises**
|
|
214
|
+
|
|
215
|
+
- `ValueError` when `ref` is not local.
|
|
216
|
+
- `KeyError` when `ref` cannot be resolved.
|
|
217
|
+
- `TypeError` when `ref` does not resolve to an OpenAPI object.
|
|
218
|
+
|
|
219
|
+
Remote references are not supported.
|
|
220
|
+
|
|
221
|
+
### Returned models
|
|
222
|
+
|
|
223
|
+
The parser returns frozen dataclass models. Their schema and extension values
|
|
224
|
+
remain dictionaries so downstream adapters can consume the original OpenAPI
|
|
225
|
+
data without taking a dependency on another schema library.
|
|
226
|
+
|
|
227
|
+
| Model | Purpose | Fields |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| `ParsedOperation` | One normalized HTTP operation | `operation_key`, `path`, `method`, `operation_id`, `summary`, `description`, `tags`, `platforms`, `alias`, `scopes`, `extensions`, `parameters`, `request_body`, `response`, `raw_operation` |
|
|
230
|
+
| `ParsedParameter` | One path, query, header, or cookie parameter | `name`, `location`, `required`, `schema`, `description` |
|
|
231
|
+
| `ParsedRequestBody` | The selected request media type and schema | `content_type`, `schema`, `schema_name`, `required` |
|
|
232
|
+
| `ParsedResponse` | The selected successful response and schema | `status_code`, `content_type`, `schema`, `schema_name` |
|
|
233
|
+
| `ParameterLocation` | Supported parameter location type | `"path"`, `"query"`, `"header"`, or `"cookie"` |
|
|
234
|
+
|
|
235
|
+
Internal parser helpers, including underscore-prefixed functions, are
|
|
236
|
+
implementation details and should not be imported by downstream consumers.
|
|
237
|
+
|
|
238
|
+
## Security model
|
|
239
|
+
|
|
240
|
+
This library accepts an already-decoded OpenAPI mapping. It does not parse YAML,
|
|
241
|
+
read files, fetch remote references, execute requests, or enforce authorization.
|
|
242
|
+
Only local references beginning with `#/` are supported.
|
|
243
|
+
|
|
244
|
+
`ParsedOperation.security_requirements` preserves OpenAPI's boolean structure: the
|
|
245
|
+
outer tuple contains alternatives, while every scheme inside one alternative must
|
|
246
|
+
be satisfied. `ParsedOperation.scopes` is a convenience union of scopes from OAuth2
|
|
247
|
+
schemes and must not be used as an authorization decision.
|
|
248
|
+
|
|
249
|
+
Operation paths must be absolute and canonical. Percent-encoded paths, backslashes,
|
|
250
|
+
control characters, and `.` or `..` path segments are rejected. `path_prefix`
|
|
251
|
+
selects operations but is not a replacement for authorization in a caller.
|
|
252
|
+
|
|
253
|
+
Reference-object traversal is bounded by `max_reference_depth`, which defaults to
|
|
254
|
+
100. Applications accepting untrusted documents should also enforce an input-size
|
|
255
|
+
limit before decoding JSON.
|
|
256
|
+
|
|
257
|
+
## Non-goals
|
|
258
|
+
|
|
259
|
+
This package is not a general-purpose OpenAPI framework. It does not own:
|
|
260
|
+
|
|
261
|
+
- OpenAPI document loading or full-spec validation
|
|
262
|
+
- remote `$ref` resolution or recursive schema dereferencing
|
|
263
|
+
- CLI command or Pydantic model generation
|
|
264
|
+
- CLI- or MCP-visible naming
|
|
265
|
+
- platform visibility defaults
|
|
266
|
+
- category or tag selection
|
|
267
|
+
- route or command registration
|
|
268
|
+
- request serialization or execution
|
|
269
|
+
|
|
270
|
+
## Development
|
|
271
|
+
|
|
272
|
+
Run the package tests with:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
uv run --group test python -m pytest -q
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Status
|
|
279
|
+
|
|
280
|
+
The initial `0.x` API is provisional until both `ramp-cli` and
|
|
281
|
+
`ramp-mcp-remote` have migrated to the shared normalization layer. Add links to
|
|
282
|
+
both consumer migration PRs here before treating the API as stable.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "hatchling.build"
|
|
3
|
+
requires = ["hatchling>=1.27,<2"]
|
|
4
|
+
|
|
5
|
+
[dependency-groups]
|
|
6
|
+
test = [
|
|
7
|
+
"pytest>=9.0.3,<10"
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[project]
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Ramp", email = "developer@ramp.com"}
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Typing :: Typed"
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
description = "Shared OpenAPI normalization for Ramp tool surfaces"
|
|
24
|
+
keywords = ["openapi", "tooling"]
|
|
25
|
+
license = "MIT"
|
|
26
|
+
license-files = ["LICENSE"]
|
|
27
|
+
name = "ramp-tool-openapi"
|
|
28
|
+
readme = "README.md"
|
|
29
|
+
requires-python = ">=3.11"
|
|
30
|
+
version = "0.1.1"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
only-include = ["ramp_tool_openapi"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["ramp_tool_openapi"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
pythonpath = ["."]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .models import ParameterLocation as ParameterLocation
|
|
2
|
+
from .models import ParsedOperation as ParsedOperation
|
|
3
|
+
from .models import ParsedParameter as ParsedParameter
|
|
4
|
+
from .models import ParsedRequestBody as ParsedRequestBody
|
|
5
|
+
from .models import ParsedResponse as ParsedResponse
|
|
6
|
+
from .models import (
|
|
7
|
+
ParsedSecurityRequirement as ParsedSecurityRequirement,
|
|
8
|
+
)
|
|
9
|
+
from .models import (
|
|
10
|
+
ParsedSecuritySchemeRequirement as ParsedSecuritySchemeRequirement,
|
|
11
|
+
)
|
|
12
|
+
from .parser import (
|
|
13
|
+
parse_openapi_operations as parse_openapi_operations,
|
|
14
|
+
)
|
|
15
|
+
from .schema import resolve_local_ref as resolve_local_ref
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Neutral OpenAPI operation models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
ParameterLocation = Literal["path", "query", "header", "cookie"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ParsedParameter:
|
|
13
|
+
name: str
|
|
14
|
+
location: ParameterLocation
|
|
15
|
+
required: bool
|
|
16
|
+
schema: dict[str, Any]
|
|
17
|
+
description: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ParsedRequestBody:
|
|
22
|
+
content_type: str
|
|
23
|
+
schema: dict[str, Any]
|
|
24
|
+
schema_name: str
|
|
25
|
+
required: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ParsedResponse:
|
|
30
|
+
status_code: str
|
|
31
|
+
content_type: str
|
|
32
|
+
schema: dict[str, Any]
|
|
33
|
+
schema_name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ParsedSecuritySchemeRequirement:
|
|
38
|
+
"""One security scheme within an OpenAPI security requirement."""
|
|
39
|
+
|
|
40
|
+
scheme_name: str
|
|
41
|
+
scopes: tuple[str, ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ParsedSecurityRequirement:
|
|
46
|
+
"""Schemes that must all be satisfied for one security alternative."""
|
|
47
|
+
|
|
48
|
+
schemes: tuple[ParsedSecuritySchemeRequirement, ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class ParsedOperation:
|
|
53
|
+
operation_key: str
|
|
54
|
+
path: str
|
|
55
|
+
method: str
|
|
56
|
+
operation_id: str | None
|
|
57
|
+
summary: str | None
|
|
58
|
+
description: str | None
|
|
59
|
+
tags: tuple[str, ...]
|
|
60
|
+
platforms: tuple[str, ...]
|
|
61
|
+
alias: str | None
|
|
62
|
+
security_requirements: tuple[ParsedSecurityRequirement, ...]
|
|
63
|
+
scopes: tuple[str, ...]
|
|
64
|
+
extensions: dict[str, Any]
|
|
65
|
+
parameters: tuple[ParsedParameter, ...]
|
|
66
|
+
request_body: ParsedRequestBody | None
|
|
67
|
+
response: ParsedResponse | None
|
|
68
|
+
raw_operation: dict[str, Any]
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Dependency-free OpenAPI operation parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
|
|
9
|
+
from .models import (
|
|
10
|
+
ParsedOperation,
|
|
11
|
+
ParsedParameter,
|
|
12
|
+
ParsedRequestBody,
|
|
13
|
+
ParsedResponse,
|
|
14
|
+
ParsedSecurityRequirement,
|
|
15
|
+
ParsedSecuritySchemeRequirement,
|
|
16
|
+
)
|
|
17
|
+
from .schema import resolve_local_ref, schema_ref_name
|
|
18
|
+
|
|
19
|
+
_DEFAULT_REQUEST_CONTENT_TYPES = ("application/json", "multipart/form-data")
|
|
20
|
+
_DEFAULT_RESPONSE_CONTENT_TYPES = ("application/json",)
|
|
21
|
+
_DEFAULT_MAX_REFERENCE_DEPTH = 100
|
|
22
|
+
_HTTP_METHODS = frozenset({"delete", "get", "patch", "post", "put"})
|
|
23
|
+
_PARAMETER_LOCATIONS = frozenset({"cookie", "header", "path", "query"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_http_method(method: str) -> bool:
|
|
27
|
+
return method in _HTTP_METHODS
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_path(path: str, *, field_name: str = "path") -> None:
|
|
31
|
+
if not path.startswith("/"):
|
|
32
|
+
raise ValueError(f"OpenAPI {field_name} must start with '/': {path!r}")
|
|
33
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in path):
|
|
34
|
+
raise ValueError(f"OpenAPI {field_name} contains control characters: {path!r}")
|
|
35
|
+
if "\\" in path:
|
|
36
|
+
raise ValueError(f"OpenAPI {field_name} contains a backslash: {path!r}")
|
|
37
|
+
if unquote(path) != path:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"OpenAPI {field_name} must not contain percent-encoding: {path!r}"
|
|
40
|
+
)
|
|
41
|
+
path_parts = path.split("/")
|
|
42
|
+
if "" in path_parts[1:-1]:
|
|
43
|
+
raise ValueError(f"OpenAPI {field_name} contains an empty segment: {path!r}")
|
|
44
|
+
if any(part in {".", ".."} for part in path_parts):
|
|
45
|
+
raise ValueError(f"OpenAPI {field_name} contains a dot segment: {path!r}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _path_matches_prefix(path: str, path_prefix: str) -> bool:
|
|
49
|
+
if path_prefix == "/":
|
|
50
|
+
return True
|
|
51
|
+
normalized_prefix = path_prefix.rstrip("/")
|
|
52
|
+
return path == normalized_prefix or path.startswith(f"{normalized_prefix}/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _operation_tags(operation: Mapping[str, Any]) -> tuple[str, ...]:
|
|
56
|
+
tags = operation.get("tags")
|
|
57
|
+
if not isinstance(tags, Sequence) or isinstance(tags, (str, bytes)):
|
|
58
|
+
return ()
|
|
59
|
+
return tuple(tag for tag in tags if isinstance(tag, str))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _operation_platforms(operation: Mapping[str, Any]) -> tuple[str, ...]:
|
|
63
|
+
platforms = operation.get("x-platforms")
|
|
64
|
+
if isinstance(platforms, str):
|
|
65
|
+
return (platforms,)
|
|
66
|
+
if isinstance(platforms, Sequence) and not isinstance(platforms, (str, bytes)):
|
|
67
|
+
return tuple(item for item in platforms if isinstance(item, str))
|
|
68
|
+
return ()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_security_requirements(
|
|
72
|
+
operation: Mapping[str, Any],
|
|
73
|
+
spec: Mapping[str, Any],
|
|
74
|
+
) -> tuple[ParsedSecurityRequirement, ...]:
|
|
75
|
+
security_source = operation if "security" in operation else spec
|
|
76
|
+
if "security" not in security_source:
|
|
77
|
+
return ()
|
|
78
|
+
security = security_source["security"]
|
|
79
|
+
if not isinstance(security, Sequence) or isinstance(security, (str, bytes)):
|
|
80
|
+
raise ValueError("OpenAPI security requirements must be an array")
|
|
81
|
+
|
|
82
|
+
parsed_requirements: list[ParsedSecurityRequirement] = []
|
|
83
|
+
for requirement in security:
|
|
84
|
+
if not isinstance(requirement, Mapping):
|
|
85
|
+
raise ValueError("Each OpenAPI security requirement must be an object")
|
|
86
|
+
schemes: list[ParsedSecuritySchemeRequirement] = []
|
|
87
|
+
for scheme_name, raw_scopes in requirement.items():
|
|
88
|
+
if not isinstance(scheme_name, str) or not scheme_name:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"OpenAPI security scheme names must be non-empty strings"
|
|
91
|
+
)
|
|
92
|
+
if not isinstance(raw_scopes, Sequence) or isinstance(
|
|
93
|
+
raw_scopes, (str, bytes)
|
|
94
|
+
):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"OpenAPI security scopes for {scheme_name!r} must be an array"
|
|
97
|
+
)
|
|
98
|
+
if not all(isinstance(scope, str) for scope in raw_scopes):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"OpenAPI security scopes for {scheme_name!r} must be strings"
|
|
101
|
+
)
|
|
102
|
+
schemes.append(
|
|
103
|
+
ParsedSecuritySchemeRequirement(
|
|
104
|
+
scheme_name=scheme_name,
|
|
105
|
+
scopes=tuple(raw_scopes),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
parsed_requirements.append(ParsedSecurityRequirement(schemes=tuple(schemes)))
|
|
109
|
+
return tuple(parsed_requirements)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _oauth_scheme_names(
|
|
113
|
+
spec: Mapping[str, Any],
|
|
114
|
+
*,
|
|
115
|
+
max_reference_depth: int,
|
|
116
|
+
) -> frozenset[str]:
|
|
117
|
+
components = spec.get("components")
|
|
118
|
+
if not isinstance(components, Mapping):
|
|
119
|
+
return frozenset({"oauth2"})
|
|
120
|
+
security_schemes = components.get("securitySchemes")
|
|
121
|
+
if not isinstance(security_schemes, Mapping):
|
|
122
|
+
return frozenset({"oauth2"})
|
|
123
|
+
oauth_scheme_names: set[str] = set()
|
|
124
|
+
for name, scheme in security_schemes.items():
|
|
125
|
+
if not isinstance(name, str) or not isinstance(scheme, Mapping):
|
|
126
|
+
continue
|
|
127
|
+
resolved_scheme = _resolve_reference_object(
|
|
128
|
+
scheme,
|
|
129
|
+
spec,
|
|
130
|
+
max_reference_depth=max_reference_depth,
|
|
131
|
+
)
|
|
132
|
+
if resolved_scheme.get("type") == "oauth2":
|
|
133
|
+
oauth_scheme_names.add(name)
|
|
134
|
+
return frozenset(oauth_scheme_names)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_oauth_scopes(
|
|
138
|
+
security_requirements: tuple[ParsedSecurityRequirement, ...],
|
|
139
|
+
oauth_scheme_names: frozenset[str],
|
|
140
|
+
) -> tuple[str, ...]:
|
|
141
|
+
scopes: dict[str, None] = {}
|
|
142
|
+
for requirement in security_requirements:
|
|
143
|
+
for scheme in requirement.schemes:
|
|
144
|
+
if scheme.scheme_name in oauth_scheme_names:
|
|
145
|
+
for scope in scheme.scopes:
|
|
146
|
+
scopes[scope] = None
|
|
147
|
+
return tuple(scopes)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _operation_alias(operation: Mapping[str, Any]) -> str | None:
|
|
151
|
+
alias = operation.get("x-alias")
|
|
152
|
+
return alias if isinstance(alias, str) and alias else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _operation_extensions(operation: Mapping[str, Any]) -> dict[str, Any]:
|
|
156
|
+
return {
|
|
157
|
+
key: value
|
|
158
|
+
for key, value in operation.items()
|
|
159
|
+
if isinstance(key, str) and key.startswith("x-")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _combine_parameters(
|
|
164
|
+
path_parameters: Any,
|
|
165
|
+
operation_parameters: Any,
|
|
166
|
+
*,
|
|
167
|
+
spec: Mapping[str, Any] | None = None,
|
|
168
|
+
max_reference_depth: int = _DEFAULT_MAX_REFERENCE_DEPTH,
|
|
169
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
170
|
+
by_key: dict[tuple[str, str], Mapping[str, Any]] = {}
|
|
171
|
+
for raw_parameter in _iter_parameter_mappings(
|
|
172
|
+
path_parameters,
|
|
173
|
+
spec=spec,
|
|
174
|
+
max_reference_depth=max_reference_depth,
|
|
175
|
+
):
|
|
176
|
+
key = _parameter_key(raw_parameter)
|
|
177
|
+
if key is not None:
|
|
178
|
+
by_key[key] = raw_parameter
|
|
179
|
+
for raw_parameter in _iter_parameter_mappings(
|
|
180
|
+
operation_parameters,
|
|
181
|
+
spec=spec,
|
|
182
|
+
max_reference_depth=max_reference_depth,
|
|
183
|
+
):
|
|
184
|
+
key = _parameter_key(raw_parameter)
|
|
185
|
+
if key is not None:
|
|
186
|
+
by_key[key] = raw_parameter
|
|
187
|
+
return tuple(by_key.values())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _extract_parameters(
|
|
191
|
+
raw_parameters: Any,
|
|
192
|
+
*,
|
|
193
|
+
spec: Mapping[str, Any] | None = None,
|
|
194
|
+
max_reference_depth: int = _DEFAULT_MAX_REFERENCE_DEPTH,
|
|
195
|
+
) -> tuple[ParsedParameter, ...]:
|
|
196
|
+
parameters: list[ParsedParameter] = []
|
|
197
|
+
for raw_parameter in _iter_parameter_mappings(
|
|
198
|
+
raw_parameters,
|
|
199
|
+
spec=spec,
|
|
200
|
+
max_reference_depth=max_reference_depth,
|
|
201
|
+
):
|
|
202
|
+
name = raw_parameter.get("name")
|
|
203
|
+
location = raw_parameter.get("in")
|
|
204
|
+
if not isinstance(name, str) or location not in _PARAMETER_LOCATIONS:
|
|
205
|
+
continue
|
|
206
|
+
raw_schema = raw_parameter.get("schema")
|
|
207
|
+
schema = dict(raw_schema) if isinstance(raw_schema, Mapping) else {}
|
|
208
|
+
description = raw_parameter.get("description")
|
|
209
|
+
parameters.append(
|
|
210
|
+
ParsedParameter(
|
|
211
|
+
name=name,
|
|
212
|
+
location=location,
|
|
213
|
+
required=bool(raw_parameter.get("required")),
|
|
214
|
+
schema=schema,
|
|
215
|
+
description=description if isinstance(description, str) else None,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
return tuple(parameters)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _extract_request_body(
|
|
222
|
+
operation: Mapping[str, Any],
|
|
223
|
+
*,
|
|
224
|
+
spec: Mapping[str, Any] | None = None,
|
|
225
|
+
content_types: tuple[str, ...] = _DEFAULT_REQUEST_CONTENT_TYPES,
|
|
226
|
+
max_reference_depth: int = _DEFAULT_MAX_REFERENCE_DEPTH,
|
|
227
|
+
) -> ParsedRequestBody | None:
|
|
228
|
+
request_body = operation.get("requestBody")
|
|
229
|
+
if not isinstance(request_body, Mapping):
|
|
230
|
+
return None
|
|
231
|
+
request_body = _resolve_reference_object(
|
|
232
|
+
request_body,
|
|
233
|
+
spec,
|
|
234
|
+
max_reference_depth=max_reference_depth,
|
|
235
|
+
)
|
|
236
|
+
content = request_body.get("content")
|
|
237
|
+
if not isinstance(content, Mapping):
|
|
238
|
+
return None
|
|
239
|
+
for content_type in content_types:
|
|
240
|
+
media_type = content.get(content_type)
|
|
241
|
+
if not isinstance(media_type, Mapping):
|
|
242
|
+
continue
|
|
243
|
+
schema = media_type.get("schema")
|
|
244
|
+
if isinstance(schema, Mapping):
|
|
245
|
+
return ParsedRequestBody(
|
|
246
|
+
content_type=content_type,
|
|
247
|
+
schema=dict(schema),
|
|
248
|
+
schema_name=schema_ref_name(schema),
|
|
249
|
+
required=bool(request_body.get("required")),
|
|
250
|
+
)
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _extract_response(
|
|
255
|
+
operation: Mapping[str, Any],
|
|
256
|
+
*,
|
|
257
|
+
spec: Mapping[str, Any] | None = None,
|
|
258
|
+
content_types: tuple[str, ...] = _DEFAULT_RESPONSE_CONTENT_TYPES,
|
|
259
|
+
max_reference_depth: int = _DEFAULT_MAX_REFERENCE_DEPTH,
|
|
260
|
+
) -> ParsedResponse | None:
|
|
261
|
+
responses = operation.get("responses")
|
|
262
|
+
if not isinstance(responses, Mapping):
|
|
263
|
+
return None
|
|
264
|
+
for status_code in sorted(responses, key=str):
|
|
265
|
+
if not str(status_code).startswith("2"):
|
|
266
|
+
continue
|
|
267
|
+
response = responses[status_code]
|
|
268
|
+
if not isinstance(response, Mapping):
|
|
269
|
+
continue
|
|
270
|
+
response = _resolve_reference_object(
|
|
271
|
+
response,
|
|
272
|
+
spec,
|
|
273
|
+
max_reference_depth=max_reference_depth,
|
|
274
|
+
)
|
|
275
|
+
content = response.get("content")
|
|
276
|
+
if not isinstance(content, Mapping):
|
|
277
|
+
continue
|
|
278
|
+
for content_type in content_types:
|
|
279
|
+
media_type = content.get(content_type)
|
|
280
|
+
if not isinstance(media_type, Mapping):
|
|
281
|
+
continue
|
|
282
|
+
schema = media_type.get("schema")
|
|
283
|
+
if isinstance(schema, Mapping):
|
|
284
|
+
return ParsedResponse(
|
|
285
|
+
status_code=str(status_code),
|
|
286
|
+
content_type=content_type,
|
|
287
|
+
schema=dict(schema),
|
|
288
|
+
schema_name=schema_ref_name(schema),
|
|
289
|
+
)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def parse_openapi_operations(
|
|
294
|
+
spec: Mapping[str, Any],
|
|
295
|
+
*,
|
|
296
|
+
path_prefix: str | None = None,
|
|
297
|
+
max_reference_depth: int = _DEFAULT_MAX_REFERENCE_DEPTH,
|
|
298
|
+
) -> tuple[ParsedOperation, ...]:
|
|
299
|
+
if max_reference_depth < 1:
|
|
300
|
+
raise ValueError("max_reference_depth must be at least 1")
|
|
301
|
+
if path_prefix is not None:
|
|
302
|
+
_validate_path(path_prefix, field_name="path prefix")
|
|
303
|
+
|
|
304
|
+
operations: list[ParsedOperation] = []
|
|
305
|
+
operation_keys: set[str] = set()
|
|
306
|
+
paths = spec.get("paths")
|
|
307
|
+
if not isinstance(paths, Mapping):
|
|
308
|
+
return ()
|
|
309
|
+
oauth_scheme_names = _oauth_scheme_names(
|
|
310
|
+
spec,
|
|
311
|
+
max_reference_depth=max_reference_depth,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
for path, path_item in paths.items():
|
|
315
|
+
if not isinstance(path, str) or not isinstance(path_item, Mapping):
|
|
316
|
+
continue
|
|
317
|
+
_validate_path(path)
|
|
318
|
+
if path_prefix is not None and not _path_matches_prefix(path, path_prefix):
|
|
319
|
+
continue
|
|
320
|
+
path_parameters = path_item.get("parameters", ())
|
|
321
|
+
for method, operation in path_item.items():
|
|
322
|
+
if not isinstance(method, str):
|
|
323
|
+
continue
|
|
324
|
+
if method.lower() in _HTTP_METHODS and not _is_http_method(method):
|
|
325
|
+
raise ValueError(
|
|
326
|
+
f"OpenAPI HTTP method keys must be lowercase: {method!r}"
|
|
327
|
+
)
|
|
328
|
+
if not _is_http_method(method):
|
|
329
|
+
continue
|
|
330
|
+
if not isinstance(operation, Mapping):
|
|
331
|
+
continue
|
|
332
|
+
normalized_method = method
|
|
333
|
+
operation_key = _operation_key(normalized_method, path)
|
|
334
|
+
if operation_key in operation_keys:
|
|
335
|
+
raise ValueError(f"Duplicate OpenAPI operation key: {operation_key!r}")
|
|
336
|
+
operation_keys.add(operation_key)
|
|
337
|
+
parameters = _combine_parameters(
|
|
338
|
+
path_parameters,
|
|
339
|
+
operation.get("parameters", ()),
|
|
340
|
+
spec=spec,
|
|
341
|
+
max_reference_depth=max_reference_depth,
|
|
342
|
+
)
|
|
343
|
+
security_requirements = _extract_security_requirements(operation, spec)
|
|
344
|
+
operations.append(
|
|
345
|
+
ParsedOperation(
|
|
346
|
+
operation_key=operation_key,
|
|
347
|
+
path=path,
|
|
348
|
+
method=normalized_method,
|
|
349
|
+
operation_id=_string_or_none(operation.get("operationId")),
|
|
350
|
+
summary=_string_or_none(operation.get("summary")),
|
|
351
|
+
description=_string_or_none(operation.get("description")),
|
|
352
|
+
tags=_operation_tags(operation),
|
|
353
|
+
platforms=_operation_platforms(operation),
|
|
354
|
+
alias=_operation_alias(operation),
|
|
355
|
+
security_requirements=security_requirements,
|
|
356
|
+
scopes=_extract_oauth_scopes(
|
|
357
|
+
security_requirements,
|
|
358
|
+
oauth_scheme_names,
|
|
359
|
+
),
|
|
360
|
+
extensions=_operation_extensions(operation),
|
|
361
|
+
parameters=_extract_parameters(
|
|
362
|
+
parameters,
|
|
363
|
+
max_reference_depth=max_reference_depth,
|
|
364
|
+
),
|
|
365
|
+
request_body=_extract_request_body(
|
|
366
|
+
operation,
|
|
367
|
+
spec=spec,
|
|
368
|
+
max_reference_depth=max_reference_depth,
|
|
369
|
+
),
|
|
370
|
+
response=_extract_response(
|
|
371
|
+
operation,
|
|
372
|
+
spec=spec,
|
|
373
|
+
max_reference_depth=max_reference_depth,
|
|
374
|
+
),
|
|
375
|
+
raw_operation=dict(operation),
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
return tuple(operations)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _operation_key(method: str, path: str) -> str:
|
|
382
|
+
return f"{method.lower()} {path}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _iter_parameter_mappings(
|
|
386
|
+
raw_parameters: Any,
|
|
387
|
+
*,
|
|
388
|
+
spec: Mapping[str, Any] | None = None,
|
|
389
|
+
max_reference_depth: int = _DEFAULT_MAX_REFERENCE_DEPTH,
|
|
390
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
391
|
+
if not isinstance(raw_parameters, Sequence) or isinstance(
|
|
392
|
+
raw_parameters, (str, bytes)
|
|
393
|
+
):
|
|
394
|
+
return ()
|
|
395
|
+
return tuple(
|
|
396
|
+
_resolve_reference_object(
|
|
397
|
+
item,
|
|
398
|
+
spec,
|
|
399
|
+
max_reference_depth=max_reference_depth,
|
|
400
|
+
)
|
|
401
|
+
for item in raw_parameters
|
|
402
|
+
if isinstance(item, Mapping)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _resolve_reference_object(
|
|
407
|
+
raw_object: Mapping[str, Any],
|
|
408
|
+
spec: Mapping[str, Any] | None,
|
|
409
|
+
*,
|
|
410
|
+
max_reference_depth: int = _DEFAULT_MAX_REFERENCE_DEPTH,
|
|
411
|
+
) -> Mapping[str, Any]:
|
|
412
|
+
if max_reference_depth < 1:
|
|
413
|
+
raise ValueError("max_reference_depth must be at least 1")
|
|
414
|
+
|
|
415
|
+
current = raw_object
|
|
416
|
+
seen_refs: set[str] = set()
|
|
417
|
+
while True:
|
|
418
|
+
ref = current.get("$ref")
|
|
419
|
+
if not isinstance(ref, str):
|
|
420
|
+
return current
|
|
421
|
+
if spec is None:
|
|
422
|
+
raise ValueError(f"OpenAPI ref {ref!r} cannot be resolved without a spec")
|
|
423
|
+
if ref in seen_refs:
|
|
424
|
+
raise ValueError(f"OpenAPI ref cycle detected for {ref!r}")
|
|
425
|
+
if len(seen_refs) >= max_reference_depth:
|
|
426
|
+
raise ValueError(
|
|
427
|
+
"OpenAPI reference depth exceeds "
|
|
428
|
+
f"the configured maximum of {max_reference_depth}"
|
|
429
|
+
)
|
|
430
|
+
seen_refs.add(ref)
|
|
431
|
+
current = resolve_local_ref(ref, spec)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parameter_key(parameter: Mapping[str, Any]) -> tuple[str, str] | None:
|
|
435
|
+
name = parameter.get("name")
|
|
436
|
+
location = parameter.get("in")
|
|
437
|
+
if isinstance(name, str) and isinstance(location, str):
|
|
438
|
+
return (location, name)
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _string_or_none(value: Any) -> str | None:
|
|
443
|
+
return value if isinstance(value, str) else None
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Small schema helpers for OpenAPI parser adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def schema_ref_name(schema: Mapping[str, Any] | None) -> str:
|
|
10
|
+
if not schema:
|
|
11
|
+
return ""
|
|
12
|
+
ref = schema.get("$ref")
|
|
13
|
+
if isinstance(ref, str):
|
|
14
|
+
return ref.rsplit("/", 1)[-1]
|
|
15
|
+
refs = [
|
|
16
|
+
item["$ref"].rsplit("/", 1)[-1]
|
|
17
|
+
for item in schema.get("allOf", [])
|
|
18
|
+
if isinstance(item, Mapping) and isinstance(item.get("$ref"), str)
|
|
19
|
+
]
|
|
20
|
+
return "+".join(refs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_local_ref(ref: str, spec: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
24
|
+
if not ref.startswith("#/"):
|
|
25
|
+
raise ValueError(f"Only local OpenAPI refs are supported, got {ref!r}")
|
|
26
|
+
|
|
27
|
+
value: Any = spec
|
|
28
|
+
for part in ref.removeprefix("#/").split("/"):
|
|
29
|
+
key = part.replace("~1", "/").replace("~0", "~")
|
|
30
|
+
if not isinstance(value, Mapping) or key not in value:
|
|
31
|
+
raise KeyError(f"OpenAPI ref {ref!r} could not be resolved")
|
|
32
|
+
value = value[key]
|
|
33
|
+
if not isinstance(value, Mapping):
|
|
34
|
+
raise TypeError(f"OpenAPI ref {ref!r} did not resolve to an object")
|
|
35
|
+
return value
|