otterapi 0.0.5__py3-none-any.whl → 0.0.6__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.
- README.md +581 -8
- otterapi/__init__.py +73 -0
- otterapi/cli.py +327 -29
- otterapi/codegen/__init__.py +115 -0
- otterapi/codegen/ast_utils.py +134 -5
- otterapi/codegen/client.py +1271 -0
- otterapi/codegen/codegen.py +1736 -0
- otterapi/codegen/dataframes.py +392 -0
- otterapi/codegen/emitter.py +473 -0
- otterapi/codegen/endpoints.py +2597 -343
- otterapi/codegen/pagination.py +1026 -0
- otterapi/codegen/schema.py +593 -0
- otterapi/codegen/splitting.py +1397 -0
- otterapi/codegen/types.py +1345 -0
- otterapi/codegen/utils.py +180 -1
- otterapi/config.py +1017 -24
- otterapi/exceptions.py +231 -0
- otterapi/openapi/__init__.py +46 -0
- otterapi/openapi/v2/__init__.py +86 -0
- otterapi/openapi/v2/spec.json +1607 -0
- otterapi/openapi/v2/v2.py +1776 -0
- otterapi/openapi/v3/__init__.py +131 -0
- otterapi/openapi/v3/spec.json +1651 -0
- otterapi/openapi/v3/v3.py +1557 -0
- otterapi/openapi/v3_1/__init__.py +133 -0
- otterapi/openapi/v3_1/spec.json +1411 -0
- otterapi/openapi/v3_1/v3_1.py +798 -0
- otterapi/openapi/v3_2/__init__.py +133 -0
- otterapi/openapi/v3_2/spec.json +1666 -0
- otterapi/openapi/v3_2/v3_2.py +777 -0
- otterapi/tests/__init__.py +3 -0
- otterapi/tests/fixtures/__init__.py +455 -0
- otterapi/tests/test_ast_utils.py +680 -0
- otterapi/tests/test_codegen.py +610 -0
- otterapi/tests/test_dataframe.py +1038 -0
- otterapi/tests/test_exceptions.py +493 -0
- otterapi/tests/test_openapi_support.py +616 -0
- otterapi/tests/test_openapi_upgrade.py +215 -0
- otterapi/tests/test_pagination.py +1101 -0
- otterapi/tests/test_splitting_config.py +319 -0
- otterapi/tests/test_splitting_integration.py +427 -0
- otterapi/tests/test_splitting_resolver.py +512 -0
- otterapi/tests/test_splitting_tree.py +525 -0
- otterapi-0.0.6.dist-info/METADATA +627 -0
- otterapi-0.0.6.dist-info/RECORD +48 -0
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
- otterapi/codegen/generator.py +0 -358
- otterapi/codegen/openapi_processor.py +0 -27
- otterapi/codegen/type_generator.py +0 -559
- otterapi-0.0.5.dist-info/METADATA +0 -54
- otterapi-0.0.5.dist-info/RECORD +0 -16
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""Schema loading and resolution utilities for OpenAPI documents.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for:
|
|
4
|
+
- Loading OpenAPI schemas from various sources (URLs, local files, YAML/JSON)
|
|
5
|
+
- Automatic version detection and upgrading of older OpenAPI/Swagger specifications
|
|
6
|
+
- Resolving $ref references and managing schema lookups in OpenAPI documents
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urljoin, urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import TypeAdapter
|
|
18
|
+
|
|
19
|
+
from otterapi.exceptions import (
|
|
20
|
+
SchemaLoadError,
|
|
21
|
+
SchemaReferenceError,
|
|
22
|
+
SchemaValidationError,
|
|
23
|
+
)
|
|
24
|
+
from otterapi.openapi import UniversalOpenAPI
|
|
25
|
+
from otterapi.openapi.v3_2 import Reference, Schema
|
|
26
|
+
from otterapi.openapi.v3_2.v3_2 import OpenAPI as OpenAPIv3
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
'SchemaLoader',
|
|
32
|
+
'SchemaResolver',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# Schema Loader
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SchemaLoader:
|
|
42
|
+
"""Loads OpenAPI schemas from URLs or file paths.
|
|
43
|
+
|
|
44
|
+
This class provides a unified interface for loading OpenAPI schemas
|
|
45
|
+
from different sources (HTTP URLs or local files), supporting both
|
|
46
|
+
JSON and YAML formats. It also handles automatic version detection
|
|
47
|
+
and upgrading of Swagger 2.0 and older OpenAPI 3.x specifications.
|
|
48
|
+
|
|
49
|
+
Features:
|
|
50
|
+
- Load from URLs (http/https) or local file paths
|
|
51
|
+
- Support for both JSON and YAML formats
|
|
52
|
+
- Automatic version detection (Swagger 2.0, OpenAPI 3.0, 3.1, 3.2)
|
|
53
|
+
- Automatic upgrade to OpenAPI 3.2 for code generation
|
|
54
|
+
- External $ref resolution for URLs and relative files
|
|
55
|
+
- Caching of loaded external schemas
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> loader = SchemaLoader()
|
|
59
|
+
>>> schema = loader.load('https://api.example.com/openapi.json')
|
|
60
|
+
>>> # or
|
|
61
|
+
>>> schema = loader.load('/path/to/openapi.yaml')
|
|
62
|
+
>>> # or with external ref resolution
|
|
63
|
+
>>> loader = SchemaLoader(resolve_external_refs=True)
|
|
64
|
+
>>> schema = loader.load('./api.yaml')
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
http_client: httpx.Client | None = None,
|
|
70
|
+
resolve_external_refs: bool = False,
|
|
71
|
+
base_path: str | Path | None = None,
|
|
72
|
+
):
|
|
73
|
+
"""Initialize the schema loader.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
http_client: Optional HTTP client to use for URL requests.
|
|
77
|
+
If not provided, a default client will be created.
|
|
78
|
+
resolve_external_refs: Whether to resolve external $ref references.
|
|
79
|
+
If True, external URLs and relative file paths
|
|
80
|
+
will be loaded and inlined.
|
|
81
|
+
base_path: Base path for resolving relative file references.
|
|
82
|
+
Defaults to current working directory.
|
|
83
|
+
"""
|
|
84
|
+
self._http_client = http_client
|
|
85
|
+
self._resolve_external_refs = resolve_external_refs
|
|
86
|
+
self._base_path = Path(base_path) if base_path else Path.cwd()
|
|
87
|
+
self._external_cache: dict[str, dict] = {}
|
|
88
|
+
self._upgrade_warnings: list[str] = []
|
|
89
|
+
|
|
90
|
+
def load(self, source: str) -> OpenAPIv3:
|
|
91
|
+
"""Load and validate an OpenAPI schema from a URL or file path.
|
|
92
|
+
|
|
93
|
+
This method:
|
|
94
|
+
1. Loads the content from the source (URL or file)
|
|
95
|
+
2. Parses JSON or YAML content
|
|
96
|
+
3. Detects the OpenAPI version
|
|
97
|
+
4. Optionally resolves external $ref references
|
|
98
|
+
5. Upgrades older versions to OpenAPI 3.2
|
|
99
|
+
6. Validates the final schema
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
source: URL or file path to the OpenAPI schema.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Validated OpenAPIv3 (3.2) object.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
SchemaLoadError: If the schema cannot be loaded from the source.
|
|
109
|
+
SchemaValidationError: If the schema is not valid OpenAPI.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
if self._is_url(source):
|
|
113
|
+
content = self._load_from_url(source)
|
|
114
|
+
self._current_base_url = source.rsplit('/', 1)[0] + '/'
|
|
115
|
+
else:
|
|
116
|
+
content = self._load_from_file(source)
|
|
117
|
+
self._current_base_url = None
|
|
118
|
+
source_path = Path(source)
|
|
119
|
+
if source_path.is_absolute():
|
|
120
|
+
self._base_path = source_path.parent
|
|
121
|
+
else:
|
|
122
|
+
self._base_path = (Path.cwd() / source_path).parent
|
|
123
|
+
|
|
124
|
+
if self._resolve_external_refs:
|
|
125
|
+
content = self._resolve_refs(content, source)
|
|
126
|
+
|
|
127
|
+
return self._validate_and_upgrade(content, source)
|
|
128
|
+
|
|
129
|
+
except SchemaLoadError:
|
|
130
|
+
raise
|
|
131
|
+
except SchemaValidationError:
|
|
132
|
+
raise
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise SchemaLoadError(source, cause=e)
|
|
135
|
+
|
|
136
|
+
def get_upgrade_warnings(self) -> list[str]:
|
|
137
|
+
"""Get any warnings generated during schema upgrade."""
|
|
138
|
+
return self._upgrade_warnings.copy()
|
|
139
|
+
|
|
140
|
+
def get_detected_version(self, content: dict) -> str:
|
|
141
|
+
"""Detect the OpenAPI/Swagger version from schema content."""
|
|
142
|
+
if 'swagger' in content:
|
|
143
|
+
return content.get('swagger', '2.0')
|
|
144
|
+
openapi_version = content.get('openapi', '3.0.0')
|
|
145
|
+
if openapi_version.startswith('3.0'):
|
|
146
|
+
return '3.0'
|
|
147
|
+
elif openapi_version.startswith('3.1'):
|
|
148
|
+
return '3.1'
|
|
149
|
+
elif openapi_version.startswith('3.2'):
|
|
150
|
+
return '3.2'
|
|
151
|
+
return openapi_version
|
|
152
|
+
|
|
153
|
+
def _is_url(self, text: str) -> bool:
|
|
154
|
+
"""Check if a string is a URL."""
|
|
155
|
+
try:
|
|
156
|
+
result = urlparse(text)
|
|
157
|
+
return result.scheme in ('http', 'https')
|
|
158
|
+
except ValueError:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def _load_from_url(self, url: str) -> dict:
|
|
162
|
+
"""Load schema content from a URL."""
|
|
163
|
+
try:
|
|
164
|
+
if self._http_client:
|
|
165
|
+
response = self._http_client.get(url)
|
|
166
|
+
else:
|
|
167
|
+
response = httpx.get(url, follow_redirects=True, timeout=30.0)
|
|
168
|
+
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
content_type = response.headers.get('content-type', '')
|
|
171
|
+
content = response.text
|
|
172
|
+
|
|
173
|
+
if 'yaml' in content_type or url.endswith(('.yaml', '.yml')):
|
|
174
|
+
return yaml.safe_load(content)
|
|
175
|
+
else:
|
|
176
|
+
return json.loads(content)
|
|
177
|
+
|
|
178
|
+
except httpx.HTTPError as e:
|
|
179
|
+
raise SchemaLoadError(url, cause=e)
|
|
180
|
+
except (json.JSONDecodeError, yaml.YAMLError) as e:
|
|
181
|
+
raise SchemaLoadError(url, cause=e)
|
|
182
|
+
|
|
183
|
+
def _load_from_file(self, file_path: str) -> dict:
|
|
184
|
+
"""Load schema content from a file."""
|
|
185
|
+
path = Path(file_path)
|
|
186
|
+
if not path.is_absolute():
|
|
187
|
+
path = self._base_path / path
|
|
188
|
+
|
|
189
|
+
if not path.exists():
|
|
190
|
+
raise SchemaLoadError(
|
|
191
|
+
str(file_path), cause=FileNotFoundError(f'File not found: {path}')
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
content = path.read_text(encoding='utf-8')
|
|
196
|
+
if path.suffix.lower() in ('.yaml', '.yml'):
|
|
197
|
+
return yaml.safe_load(content)
|
|
198
|
+
else:
|
|
199
|
+
return json.loads(content)
|
|
200
|
+
except (json.JSONDecodeError, yaml.YAMLError) as e:
|
|
201
|
+
raise SchemaLoadError(str(file_path), cause=e)
|
|
202
|
+
except OSError as e:
|
|
203
|
+
raise SchemaLoadError(str(file_path), cause=e)
|
|
204
|
+
|
|
205
|
+
def _resolve_refs(self, content: dict, source: str) -> dict:
|
|
206
|
+
"""Resolve all external $ref references in the schema."""
|
|
207
|
+
return self._resolve_refs_recursive(content, source, set())
|
|
208
|
+
|
|
209
|
+
def _resolve_refs_recursive(self, obj: Any, base: str, visited: set[str]) -> Any:
|
|
210
|
+
"""Recursively resolve external references."""
|
|
211
|
+
if isinstance(obj, dict):
|
|
212
|
+
if '$ref' in obj:
|
|
213
|
+
ref = obj['$ref']
|
|
214
|
+
if not ref.startswith('#'):
|
|
215
|
+
return self._resolve_external_ref(ref, base, visited)
|
|
216
|
+
return {
|
|
217
|
+
k: self._resolve_refs_recursive(v, base, visited)
|
|
218
|
+
for k, v in obj.items()
|
|
219
|
+
}
|
|
220
|
+
elif isinstance(obj, list):
|
|
221
|
+
return [self._resolve_refs_recursive(item, base, visited) for item in obj]
|
|
222
|
+
return obj
|
|
223
|
+
|
|
224
|
+
def _resolve_external_ref(self, ref: str, base: str, visited: set[str]) -> dict:
|
|
225
|
+
"""Resolve an external $ref reference."""
|
|
226
|
+
if '#' in ref:
|
|
227
|
+
file_part, pointer = ref.split('#', 1)
|
|
228
|
+
else:
|
|
229
|
+
file_part, pointer = ref, ''
|
|
230
|
+
|
|
231
|
+
if self._is_url(file_part):
|
|
232
|
+
location = file_part
|
|
233
|
+
elif self._is_url(base):
|
|
234
|
+
location = urljoin(base, file_part)
|
|
235
|
+
else:
|
|
236
|
+
base_path = Path(base).parent if not Path(base).is_dir() else Path(base)
|
|
237
|
+
location = str(base_path / file_part)
|
|
238
|
+
|
|
239
|
+
cache_key = f'{location}#{pointer}'
|
|
240
|
+
if cache_key in visited:
|
|
241
|
+
logger.warning(f'Circular reference detected: {cache_key}')
|
|
242
|
+
return {'$ref': ref}
|
|
243
|
+
|
|
244
|
+
visited = visited | {cache_key}
|
|
245
|
+
|
|
246
|
+
if location not in self._external_cache:
|
|
247
|
+
try:
|
|
248
|
+
if self._is_url(location):
|
|
249
|
+
self._external_cache[location] = self._load_from_url(location)
|
|
250
|
+
else:
|
|
251
|
+
self._external_cache[location] = self._load_from_file(location)
|
|
252
|
+
except SchemaLoadError:
|
|
253
|
+
logger.warning(f'Failed to resolve external reference: {ref}')
|
|
254
|
+
return {'$ref': ref}
|
|
255
|
+
|
|
256
|
+
content = self._external_cache[location]
|
|
257
|
+
|
|
258
|
+
if pointer:
|
|
259
|
+
content = self._resolve_json_pointer(content, pointer)
|
|
260
|
+
|
|
261
|
+
return self._resolve_refs_recursive(content, location, visited)
|
|
262
|
+
|
|
263
|
+
def _resolve_json_pointer(self, obj: Any, pointer: str) -> Any:
|
|
264
|
+
"""Resolve a JSON pointer within an object."""
|
|
265
|
+
if not pointer or pointer == '/':
|
|
266
|
+
return obj
|
|
267
|
+
|
|
268
|
+
parts = pointer.strip('/').split('/')
|
|
269
|
+
current = obj
|
|
270
|
+
|
|
271
|
+
for part in parts:
|
|
272
|
+
part = part.replace('~1', '/').replace('~0', '~')
|
|
273
|
+
if isinstance(current, dict):
|
|
274
|
+
if part not in current:
|
|
275
|
+
raise ValueError(f'JSON pointer path not found: {pointer}')
|
|
276
|
+
current = current[part]
|
|
277
|
+
elif isinstance(current, list):
|
|
278
|
+
try:
|
|
279
|
+
index = int(part)
|
|
280
|
+
current = current[index]
|
|
281
|
+
except (ValueError, IndexError):
|
|
282
|
+
raise ValueError(f'JSON pointer path not found: {pointer}')
|
|
283
|
+
else:
|
|
284
|
+
raise ValueError(f'JSON pointer path not found: {pointer}')
|
|
285
|
+
|
|
286
|
+
return current
|
|
287
|
+
|
|
288
|
+
def _validate_and_upgrade(self, content: dict, source: str) -> OpenAPIv3:
|
|
289
|
+
"""Validate and upgrade schema content to OpenAPI 3.2."""
|
|
290
|
+
self._upgrade_warnings = []
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
universal: UniversalOpenAPI = TypeAdapter(UniversalOpenAPI).validate_python(
|
|
294
|
+
content
|
|
295
|
+
)
|
|
296
|
+
schema = universal.root
|
|
297
|
+
|
|
298
|
+
if isinstance(schema, OpenAPIv3):
|
|
299
|
+
return schema
|
|
300
|
+
|
|
301
|
+
while not isinstance(schema, OpenAPIv3):
|
|
302
|
+
schema, warnings = schema.upgrade()
|
|
303
|
+
self._upgrade_warnings.extend(warnings)
|
|
304
|
+
|
|
305
|
+
return schema
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
raise SchemaValidationError(source, errors=[str(e)])
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# =============================================================================
|
|
312
|
+
# Schema Resolver
|
|
313
|
+
# =============================================================================
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class SchemaResolver:
|
|
317
|
+
"""Resolves $ref references and manages schema lookups in OpenAPI documents.
|
|
318
|
+
|
|
319
|
+
This class provides a centralized way to resolve JSON References ($ref) in
|
|
320
|
+
OpenAPI documents, supporting both local references (within the same document)
|
|
321
|
+
and caching resolved references for performance.
|
|
322
|
+
|
|
323
|
+
Example:
|
|
324
|
+
>>> resolver = SchemaResolver(openapi_doc)
|
|
325
|
+
>>> schema, name = resolver.resolve_reference(ref)
|
|
326
|
+
>>> all_schemas = resolver.get_all_schemas()
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
def __init__(self, openapi: OpenAPIv3):
|
|
330
|
+
"""Initialize the schema resolver.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
openapi: The OpenAPI document to resolve references from.
|
|
334
|
+
"""
|
|
335
|
+
self.openapi = openapi
|
|
336
|
+
self._cache: dict[str, tuple[Schema, str]] = {}
|
|
337
|
+
|
|
338
|
+
def resolve_reference(self, reference: Reference | Schema) -> tuple[Schema, str]:
|
|
339
|
+
"""Resolve a $ref reference to its schema and name.
|
|
340
|
+
|
|
341
|
+
If the input is already a Schema (not a Reference), it is returned as-is
|
|
342
|
+
with its title as the name (if available).
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
reference: A Reference or Schema object to resolve.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Tuple of (resolved Schema, schema name).
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
SchemaReferenceError: If the reference cannot be resolved.
|
|
352
|
+
"""
|
|
353
|
+
if isinstance(reference, Schema):
|
|
354
|
+
name = (
|
|
355
|
+
reference.title
|
|
356
|
+
if hasattr(reference, 'title') and reference.title
|
|
357
|
+
else None
|
|
358
|
+
)
|
|
359
|
+
return reference, self._sanitize_name(name) if name else None
|
|
360
|
+
|
|
361
|
+
ref_str = reference.ref
|
|
362
|
+
|
|
363
|
+
# Check cache first
|
|
364
|
+
if ref_str in self._cache:
|
|
365
|
+
return self._cache[ref_str]
|
|
366
|
+
|
|
367
|
+
# Resolve the reference
|
|
368
|
+
schema, name = self._resolve_ref_string(ref_str)
|
|
369
|
+
|
|
370
|
+
# Cache the result
|
|
371
|
+
self._cache[ref_str] = (schema, name)
|
|
372
|
+
|
|
373
|
+
return schema, name
|
|
374
|
+
|
|
375
|
+
def _resolve_ref_string(self, ref: str) -> tuple[Schema, str]:
|
|
376
|
+
"""Resolve a $ref string to its schema.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
ref: The $ref string (e.g., '#/components/schemas/Pet').
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Tuple of (resolved Schema, schema name).
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
SchemaReferenceError: If the reference format is unsupported or
|
|
386
|
+
the referenced schema doesn't exist.
|
|
387
|
+
"""
|
|
388
|
+
# Handle local references
|
|
389
|
+
if ref.startswith('#/'):
|
|
390
|
+
return self._resolve_local_reference(ref)
|
|
391
|
+
|
|
392
|
+
# Handle external references (not yet supported)
|
|
393
|
+
if ref.startswith('http://') or ref.startswith('https://'):
|
|
394
|
+
raise SchemaReferenceError(
|
|
395
|
+
ref,
|
|
396
|
+
'External URL references are not yet supported. '
|
|
397
|
+
'Consider inlining the referenced schema.',
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if ref.startswith('./') or ref.startswith('../'):
|
|
401
|
+
raise SchemaReferenceError(
|
|
402
|
+
ref,
|
|
403
|
+
'Relative file references are not yet supported. '
|
|
404
|
+
'Consider using a tool to bundle your OpenAPI spec.',
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
raise SchemaReferenceError(ref, 'Unknown reference format')
|
|
408
|
+
|
|
409
|
+
def _resolve_local_reference(self, ref: str) -> tuple[Schema, str]:
|
|
410
|
+
"""Resolve a local JSON Pointer reference.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
ref: The local reference (e.g., '#/components/schemas/Pet').
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Tuple of (resolved Schema, schema name).
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
SchemaReferenceError: If the reference path is invalid or
|
|
420
|
+
the schema doesn't exist.
|
|
421
|
+
"""
|
|
422
|
+
# Parse the JSON Pointer
|
|
423
|
+
if not ref.startswith('#/'):
|
|
424
|
+
raise SchemaReferenceError(ref, 'Local reference must start with #/')
|
|
425
|
+
|
|
426
|
+
path_parts = ref[2:].split('/')
|
|
427
|
+
|
|
428
|
+
# Currently only support components/schemas references
|
|
429
|
+
if (
|
|
430
|
+
len(path_parts) >= 3
|
|
431
|
+
and path_parts[0] == 'components'
|
|
432
|
+
and path_parts[1] == 'schemas'
|
|
433
|
+
):
|
|
434
|
+
schema_name = path_parts[2]
|
|
435
|
+
return self._get_component_schema(schema_name, ref)
|
|
436
|
+
|
|
437
|
+
# Support components/parameters references
|
|
438
|
+
if (
|
|
439
|
+
len(path_parts) >= 3
|
|
440
|
+
and path_parts[0] == 'components'
|
|
441
|
+
and path_parts[1] == 'parameters'
|
|
442
|
+
):
|
|
443
|
+
raise SchemaReferenceError(
|
|
444
|
+
ref,
|
|
445
|
+
'Parameter references should be resolved through the parameter resolver',
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Support components/responses references
|
|
449
|
+
if (
|
|
450
|
+
len(path_parts) >= 3
|
|
451
|
+
and path_parts[0] == 'components'
|
|
452
|
+
and path_parts[1] == 'responses'
|
|
453
|
+
):
|
|
454
|
+
raise SchemaReferenceError(
|
|
455
|
+
ref,
|
|
456
|
+
'Response references should be resolved through the response resolver',
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
raise SchemaReferenceError(
|
|
460
|
+
ref,
|
|
461
|
+
'Unsupported reference path. Only #/components/schemas/... is currently supported.',
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _get_component_schema(self, schema_name: str, ref: str) -> tuple[Schema, str]:
|
|
465
|
+
"""Get a schema from the components/schemas section.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
schema_name: The name of the schema in components/schemas.
|
|
469
|
+
ref: The original reference string (for error messages).
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
Tuple of (Schema, sanitized schema name).
|
|
473
|
+
|
|
474
|
+
Raises:
|
|
475
|
+
SchemaReferenceError: If the schema doesn't exist.
|
|
476
|
+
"""
|
|
477
|
+
if not self.openapi.components:
|
|
478
|
+
raise SchemaReferenceError(
|
|
479
|
+
ref,
|
|
480
|
+
'Document has no components section',
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if not self.openapi.components.schemas:
|
|
484
|
+
raise SchemaReferenceError(
|
|
485
|
+
ref,
|
|
486
|
+
'Document has no schemas in components',
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
schemas = self.openapi.components.schemas
|
|
490
|
+
|
|
491
|
+
if schema_name not in schemas:
|
|
492
|
+
available = ', '.join(sorted(schemas.keys())[:10])
|
|
493
|
+
if len(schemas) > 10:
|
|
494
|
+
available += f', ... ({len(schemas)} total)'
|
|
495
|
+
raise SchemaReferenceError(
|
|
496
|
+
ref,
|
|
497
|
+
f"Schema '{schema_name}' not found. Available schemas: {available}",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return schemas[schema_name], self._sanitize_name(schema_name)
|
|
501
|
+
|
|
502
|
+
def get_all_schemas(self) -> dict[str, Schema]:
|
|
503
|
+
"""Get all schemas defined in the components/schemas section.
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
Dictionary mapping schema names to Schema objects.
|
|
507
|
+
Returns an empty dict if no schemas are defined.
|
|
508
|
+
"""
|
|
509
|
+
if not self.openapi.components or not self.openapi.components.schemas:
|
|
510
|
+
return {}
|
|
511
|
+
return dict(self.openapi.components.schemas)
|
|
512
|
+
|
|
513
|
+
def get_schema_names(self) -> list[str]:
|
|
514
|
+
"""Get the names of all schemas in the document.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
List of schema names, sorted alphabetically.
|
|
518
|
+
"""
|
|
519
|
+
return sorted(self.get_all_schemas().keys())
|
|
520
|
+
|
|
521
|
+
def has_schema(self, name: str) -> bool:
|
|
522
|
+
"""Check if a schema exists in the document.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
name: The schema name to check.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
True if the schema exists, False otherwise.
|
|
529
|
+
"""
|
|
530
|
+
return name in self.get_all_schemas()
|
|
531
|
+
|
|
532
|
+
def clear_cache(self) -> None:
|
|
533
|
+
"""Clear the reference resolution cache."""
|
|
534
|
+
self._cache.clear()
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _sanitize_name(name: str | None) -> str | None:
|
|
538
|
+
"""Sanitize a name to be a valid Python identifier.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
name: The name to sanitize.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Sanitized name, or None if input is None.
|
|
545
|
+
"""
|
|
546
|
+
if name is None:
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
# Import here to avoid circular imports
|
|
550
|
+
from otterapi.codegen.utils import sanitize_identifier
|
|
551
|
+
|
|
552
|
+
return sanitize_identifier(name)
|
|
553
|
+
|
|
554
|
+
def resolve_all_refs_in_schema(self, schema: Schema | Reference) -> Schema:
|
|
555
|
+
"""Recursively resolve all $ref references in a schema.
|
|
556
|
+
|
|
557
|
+
This is useful when you need a fully resolved schema without
|
|
558
|
+
any remaining references.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
schema: The schema (potentially containing references) to resolve.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
The resolved schema with all references expanded.
|
|
565
|
+
|
|
566
|
+
Note:
|
|
567
|
+
This does not modify the original schema; references are
|
|
568
|
+
resolved on access. For deeply nested schemas, this may
|
|
569
|
+
result in multiple resolutions of the same reference.
|
|
570
|
+
"""
|
|
571
|
+
if isinstance(schema, Reference):
|
|
572
|
+
resolved, _ = self.resolve_reference(schema)
|
|
573
|
+
return resolved
|
|
574
|
+
return schema
|
|
575
|
+
|
|
576
|
+
def get_reference_target(self, ref: str) -> str:
|
|
577
|
+
"""Extract the target name from a $ref string.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
ref: The $ref string (e.g., '#/components/schemas/Pet').
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
The target name (e.g., 'Pet').
|
|
584
|
+
|
|
585
|
+
Raises:
|
|
586
|
+
SchemaReferenceError: If the reference format is invalid.
|
|
587
|
+
"""
|
|
588
|
+
if not ref.startswith('#/components/schemas/'):
|
|
589
|
+
raise SchemaReferenceError(
|
|
590
|
+
ref,
|
|
591
|
+
'Can only extract target from #/components/schemas/ references',
|
|
592
|
+
)
|
|
593
|
+
return ref.split('/')[-1]
|