ramp-tool-openapi 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -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,9 @@
1
+ ramp_tool_openapi/__init__.py,sha256=sridHlIM33CN74s5WdFelKkT_E1kDDvMIJrpT3wf9Nk,602
2
+ ramp_tool_openapi/models.py,sha256=O2U9WzmfP4V_EuXUNWU2Q8JKipw8pHBvvuqPoZdgvc8,1656
3
+ ramp_tool_openapi/parser.py,sha256=U6raR9KpZigsO5pAO-P6U8MikVB6WQUSBvGUtsA7uPw,16165
4
+ ramp_tool_openapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ ramp_tool_openapi/schema.py,sha256=euawUBROnRwIIUlRnCb62CHhOSlefyl5ryHLrutOAjs,1174
6
+ ramp_tool_openapi-0.1.1.dist-info/METADATA,sha256=7e_rMg0ybxKocjnQyTG3X5BxjY2gXeQizSzs74HxLIw,9709
7
+ ramp_tool_openapi-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ ramp_tool_openapi-0.1.1.dist-info/licenses/LICENSE,sha256=imb0UxRMPJC4ghxRvEh1_7BFkgRNliBiEHSoHwyalR4,1089
9
+ ramp_tool_openapi-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.